// Flask + Azure OpenAI · multi-tenant SaaS · RAG over per-client catalogues

EventQuoter

A multi-tenant AI quotation assistant for AV event-production companies. A sales rep describes a brief in plain English; the system extracts the requirements, decides deterministically what kind of quote it is, prices it against that client's own catalogue, retrieves similar historical quotes for context, and writes a client-ready reply — then can push the result straight into Rentman or CurrentRMS.

Flask / Gunicorn Azure OpenAI (GPT-4.1 family) Azure AI Search (RAG) Azure Blob Storage PostgreSQL + SQLAlchemy Rentman + CurrentRMS APIs Azure App Service (OIDC deploy)

What It Is

AV event companies lose time and deals to a slow quoting process: a rep gets a brief, manually looks up prices, and spends 30-60 minutes building a quote before a faster competitor has already replied. EventQuoter replaces that with a chat interface backed by a deterministic pricing engine — the AI never invents a price, it only extracts intent and writes prose around numbers Python has already computed.

It's genuinely multi-tenant: every client company is an AssistantGroup row carrying its own Azure OpenAI deployment config, its own RMS integration (Rentman or CurrentRMS, selected per group), its own Azure AI Search index for RAG, and its own VAT rate and currency. Reps and the equipment catalogue they see are scoped to their group through a plain SQLAlchemy foreign key — there's no separate per-tenant infrastructure to provision, just a row and a config.

Historical quotes and the equipment catalogue are synced to Azure Blob Storage and indexed in Azure AI Search per group, so when a new enquiry comes in, the system retrieves similar past opportunities and real product data as grounding context before the AI writes anything — a plain Azure AI Search REST call with semantic ranking, not a hand-rolled vector pipeline.

Extract → Decide → Price → Write (Not One AI Call)

extract_quote_data()→ determine_quote_mode()→ compute_quote()→ AI writer (grounded in priced JSON + RAG)
1

Extraction — AI, but constrained to structured JSON only

extract_quote_data() sends the raw enquiry text to Azure OpenAI with a fixed extraction prompt and a strict output schema (client_name, event_type, attendance, specific_equipment_requested, can_generate_estimate, etc). If the model's response fails to parse as JSON, the code falls back to safe defaults with request_type: "clarification" rather than letting a malformed response propagate into pricing.

2

Mode decision — plain Python, not a second model call

determine_quote_mode() is an ordinary function with if/else branches over the extracted fields: equipment listed with no event context becomes equipment_hire (dry hire); event type plus venue or date plus attendance becomes full_quote; anything short of that becomes clarification. This is the one decision in the whole pipeline that's genuinely deterministic and unit-testable — whether to quote at all, and what kind of quote, never depends on model sampling.

3

Pricing — deterministic, fuzzy-matched against the real catalogue

pricing_engine.compute_quote() matches each requested item against that group's InventoryItem table (exact name match, then substring, then word-overlap fallback), pulls its default PriceBookLine, and computes the line total honoring rate type (per-day/week/hour), bundle pricing, discounts and minimum-hire charges — then applies the group's own VAT rate and currency symbol. Unmatched items are returned flagged rather than silently priced at zero-and-hidden, and the response carries a confidence score (matched lines ÷ total lines) so a low-confidence quote is visibly low-confidence.

4

Writing — the only stage the AI is trusted with prose

The final stage hands the AI the already-priced line items plus RAG context pulled from Azure AI Search (similar historical opportunities and matching products for that group) and asks it to write the client-facing reply. By the time the model is composing sentences, every number in the quote has already been decided by code — the AI's job is strictly: explain this priced quote well, don't invent one.

Tech Stack

