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.
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)
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.
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.
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.
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
Architecture & Design Decisions
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
| Concern | Where / how |
|---|---|
| Open redirect after login | is_safe_redirect_url() validates next against the request's own host before any redirect |
| Brute-force login | In-process rolling-window counter: 10 failures / 10 min → 15-min lockout, checked before password compare |
| Uncontrolled registration | EmailWhitelist table gates account creation; no public signup form |
| SSRF via per-tenant RMS config | Rentman base URL validated to scheme https + exact host before every request |
| Session fixation | session.clear() before every login_user() call |
| Oversized requests | MAX_CONTENT_LENGTH = 16MB applied Flask-wide |
| Clickjacking / MIME sniffing | X-Frame-Options, X-Content-Type-Options set on every response via after_request |
| Device permissions | Permissions-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.
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.
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.
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.
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."
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.
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.
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.
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.
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
search_index() requests semantic ranking and, if the index isn't configured for it, transparently falls back to plain text search rather than failing.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.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.