Replaces logoutRedirect with clearCache() so the user returns to the app login screen without being signed out of their Microsoft account. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|---|---|---|
| backend | ||
| config | ||
| deploy | ||
| frontend | ||
| .env.example | ||
| .gitignore | ||
| docker-compose.yml | ||
| README.md | ||
OLIVER Sales Operations Platform
End-to-end RFP → mobilization pipeline for OLIVER's commercial team. Drop in a brief, walk it through 17 stages (intake → qualification → asset matching → ratecard → delivery model → team shape → caveats → approval gates → pitch → post-win planning), and produce a defensible proposal.
V2 of the GMAL Scope Builder. Phase 1 covers stages 1–16; stage 17 (downstream system push to Salesforce / SharePoint) is deferred to Phase 2.
Table of contents
- What it does
- The 17 stages
- Quick start (local dev)
- Deployment (optical-dev)
- Architecture
- Per-stage agent reference
- Cross-cutting concerns
- Testing
- Repo layout
What it does
A single Opportunity (the V1 "Project") progresses through a 17-stage state machine. Each stage either:
- runs a specialised Claude agent over upstream artifacts and produces structured JSON,
- captures a human decision (qualification scorecard, approval, deal status), or
- exports a deliverable (Q&A pack, ratecard, pitch deck markdown).
Stages cannot be skipped — /stages/{n}/complete enforces the order. Two stages (3 — Qualification, 14 — Approval Gate) require explicit Approval rows to be approved before they'll close. Approvals fan out via in-app notifications and Mailgun email.
Every Claude call records its cost on the produced stage_artifact (tokens in / out / USD) so the per-stage spend is visible in the UI.
The 17 stages
| # | Stage | Status | Driver |
|---|---|---|---|
| 1 | Intake Opportunity | ✅ | Claude (Intake Agent) |
| 2 | Read & Diagnose Brief | ✅ | Claude (Diagnosis Agent) |
| 3 | Qualification Assessment | ✅ gated | Human (TROWLS) + approval |
| 4 | Generate Client Q&A Pack | ✅ | Excel/Word export |
| 5 | Ingest Client Answers | ✅ | Human edit |
| 6 | Normalize Asset List | ✅ | Claude (Asset Normalizer) |
| 7 | Match Assets to Job Routes | ✅ | Claude (Match Agent) |
| 8 | Build Asset-Level Rate Card | ✅ | Pure-Python (GMAL hours × volume) |
| 9 | Recommend Delivery Model | ✅ | Claude (Delivery Model Agent) |
| 10 | Apply Efficiency Logic | ✅ | Human (sliders, persisted) |
| 11 | Create Draft Team Shape | ✅ | Pure-Python (FTE calc) |
| 12 | Identify Capability Gaps | ✅ | Claude (Capability Gap Agent) |
| 13 | Generate Support Docs | ✅ | Claude (Support Docs Agent) |
| 14 | Validation & Approval Gates | ✅ gated | Approval flow |
| 15 | Build Pitch Materials | ✅ | Claude (Pitch Deck Agent) + markdown stub |
| 16 | Delivery Planning (post-win) | ✅ | Claude (Implementation Plan Agent) |
| 17 | Trigger Downstream Systems | ⏳ Phase 2 | Salesforce / SharePoint push |
Quick start (local dev)
git clone git@bitbucket.org:zlalani/oliver-sales-ops-platform.git
cd oliver-sales-ops-platform
cp .env.example .env
$EDITOR .env # set ANTHROPIC_API_KEY at minimum
# Local dev mode: brings up db + redis + backend + Vite dev container
COMPOSE_PROFILES=dev docker compose up -d
# Runs at:
# http://localhost:3011/oliver-sales-ops-platform/ ← frontend (Vite HMR)
# http://localhost:8003/api/health ← backend
# localhost:5435 / localhost:6380 ← postgres / redis
.env defaults give you DEV_AUTH_BYPASS=true so you skip the MSAL login gate and land as the admin user (admin@oliver.agency). The backend's auth middleware reads DEV_AUTH_EMAIL / DEV_AUTH_NAME / DEV_AUTH_ROLE.
Smoke test
curl http://localhost:8003/api/health
# {"status":"ok","db":"ok"}
curl http://localhost:8003/api/users/me
# {"id":N,"email":"admin@oliver.agency","name":"OSOP Admin","role":"admin",...}
Tests
python3 -m venv /tmp/osop_test_venv
/tmp/osop_test_venv/bin/pip install -r backend/requirements-dev.txt psycopg2-binary
cd backend && /tmp/osop_test_venv/bin/pytest tests/ -v
# 118 tests, 109 pass, 9 skip (real-Anthropic), 0 fail
Deployment (optical-dev)
The dev server hosts a stack of internal apps under one Apache vhost, each at its own URL prefix and backend port. This app sits at /oliver-sales-ops-platform/.
sudo git clone git@bitbucket.org:zlalani/oliver-sales-ops-platform.git /opt/oliver-sales-ops-platform
cd /opt/oliver-sales-ops-platform
sudo cp .env.example .env && sudo $EDITOR .env
# → set ANTHROPIC_API_KEY
# → set APP_PUBLIC_URL=https://optical-dev.oliver.solutions
# → leave DEV_AUTH_BYPASS=true until SSO is wired
sudo ./deploy/deploy.sh
What deploy.sh does:
- Auto-picks free host ports (
OSOP_DB_PORT/OSOP_REDIS_PORT/OSOP_BACKEND_PORT). If 8003 is taken, it scans 8004→8099 and persists the chosen port back to.env. - Renders
deploy/apache-osop.conffromapache-osop.conf.tmplwith the chosen backend port substituted in. git pull && docker compose build && up -d(db + redis + backend).- Builds the Vite SPA in a one-shot
node:20container, syncs thedist/to/var/www/html/oliver-sales-ops-platform/. PipesDEV_AUTH_BYPASSthrough toVITE_DEV_AUTH_BYPASSso the SPA matches the backend's auth setting. - Polls
/api/healthuntil ready, prints URLs + admin email.
Flags: --no-pull, --no-build, --no-frontend, --logs.
Apache wiring (one-time):
echo 'Include /opt/oliver-sales-ops-platform/deploy/apache-osop.conf' \
| sudo tee -a /etc/apache2/sites-enabled/optical-dev.oliver.solutions.conf
sudo apachectl configtest && sudo systemctl reload apache2
The conf is regenerated on every deploy. If the backend port changes (because something else grabbed 8003), deploy.sh will tell you to reload Apache again.
Architecture
Backend. FastAPI + async SQLAlchemy + Alembic on Python 3.12. Postgres 16 for state, Redis for future Celery work. Anthropic SDK against Claude Opus 4.7. Azure JWT validation via python-jose; DEV_AUTH_BYPASS=true swaps the JWT path for a configurable identity (used until SSO is wired on the dev server).
Frontend. React 18 + Vite + TypeScript. TanStack Query for server state, React Router for the per-stage views. MSAL (Azure SSO) is in the box but bypassed on the dev server. Mermaid renders the 17-stage flowchart on the About page.
Stages as a state machine. Every Opportunity has 17 stage_states rows (created on opportunity creation; stage 1 starts in_progress, the rest not_started). /stages/{n}/complete validates the predecessor is completed and, for stages 3 and 14, that all linked Approval rows are approved.
Artifacts. Each agent run persists a stage_artifact row carrying its JSON output + cost stamp (cost_usd / input_tokens / output_tokens). Re-runs produce new artifacts; the UI shows the latest per stage.
Approvals. A user with EDITOR or ADMIN role can request approvals on stages 3 / 14. Each approval gets a unique email_token and fires a Mailgun email with a deeplink. The approver clicks through, lands on /approvals/:id, decides (with optional notes), and the opportunity owner gets notified.
Cost tracking. Per-call usage is rolled up onto the Opportunity (cumulative) and stamped on the stage_artifact (per run). The Stage 8 panel shows the running total; every agent panel header shows its run cost.
Per-stage agent reference
This section is the operator's manual for each stage — what the agent does, what it reads, what it produces, and the rules baked into its prompt.
Stage 1 — Intake Opportunity
Agent: Intake Agent — backend/app/services/intake_agent.py
Tool name: submit_intake_metadata
Endpoint: POST /api/opportunities/{id}/intake
Inputs: all uploaded OpportunityFile rows (concat to ~150k char cap).
Output: structured opportunity metadata.
Output schema: client_name, region, brands[], service_types[], deadline_iso, go_live_iso, summary.
What the agent is told:
You are the Intake Agent for the OLIVER Sales Ops Platform. Your job is to read the documents an account team has uploaded for a new opportunity and extract a tight structured summary so the rest of the pipeline can plan the response.
Be conservative — only fill fields you can ground in the documents. Omit a field rather than guessing. Brands should be specific consumer-facing brands, not the umbrella client. Service types should be concrete categories (Content, eCommerce, Social, CRM, Production, Strategy, etc.) not vague phrases. Dates must be ISO (YYYY-MM-DD); convert phrases like "end of Q2 2026" to a sensible explicit date and mention the original phrase in the summary.
Side effect: populates Opportunity.client_name / region / brands / service_types / deadline / go_live / description ONLY when those fields are blank — manual entries are never overwritten.
Stage 2 — Read & Diagnose Brief
Agent: Diagnosis Agent — backend/app/services/diagnosis_agent.py
Tool name: submit_brief_diagnosis
Endpoint: POST /api/opportunities/{id}/diagnose
Inputs: all uploaded files (same as Stage 1).
Output: structured diagnosis + ≥6 client clarification questions.
Output schema: deliverables[] (name / category / volume_estimate / complexity_hint), channels[], markets[], capabilities_required[], kpis_slas[], tech_asks[], timelines[], ambiguities[], contradictions[], complexity_assessment (low/medium/high), summary, clarifications[] (category / question / rationale / priority red|amber|green).
What the agent is told:
You are the Diagnosis Agent for the OLIVER Sales Ops Platform. The account team has uploaded an RFP / brief and you must read it like a senior strategist preparing a scope.
For deliverables: name specific assets, not vague phrases. "Paid social statics" is better than "social content". Include volume even when fuzzy — capture the brief's own words ("TBC", "~50/month", "on demand").
For ambiguities: anything the brief leaves open that affects effort. "Multiple markets" without a list is an ambiguity. "Standard turnaround" without days is an ambiguity. "Feedback iterations" without a count is an ambiguity.
For contradictions: explicit conflicts (timeline says Q2, go-live says Q1; volumes say 200 in one section and 500 in another).
For clarifications (MANDATORY — minimum 6, target 8–15): ambiguities and contradictions are observations; clarifications are the actual questions you'd send the client to resolve them. Every ambiguity you list MUST also appear here as an actionable, question-marked sentence with a category + priority + rationale. This is what Stage 4 packages into the client Q&A pack — if you leave it empty, the rest of the pipeline breaks.
Side effect: wipes existing source_stage=2 clarification rows and inserts fresh ones. Stage 5 ingests answers against these.
Stage 3 — Qualification Assessment (gated)
Driver: human — no Claude call.
Endpoints:
POST /api/opportunities/{id}/qualification (save TROWLS scorecard)
GET /api/opportunities/{id}/qualification
POST /api/opportunities/{id}/stages/3/approvals (request approval)
POST /api/approvals/{id}/decision (approve / reject)
TROWLS dimensions (each scored 0–10):
- Timing — do we have time to win and deliver?
- Relationship — how well do we know the client / decision-makers?
- Opportunity — deal size, strategic value, future pipeline.
- What We Know — quality of brief, market data, prior knowledge.
- Location — entity availability, hiring viability, labour law fit.
- Sector — sector experience + conflict-list checks.
Total 0–60 → percentage 0–100 → recommendation: proceed (≥60), slt_review (≥50), no_go (<50). Stamped on Opportunity.qualification_score.
Gate. /stages/3/complete 400s unless at least one Approval row exists for stage 3 AND every approval is approved.
Stage 4 — Generate Client Q&A Pack
Driver: pure-Python export — no Claude call.
Endpoints:
GET /api/opportunities/{id}/qa-pack/excel → .xlsx
GET /api/opportunities/{id}/qa-pack/word → .docx
Reads the clarification_questions table seeded by Stage 2, sorts by priority (RED → AMBER → GREEN), groups by category, and exports a populated client-facing pack. Excel has a colour-coded priority column + free-text answer column. Word groups by category with rationale + answer slot per question.
Stage 5 — Ingest Client Answers
Driver: human — no Claude call.
Endpoint: PUT /api/opportunities/{id}/clarifications/{cid}
Per-question editor on the frontend. The user pastes the client's reply into each client_answer field; status auto-flips to answered and answered_at is stamped. Items can also be dismissed (out of scope) or pending again.
Stage 6 — Normalize Asset List
Agent: Asset Normalizer — backend/app/services/asset_normalizer.py
Tool name: submit_normalized_assets
Endpoint: POST /api/opportunities/{id}/assets/normalize
Inputs: uploaded files + Stage 2 diagnosis (when present).
Output: clean ClientAsset[] list.
Output schema: assets[] of { raw_name, raw_description, client_tier, volume }.
What the agent is told:
You are the Asset Normalizer for the OLIVER Sales Ops Platform. Each row is something a creative agency can scope hours against.
Rules:
- One asset per row. If the brief says "toolbox A/B/C", emit three rows (one per tier).
- Use specific names ('PDP hero banner', not 'web content').
- Capture tier letters / bands when the brief uses them. Leave client_tier blank if it doesn't.
- Volume is integer. If the brief says ranges ("100-200"), pick the midpoint. If TBC, set 1 and put "Volume TBC — confirm with client" in the description.
- Do NOT invent assets the brief doesn't mention. Be exhaustive but honest.
Side effect: wipes existing ClientAsset rows for the opportunity (cascading to matches + ratecard_lines), then inserts fresh ones.
Stage 7 — Match Assets to Job Routes
Agent: Match Agent — backend/app/services/ai_matching.py
Tool name: submit_matches
Endpoint: POST /api/opportunities/{id}/match (background task) → GET /matches
Inputs: each ClientAsset + the full GMAL catalog (~243 hour-route entries, ~3-20k tokens depending on AI-enhanced descriptions) + Stage 2 brief context.
Output: up to 3 candidate Match rows per ClientAsset, ranked.
Output schema: matches[] of { gmal_id, confidence (exact|close|multiple|none), confidence_score (0-1), reasoning, caveats }.
What the agent is told:
You are a GMAL asset matching specialist for a creative production agency. You match client-described assets to the closest entry in the GMAL catalog.
Guidelines:
- Match on the TYPE of deliverable first, then complexity.
- Bridge terminology: "KV" / "Key Visual" = Photography GMALs; "PDP" / "product listing" = eCommerce / Copywriting GMALs; "launch video" = Campaign Video GMALs; "social post" = Social GMALs; "banner" / "display" = Display / Standard Banner GMALs.
- Return your single best match. Only add a 2nd/3rd if within 5% of the top score.
- exact: 0.9–1.0. close: 0.6–0.89. none: <0.3.
- Always state caveats — what the GMAL covers vs what the client described.
- Match complexity literally — "simple banner" → Simple GMAL, not Complex.
Side effects:
- Wipes prior matches for this opportunity's assets first.
- Auto-selects the rank-1 match when its score ≥ 0.8.
- Saves a
matching_runstage_artifactwith the run total cost + counts. - Toggling
is_selectedto true on one match auto-deselects siblings (one selection per asset).
Stage 8 — Build Asset-Level Rate Card
Driver: pure-Python — no Claude call.
Service: backend/app/services/ratecard_builder.py
Endpoints:
POST /api/opportunities/{id}/ratecard/build
GET /api/opportunities/{id}/ratecard
For each ClientAsset with a selected match, looks up GmalHours[gmal_asset, model_type] and creates a RatecardLine per role.
Bug-4 invariant (carried forward from V1). RatecardLine.total_hours stores hours per 1 asset (= base_hours); volume lives on the row. Aggregators (this endpoint, team_shape, Excel matrix, frontend) multiply by volume themselves when computing total effort. Tests assert this directly.
Stage 9 — Recommend Delivery Model
Agent: Delivery Model Agent — backend/app/services/delivery_model_agent.py
Tool name: submit_delivery_model
Endpoint: POST /api/opportunities/{id}/delivery-model
Inputs: Stage 2 diagnosis + opportunity's GMAL model_type.
Output: headline + per-workflow-stage breakdown.
Output schema: headline (traditional | ai_supported | hybrid), summary, workflow_stages[] of { stage, approach (manual|ai_supported|fully_automated), tooling[], rationale }, tooling_caveats[], risks[].
What the agent is told:
You are the Delivery Model Agent for the OLIVER Sales Ops Platform. The team has diagnosed the brief; now you recommend HOW the work should be delivered. Be honest about where AI tooling is genuinely productive vs. where it would dent quality.
Tool capability cheat-sheet (as of 2026):
- Pencil: paid-social statics + simple static digital ads. Not motion. Not print.
- Creative-X: brand-policy automated checks across digital assets.
- Semblance: video tooling, mostly cutdowns/edits.
- OMG: media/programmatic dynamic creative.
- Google Vids / Synthesia: simple internal/eLearning motion only.
- Photography / TVC origination / Print mastering: manual.
Mastering and origination are typically manual. Adaptation, localisation, social statics, and digital ad cut-downs are good AI candidates. Approvals, brand QA, and client review stay manual. If the brief includes motion at scale or print, the headline should be "hybrid" or "traditional" — don't oversell AI.
Stage 10 — Apply Efficiency Logic
Driver: human — no Claude call.
Endpoints:
POST /api/opportunities/{id}/efficiency-profile (save)
GET /api/opportunities/{id}/efficiency-profile
UI offers a scenario picker (Conservative / Moderate / Aggressive), a blanket-percentage slider, per-discipline override sliders (capped at 90%), tools-applied chips, and a notes field. Saved as a stage_artifact for the audit trail. Stage 11 reads these to compute team shape.
Programme roles (Programme Director, Head of Project Management, etc.) are never reduced by efficiency, regardless of discipline-level overrides.
Stage 11 — Create Draft Team Shape
Driver: pure-Python — no Claude call.
Service: backend/app/services/team_shape.py
Endpoint: GET /api/opportunities/{id}/team-shape?efficiency_pct=N&discipline_overrides={"Creative":40}
Aggregates RatecardLine hours per role (multiplying base_hours × volume per the bug-4 invariant), divides by HOURS_PER_FTE = 1800, applies efficiency (per-discipline overrides take precedence over the blanket percentage; programme roles always 0%), caps at MAX_EFFICIENCY = 90. Returns the FTE table grouped by discipline.
Stage 12 — Identify Capability Gaps
Agent: Capability Gap Agent — backend/app/services/capability_gap_agent.py
Tool name: submit_capability_gaps
Endpoint: POST /api/opportunities/{id}/capability-gaps
Inputs: Stage 2 diagnosis + opportunity context.
Output: in-scope core list + gaps with sourcing recommendations.
Output schema: core_in_scope[], gaps[] of { capability, criticality (red|amber|green), suggested_source (internal_sme|brandtech_partner|external_vendor), suggested_partner, rationale }, summary.
What the agent is told:
You are the Capability Gap Agent for the OLIVER Sales Ops Platform. OLIVER core capability is: in-house creative + content production (statics, motion, social, eCom, CRM, retail, basic strategy). Things OLIVER typically partners on:
- SEO + organic search → Jellyfish.
- Performance media buying → media-specialist Brandtech partners.
- Social strategy at depth / community management → Gravity Road.
- TVC production at scale → external production company.
- Influencer/talent management → external agencies.
- Localisation in long-tail markets → external translators.
Be specific. If the brief doesn't ask for it, don't list it. Mark criticality honestly: RED = we can't deliver without solving this, AMBER = we should partner but could limp, GREEN = nice-to-have.
Stage 13 — Generate Support Docs
Agent: Support Docs Agent — backend/app/services/support_docs_agent.py
Tool name: submit_support_docs
Endpoint: POST /api/opportunities/{id}/support-docs
Inputs: Stages 2 (diagnosis), 9 (delivery model), 12 (capability gaps).
Output: caveats / assumptions / SLAs / KPIs / governance.
Output schema: caveats[], assumptions[], slas[] (≥3, of { deliverable, v1_days, v2_days, v3_days, responsible_party, notes }), kpis[] (≥3, of { metric, target, measurement }), governance[], summary.
What the agent is told:
You are the Support Docs Agent. Your job is to author the caveats, assumptions, SLAs, KPIs and governance clauses that go into the proposal.
Default SLA pattern (mirrors PARASOL Studio SLAs 2024):
- 24-48h to acknowledge a brief.
- 2 rounds of amends standard.
- Static digital ad (5 assets): V1 5 days, V2 2 days, V3 1 day.
- eCom hero set: V1 7 days, V2 3 days, V3 1 day.
- CRM email template: V1 7 days, V2 3 days, V3 1 day.
- Motion social (one master + adapts): V1 14 days, V2 5 days, V3 2 days. Adjust based on stated timeline pressure and the delivery model.
Caveats default exclusions (carry forward unless brief explicitly includes): third-party fees, stock photography, talent fees, music licensing, shoot/production costs, localisation beyond N markets, languages beyond N. Be specific.
Assumptions: anchor the price. "Brand templates supplied", "masters supplied", "feedback received within 48h", "final approval from a single named decision-maker", etc.
KPIs: be honest about what's measurable. Quality, on-time delivery, % of jobs within SLA, first-time-right rate. Avoid vague aspirations.
Phase 2 will swap the prompt-baked defaults for a real template_library sourced from PARASOL_Studio_SLAs_2024_V1.pptx.
Stage 14 — Validation & Approval Gates (gated)
Driver: approval flow only — no agent.
Endpoints:
POST /api/opportunities/{id}/stages/14/approvals (admin requests an approval per role)
POST /api/approvals/{id}/decision (approver decides)
POST /api/opportunities/{id}/stages/14/complete
Same approval mechanism as Stage 3. Roles requested per the EMEA-DOA chain (commercial / delivery / solution / regional / deal_desk). Each request fires an in-app notification + Mailgun email with a deeplink to the approval page (/approvals/:id or /approvals/by-token/:token). The opportunity owner gets notified back when each decision lands.
Stage 15 — Build Pitch Materials
Agent: Pitch Deck Agent — backend/app/services/pitch_deck_agent.py
Tool name: submit_pitch_deck_outline
Endpoint: POST /api/opportunities/{id}/pitch-deck
Inputs: every upstream artifact (intake, diagnosis, qualification, delivery model, capability gaps, support docs) + normalized assets list.
Output: structured slide-by-slide outline.
Output schema: headline, deck_summary, slides[] of { section (cover|context|approach|scope|team|commercials|governance|next_steps), title, key_points[] (3-6), speaker_notes, data_callout }, appendix[].
What the agent is told:
You are the Pitch Deck Agent. The team has run the full intake → diagnosis → qualification → match → ratecard → delivery model → team shape → capability → caveats pipeline. Compose a tight, client-facing pitch deck outline that turns the platform's structured outputs into a slide-by-slide flow.
Rules:
- 8-12 slides for a sales deck. Be ruthless. Don't pad.
- Section flow: cover → context → approach → scope → team → commercials → governance → next steps.
- Every slide has 3-6 punchy bullets, not paragraphs. Speaker notes carry the prose.
- Use real numbers from the platform: FTE, total hours, asset counts, cost, AI savings %, qualification score. Surface them as data_callouts.
- Don't invent. If the platform hasn't produced something, omit the slide rather than fabricate.
Also exposes GET /pitch-deck/markdown (auto-composed quick deck from raw artifacts, no Claude) and GET /pitch-deck/outline-markdown (renders the agent's structured outline to markdown for download).
Phase 2 will render the outline to a real .pptx via python-pptx + a branded template library.
Stage 16 — Delivery Planning (post-win)
Agent: Implementation Plan Agent — backend/app/services/implementation_plan_agent.py
Tool name: submit_implementation_plan
Endpoint: POST /api/opportunities/{id}/implementation-plan
Precondition: Opportunity.deal_status == 'won' (otherwise 400).
Inputs: Stages 2, 9, 12, 13.
Output: phased rollout plan.
Output schema: summary, phases[] (3-6) of { name, timeframe, objectives[], milestones[], owner }, market_rollout[] of { market, go_live, dependencies[] }, training_and_adoption[], compliance_and_policy[], in_flight_metrics[] of { metric, target, review_cadence }, risks[].
What the agent is told:
You are the Implementation Plan Agent. The pitch WAS WON. Your job is to turn the proposal into an executable post-win rollout plan the delivery team can run from day 1.
Rules:
- 3-6 phases. Be concrete with weeks / months / quarters.
- Use the brief's market list. If 12 EMEA markets, propose a sane market_rollout sequence (lead-market first, fast-followers, long tail).
- Training & adoption: include client-side enablement (briefing standards, asset library training, tool access, AI tooling onboarding when delivery model is AI-supported/hybrid).
- Compliance: legal sign-off, brand guideline rollout, DAM access, tool policy approval, data-handling, IP & talent rights for any shoots.
- in_flight_metrics: pull from Stage 13 KPIs where present, add operational ones (on-time %, first-time-right %, brief-to-final cycle time, % work in SLA).
- Owner: real role names. Programme Director / Account Lead / Delivery Lead / Client Lead / Client Marketing Lead.
The frontend's Stage 16 panel also handles the deal-status switcher (Active / Won / Lost / Deprioritized) and a lessons-learned textarea for Lost / Deprioritized deals.
Stage 17 — Trigger Downstream Systems
Phase 2 deliverable. Will push the approved opportunity to Salesforce, link out to SharePoint Sales Encyclopedia, and notify regional approvers + talent/recruitment via email/Slack.
Cross-cutting concerns
Cost tracking
Every Claude call records input_tokens / output_tokens / cost_usd (priced at $3/M input, $15/M output for Opus 4.7). The cost is rolled up onto the Opportunity (cumulative spend) and stamped on the produced stage_artifact (per run). The Stage 8 panel shows the cumulative total; every Claude-driven stage panel header shows the AgentRunCost pill for its latest run.
A typical full walkthrough on a complex brief (Versuni-class, 25+ assets, 10+ markets) runs about $1.40–$1.50 of Anthropic spend across 26 calls.
Approvals + email
Approval rows belong to a stage and a role (commercial / delivery / solution / regional / deal_desk). Creating one fires an in-app notification + Mailgun email with a unique email_token deeplink (/approvals/by-token/:token). The approver decides with optional notes; the opportunity owner is notified back.
Mailgun is optional — when MAILGUN_API_KEY is empty the service logs the would-be payload and returns success, so dev environments work without credentials.
Auth
Two paths in backend/app/middleware/auth.py:
- DEV_AUTH_BYPASS=true: every request is treated as the configured
DEV_AUTH_EMAIL/DEV_AUTH_NAME/DEV_AUTH_ROLE. The auth middleware upserts anAppUserrow with that email + role on first hit.scripts/seed_admin.pyruns at container start to ensure the admin row exists idempotently. - DEV_AUTH_BYPASS unset (production with SSO): validates the bearer token as an Azure Entra ID JWT. Audience is
AZURE_CLIENT_ID, issuer ishttps://login.microsoftonline.com/{AZURE_TENANT_ID}/v2.0. JWKS is cached in process memory; the cache is invalidated and refetched on a key miss.
Same upsert path either way — first authenticated visit creates an AppUser with role=editor (or whatever DEV_AUTH_ROLE says under bypass). Admins are promoted via /api/users/{id}/role (when the user-management UI lands) or by direct DB update.
Stage-machine rules (exact)
- Creating an Opportunity inserts 17
stage_statesrows: stage 1in_progress, stages 2-17not_started. /stages/{n}/complete:- 400 if stage is already
completed. - 400 if status is
not_started(i.e. predecessor not done). - For stage
n ∈ {3, 14}: 400 if noApprovalrows exist or any are notapproved. - On success: status →
completed,completed_atstamped. Stagen+1flips toin_progress.Opportunity.current_stageadvances.
- 400 if stage is already
- Agent endpoints (
/intake,/diagnose,/match, etc.) intentionally don't enforce stage state — the user can iterate freely until they "close" a stage.
Per-stage destructive cascades
Re-running these stages wipes downstream artifacts intentionally:
- Stage 6 normalize — wipes
ClientAssetrows, which CASCADE-deletesMatchrows andRatecardLinerows. - Stage 7 match — wipes existing
Matchrows for the opportunity's assets first. - Stage 8 ratecard build — wipes
RatecardLinerows for the opportunity first. - Stage 2 diagnose — wipes
clarification_questionsrows wheresource_stage=2(preserving any that were added manually or seeded by other stages).
Tests assert these cascade rules.
Testing
python3 -m venv /tmp/osop_test_venv
/tmp/osop_test_venv/bin/pip install -r backend/requirements-dev.txt psycopg2-binary
cd backend && /tmp/osop_test_venv/bin/pytest tests/ -v
Current state: 118 tests / 109 pass / 9 skipped (real-Anthropic, marked @pytest.mark.requires_anthropic) / 0 failures.
Test files in backend/tests/:
test_opportunity_crud.py— CRUD + defaults + model_type round-triptest_stage_machine.py— 17-stage init, advance, double-complete, out-of-ordertest_stage_gating.py— stages 3 and 14 reject without approvalstest_files.py— upload/extract for.txt / .md / .docx / .xlsx,.exerejectedtest_intake_agent.py— Stage 1 endpoints (real-Claude path skipped)test_diagnosis.py— Stage 2 + clarification CRUDtest_qualification.py— TROWLS scoring, thresholds, validationtest_qa_pack.py— Stage 4 Excel + Word exportstest_assets.py— Stage 6 ClientAsset CRUD + normalize errorstest_matching.py— Stage 7 selection rules + 400/404 pathstest_ratecard.py— Stage 8 + bug-4 invariant explicitly assertedtest_team_shape.py— Stage 11 FTE math + override precedencetest_agents_stages_9_12_13.py— error paths for the 3 simple agentstest_approvals.py— request/decide/token flow + cross-opp 404test_notifications.py— bell + mark-read + unread counttest_schema_sanity.py— list endpoint shapes
The harness hits the live backend on http://localhost:8003 (override with OSOP_BASE_URL) — tests the deployed surface, not an in-process app. OSOP_TEST_DSN overrides the psycopg2 DSN used for direct DB seeding.
Repo layout
oliver-sales-ops-platform/
├── backend/ # FastAPI + SQLAlchemy + Alembic
│ ├── alembic/versions/ # 6 migrations: 0001 → 0006
│ ├── app/
│ │ ├── api/ # routers per domain (opportunities, approvals, …)
│ │ ├── middleware/ # auth (Azure JWT + dev bypass + AppUser upsert)
│ │ ├── models/ # SQLAlchemy ORM
│ │ ├── schemas/ # Pydantic request/response models
│ │ ├── services/ # 9 Claude agents + utility services
│ │ └── utils/ # claude_client (cost tracking, debug log)
│ ├── scripts/seed_admin.py # idempotent admin user seed (runs at boot)
│ ├── start.sh # alembic upgrade head → seed_admin → uvicorn
│ ├── requirements.txt
│ ├── requirements-dev.txt
│ └── tests/ # 16 pytest files, 118 tests
│
├── frontend/ # React 18 + Vite + TS
│ └── src/
│ ├── api/ # axios + TanStack Query hooks per domain
│ ├── auth/ # MSAL setup + AuthProvider
│ ├── components/ # StageStepper, NotificationBell, AgentRunCost,
│ │ # Stage1Intake … Stage16Delivery (one per stage)
│ └── pages/ # Dashboard, NewOpportunity, OpportunityView,
│ # ApprovalView, About
│
├── deploy/
│ ├── deploy.sh # idempotent deploy: ports, build, sync,
│ │ # apache render, health probe
│ ├── apache-osop.conf.tmpl # Apache reverse-proxy template
│ └── apache-osop.conf # generated per-deploy (gitignored)
│
├── docker-compose.yml # db + redis + backend (frontend behind dev profile)
├── .env.example # all env vars with safe defaults
└── README.md # this file
License: internal OLIVER tool. Not for redistribution. Repo: https://bitbucket.org/zlalani/oliver-sales-ops-platform