Backend
Flask 3.1App, routing, Jinja2 templates
Flask-Login 0.6Session auth, "basic" protection mode
Flask-SQLAlchemy 3.1ORM over Postgres
Gunicorn 23Production WSGI, 4 workers
Flask-DanceOAuth client scaffolding
Database
Azure DB for PostgreSQLProduction DB via AZURE_DATABASE_URL
psycopg2-binaryDriver
pool_pre_ping + pool_recycle=300Connection health, avoids stale connections
Azure AI platform
Azure OpenAIopenai SDK, per-group deployment/model config
Azure AI SearchREST API, semantic ranking with text-search fallback
Azure Blob StoragePer-group JSON docs: products + historical opportunities
azure-identityCredential handling for Azure SDKs
External integrations
Rentman APIHostname-allowlisted REST client
CurrentRMS APIAlternative RMS, selected per group
Twilio / SendGridSMS / transactional email
smtplib (Gmail)App-password-based mail fallback
Deployment
Azure App Serviceeventquoter-api-eu, Linux/Python
GitHub ActionsOIDC login (azure/login@v2), push-to-deploy on azure-prod
Oryx buildApp Service builds from requirements.txt server-side

Architecture & Design Decisions

01

Multi-tenancy as one table, not one deployment per client

Every client is a single AssistantGroup row — its own Azure OpenAI endpoint/key/model/deployment, its own RMS type and credentials, its own Azure Search index name, its own VAT rate and currency symbol. Users, inventory and quotes all carry a group_id foreign key. Onboarding a new client is inserting a row and populating their catalogue, not standing up new infrastructure — the trade-off is that every tenant's config, including per-tenant API keys, lives in the same Postgres database, so the ORM's own group_id filtering is the actual tenant boundary.

02

RAG is plain Azure AI Search REST, with a semantic-search fallback built in

search_index() calls the Search REST API directly and requests semantic ranking by default; if Azure returns 400 because semantic search isn't configured for that index, it transparently retries with plain text search instead of failing the whole request. retrieve_rag_context() builds its query from the extracted enquiry's own fields (event type, venue, client name, description) rather than the raw user message, so retrieval is already somewhat targeted before Azure's ranking runs.

03

Blob Storage as the RAG source-of-truth sync, not a live query

azure_blob_helper.upload_rms_data_to_blob() formats every active inventory product and every historical opportunity (quote) for a group into its own JSON document — products/<group_id>/<external_id>.json, opportunities/<group_id>/<external_id>.json — and uploads it to that group's blob container, creating the container on first use. This is a deliberate sync step rather than querying Postgres live at retrieval time, so Azure AI Search indexes a stable, intentionally-shaped document rather than raw table rows.

04

Fuzzy inventory matching with three widening passes, never a silent miss

find_inventory_match() tries an exact (case-insensitive) name match first, then substring containment either direction, then a word-overlap heuristic (two or more shared words, or one shared word if the catalogue item's name is itself short). An item that still doesn't match isn't dropped — it's returned in the quote with matched: false and zero price, and feeds into the quote's overall confidence score, so a rep sees exactly which lines need a manual price rather than a quote that looks complete but silently missed something.

05

RMS clients are hostname-allowlisted, not arbitrary-URL REST wrappers

rentman_api.py's validate_rentman_url() rejects any base URL whose scheme isn't https or whose host isn't exactly api.rentman.net before a request is ever made — even though the base URL is a per-group database field, a group record can't be used to redirect the server's outbound Rentman calls anywhere else. Every Rentman call routes through this validator, not just the ones a developer remembered to check.

06

Login rate limiting and lockout, implemented in-process

A module-level defaultdict tracks failed login timestamps per username within a 10-minute rolling window; 10 failures trigger a 15-minute lockout, checked before the password comparison runs at all. It's explicitly in-memory (noted in the code as a known limitation — doesn't survive a restart or scale across workers), alongside is_safe_redirect_url(), which validates any post-login next redirect target against the request's own host to block open-redirect attacks, and an email whitelist (EmailWhitelist table) gating registration entirely — there's no public self-serve signup.

07

Flask-Login session protection deliberately set to "basic", not "strong"

Flask-Login's "strong" mode invalidates a session the moment the client's apparent IP changes — which happens routinely behind a reverse proxy whose forwarded headers shift between requests, logging real users out mid-session for no actual security benefit. "basic" mode is used instead, with session fixation still prevented by calling session.clear() before every login_user() — a documented, deliberate trade-off rather than an oversight.

08

Security headers and request-size limits applied globally

An after_request hook attaches X-Content-Type-Options: nosniff, X-Frame-Options: SAMEORIGIN, a strict Referrer-Policy, and a Permissions-Policy that blocks geolocation and camera outright while scoping microphone to same-origin (for voice input) on every single response — not opted into per-route. MAX_CONTENT_LENGTH is capped at 16MB server-wide specifically to cover voice-input audio and chat payloads without leaving the limit unbounded.

09

Environment-aware config instead of hardcoded production assumptions

The database URL prefers AZURE_DATABASE_URL and falls back to the original Replit DATABASE_URL — the app literally migrated hosting providers without a hard cutover. Cookie security (SESSION_COOKIE_SECURE), SERVER_NAME and PREFERRED_URL_SCHEME are all gated behind a single PRODUCTION flag, so local development never accidentally inherits HTTPS-only cookie behaviour.

10

Deployment: OIDC login, not a stored Azure publish profile

The GitHub Actions workflow authenticates to Azure with azure/login@v2 using federated OIDC credentials (client ID, tenant ID, subscription ID as repo secrets) rather than a long-lived publish-profile secret, then deploys via azure/webapps-deploy@v3 to the eventquoter-api-eu App Service's Production slot on every push to the azure-prod branch. Azure's own Oryx build engine runs pip install server-side from requirements.txt rather than shipping a pre-built virtualenv in the deployment artifact.

A Few of the Actual Guards

# rentman_api.py — every outbound Rentman call passes through this first ALLOWED_RENTMAN_HOSTS = {'api.rentman.net'} def validate_rentman_url(base_url): parsed = urlparse(base_url or RENTMAN_BASE_URL) if parsed.scheme != 'https' or parsed.hostname not in ALLOWED_RENTMAN_HOSTS: raise ValueError(f'Invalid Rentman base URL...') return parsed.geturl().rstrip('/')
ConcernWhere / how
Open redirect after loginis_safe_redirect_url() validates next against the request's own host before any redirect
Brute-force loginIn-process rolling-window counter: 10 failures / 10 min → 15-min lockout, checked before password compare
Uncontrolled registrationEmailWhitelist table gates account creation; no public signup form
SSRF via per-tenant RMS configRentman base URL validated to scheme https + exact host before every request
Session fixationsession.clear() before every login_user() call
Oversized requestsMAX_CONTENT_LENGTH = 16MB applied Flask-wide
Clickjacking / MIME sniffingX-Frame-Options, X-Content-Type-Options set on every response via after_request
Device permissionsPermissions-Policy blocks geolocation/camera outright, scopes mic to same-origin

Architecture Decision Records

The design decisions above weren't just picked — six are written up as formal ADRs (context, options considered, trade-off analysis, consequences, action items), because in an interview "why did you choose X" is a much stronger answer when you can also say "here's what I rejected and why, and here's the risk I knowingly accepted." Two patterns run through all six: AI is used only where the task is genuinely ambiguous, and every "logical" guarantee (a tenant filter, a schema check) is called out as a risk accepted, not a risk eliminated — with a named follow-up action for the gap.

01

ADR-001 — Shared index + tenant_id filter over one index per tenant Accepted

Chose a single shared Azure AI Search index with every chunk tagged tenant_id and every query OData-filtered on it, over a dedicated index per client. Cheaper and instant to onboard, but the isolation guarantee is logical (code must always apply the filter) rather than physical (separate infra). Explicitly flagged as a risk accepted given the current stage — action item: an automated test asserting every retrieval path applies the filter, plus a hybrid model (dedicated index for high-security accounts) as the revisit trigger.

02

ADR-002 — Deterministic 3-stage pipeline over one LLM call Accepted

The first version was a single prompt doing extraction, pricing and writing together — it shipped, then hallucinated prices and equipment that didn't exist in the catalogue. Replaced with the Extract → Decide → Price → Write split described above. Not a hypothetical comparison: Option B is the version that was actually tried and empirically failed, which is what makes the general rule defensible in an interview — AI for ambiguous language in/out, deterministic code wherever correctness is non-negotiable.

03

ADR-003 — Three-layer Extractor validation, with an honest coverage gap Accepted, known gaps

Schema-constrained model output, Pydantic validation, and RAG-grounded equipment matching catch malformed or out-of-catalogue extractions before they reach pricing. What it doesn't catch: a well-formed, in-catalogue extraction that's still factually wrong (right item, wrong quantity). Today's real backstop for that is the rep's manual review — stated plainly as a limitation, not papered over, which is exactly what ADR-006 below was written to close.

04

ADR-004 — Managed Identity over static API keys Accepted, one exception

System-assigned Managed Identity + least-privilege RBAC for Azure OpenAI, Azure AI Search and Key Vault means no standing key exists for any of them to leak. One deliberate exception remains: PostgreSQL Flexible Server still authenticates with a static connection string (stored in Key Vault, fetched once at startup) because migrating it to Azure AD auth wasn't part of this build — named directly as the answer to "what would you improve."

05

ADR-005 — App Service over containerizing onto AKS Accepted

One Flask app, one engineer operating it, no polyglot service mix — App Service's native Managed Identity support directly simplified ADR-004, while AKS would have bought cluster operations, networking and workload-identity setup for a scaling problem the product doesn't have yet. Framed as right-sizing the platform to the current stage, not a rejection of containers if the product later splits into independently-scaled services.

06

ADR-006 — Automated Extractor regression suite Proposed, not yet built

Direct response to the gap ADR-003 named: ~20-30 hand-verified briefs covering simple, ambiguous, out-of-catalogue and multi-event cases, compared with field-level rules (exact match on numbers, synonym-tolerant on categories, set comparison on equipment lists), wired into the existing GitHub Actions pipeline as a pre-deploy gate, and grown from real production failures over time. Good interview answer to "how do you know a prompt change didn't break anything" — the honest version is "we don't yet, this is the proposed fix," not a claim it's already solved.

Operating at Scale (UHG, Bank of America, AIB)

EventQuoter is the clearest end-to-end AI product to walk through, but the data-engineering discipline behind its deterministic pricing stage — real rows in, validated types, no silent bad data — is the same discipline applied at much larger scale on enterprise data projects: multi-billion-row data mapping and migration work for UnitedHealth Group and Bank of America, and multi-billion-euro financial data migrations for AIB. Worth bringing up when a role asks about scale or enterprise delivery, since none of that shows up in a single-engineer SaaS product's codebase.

A

Data mapping across billions of rows (UHG, Bank of America)

Source-to-target field mapping and transformation logic for datasets at a scale where "just eyeball the diff" isn't an option — the same impulse as EventQuoter's fuzzy inventory matcher, but applied to reconciling heterogeneous source schemas against a target model: define exact-match rules first, fall back to pattern/heuristic matching for ambiguous fields, and flag anything below a confidence threshold for manual review rather than silently auto-mapping it wrong.

B

Data migrations at multi-billion-euro scale (AIB)

Migrating financial data of this volume and sensitivity means validation and reconciliation isn't optional — row counts, checksums and sampled field-level diffs between source and target at every stage of the migration, with a rollback path defined before the migration runs, not improvised after something looks wrong. The EventQuoter pattern of "never let unvalidated data reach the next stage silently" is the same engineering instinct, just applied where the unit at risk is a bank balance rather than a quote line item.

C

CI/CD via GitHub Actions auto-deployment

The same push-to-deploy discipline as EventQuoter's own azure-prod GitHub Actions workflow (OIDC login, no stored publish-profile secret) carries across — automated pipelines triggered on merge, environment-gated promotion rather than manual deploys, and deployment credentials handled via federated identity rather than long-lived secrets sitting in CI config.

Q&A

Q
Why did you build this?
I was working with AV event companies and saw the same bottleneck everywhere: a good rep spending an hour building a quote by hand while a faster competitor had already replied. I wanted to build it as a real multi-tenant product rather than a single-company tool, which pushed real decisions around tenant isolation, per-client configuration and API integration that a one-off script never forces you to make.
Q
What was the hardest problem you solved?
Keeping the AI out of the one place it can't be trusted: the price. Early versions let the model reason about pricing directly and it would occasionally invent a plausible-sounding number. The fix was architectural — extraction and writing are AI, but mode decision and every line-item total are plain, unit-testable Python. The model only ever sees a quote after it's been fully priced.
Q
Walk me through how authentication works.
Flask-Login manages the session; passwords are hashed with Werkzeug's PBKDF2. Login goes through an in-memory rate limiter first — 10 failures in a 10-minute window locks that username out for 15 minutes before the password is even checked. Session protection is set to "basic" rather than "strong" specifically because Replit's (and similarly configured) reverse-proxy infrastructure changes the apparent client IP between requests, which "strong" mode would read as session hijacking and log people out for no reason. Registration is additionally gated by an email whitelist table — there's no open signup at all.
Q
How does the RAG / retrieval piece actually work?
Each client's equipment catalogue and historical quotes are formatted as JSON documents and synced to that client's own Azure Blob Storage container, then indexed into a dedicated Azure AI Search index per group — the index name itself has to be unique per client to prevent cross-contamination. At quote time, the extracted enquiry fields (event type, venue, client name) become the search query; search_index() requests semantic ranking and, if the index isn't configured for it, transparently falls back to plain text search rather than failing.
Q
How is multi-tenancy actually enforced?
There's one shared Postgres schema. AssistantGroup is the tenant row — it carries the Azure OpenAI deployment, the RMS credentials, the Search index name, VAT rate and currency — and every User, InventoryItem and Quote carries a group_id foreign key that every query filters on. It's a simpler, cheaper model than provisioning separate infrastructure per tenant, with the trade-off that the ORM's filtering discipline is the actual isolation boundary, so every new query has to remember to scope by group.
Q
Why Rentman and CurrentRMS specifically, and how do you support both?
They're the two rental management systems AV companies actually use. Each AssistantGroup has an active_rms field selecting which integration applies, with separate credential fields for each. The Rentman client additionally hard-validates that every outbound call targets https://api.rentman.net exactly — since the base URL is technically a per-tenant database field, that validation stops a misconfigured or compromised tenant record from redirecting the server's own outbound requests anywhere else.
Q
What would you improve next?
The login rate limiter and lockout tracking are in-process memory, which is fine on a single Gunicorn worker but doesn't share state across workers or survive a restart — moving that to Redis is the obvious next step. I'd also want a proper job queue for the Azure OpenAI calls so a slow model response doesn't hold a worker thread, the same lesson as most synchronous-AI-call architectures eventually hit.
Q
Why write ADRs for a solo project?
Because the decisions themselves don't change whether one person or ten made them — what matters is being able to show the reasoning. Each ADR records the options actually considered, not just the one picked, and is honest about what's a real guarantee versus an accepted risk (ADR-001's logical-not-physical tenant isolation, ADR-003's un-caught semantic extraction errors, ADR-004's one remaining static secret). That's a stronger interview answer than "it just works" — it shows the trade-off was seen and deliberately accepted, with a named follow-up.
Q
Have you worked at a bigger scale than this?
Yes — EventQuoter is the clearest product to walk through end-to-end, but I've also done multi-billion-row data mapping and migration work for UnitedHealth Group and Bank of America, and multi-billion-euro financial data migrations for AIB, with CI/CD via GitHub Actions auto-deployment across those projects too. The underlying discipline is the same one on display in EventQuoter's pricing engine: validate before trusting, fall back to confidence-scored heuristics rather than silent guesses, and never let unvalidated data reach the next stage unflagged — just applied where the data volume and the cost of being wrong are both much higher.
Q
What did you learn building this?
That the trustworthy parts of an AI product are usually the parts that aren't AI. The pricing engine, the mode decision, the URL validation on outbound API calls — none of that is a model call, and that's exactly why it's reliable. The AI's job narrowed over time to exactly two things: turn messy English into structured data, and turn structured, already-correct data back into good English.