CareerForge
Info Portal

Internal reference documentation for the CareerForge career development platform — architecture, user guides, forms workflows, and the complete user directory.

Last updated: 27 April 2026  ·  Internal use only

Table of Contents

What is CareerForge?

CareerForge is an internal career development platform for a software IT outsourcing company with eight engineering practices. It connects employees, coaches, and practice managers in a structured, data-driven growth workflow — from skill assessment through structured commitments to verified sign-off.

Career Development
Employees define career goals, track skill gaps, and commit to growth milestones with coach and manager oversight.
Coaching Relationship
Six specialist coaches create Individual Development Plans (IDPs) and 1-on-1 forms, guiding employees through structured programs.
Manager Visibility
Practice managers see their team's readiness scores, pending sign-offs, and career progression in real time.
AI Coaching
AI-powered advice grounded in 4 explicit pillars: anonymised profile, ranked opportunities, Industry 4.0 readiness, and company strategy. Every suggestion writes to an immutable audit log with configurable cost controls and a full evidence trail. Read the AI Deep Dive →
AI Workflow Connector
Admins configure which AI provider (Anthropic, OpenAI, Perplexity, Gemini, or self-hosted Ollama/vLLM) drives all suggestions. One active config at a time. Every AI call is logged in an append-only audit trail.

Key Concepts

The Four Portals

CareerForge is delivered as four separate single-page applications, each scoped to one user role, with independent authentication.

Employee Portal
demo.lupulup.com
Self-service career hub. View profile, explore opportunities, apply, view forms, give AI-assisted advice, and manage PII consent.
employee realm
Coach Portal
demo-coach.lupulup.com
Coach workspace. Manage opportunities, review applications, create IDP/1-on-1 forms, track employee progress, and sign off on commitments.
coach realm
Manager Portal
demo-manager.lupulup.com
Practice Manager view. Team overview, readiness scores, pending form sign-offs, and progression analytics — without raw PII.
manager role
Admin Portal
demo-admin.lupulup.com
Platform operations. Skill taxonomy management, system analytics, bulk data import, model training triggers, and global audit log.
admin realm

System Architecture

CareerForge follows a microservices architecture deployed on Kubernetes. Each portal has a dedicated backend API service; a shared PostgreSQL database holds canonical state; Redis provides caching and session storage.

INGRESS CONTROLLER · nginx · *.lupulup.com (k3d MetalLB) EMPLOYEE PORTAL Next.js / React COACH PORTAL Vue 3 / Vite MANAGER PORTAL Vue 3 / Vite ADMIN PORTAL React / TypeScript EMPLOYEE SERVICE FastAPI · :8002 COACH SERVICE FastAPI · :8003 MANAGER SERVICE FastAPI · :8004 ADMIN SERVICE FastAPI · :8000 KEYCLOAK · auth.lupulup.com Realms: employee · coach · admin · OIDC PKCE flow POSTGRESQL 16 employees · coach_forms · audit_log AES-256-GCM PII encryption REDIS 7 Sessions · Consent tokens Cache & notifications ML LAYER scikit-learn · XGBoost · MLflow Readiness scores · Skill gap models GitOps: ArgoCD watches gitops/ overlays · Kustomize · base → staging → production

Backend Services

Each FastAPI service owns a bounded domain. Services share the same PostgreSQL database but access only their own tables.

Employee Service  :8002 / employee-service

Core employee operations. Verifies JWTs from the employee Keycloak realm. Decrypts PII only after consent token validation.

Responsibilities

  • Profile retrieval (anonymised by default, full PII with consent token)
  • Skill heatmap & readiness score aggregation
  • Opportunity discovery and application submission
  • AI coaching advice generation
  • PII reveal request (generates consent token via notification)
  • Audit log write for all employee actions
Coach Service  :8003 / coach-service

Coach workspace operations. Verifies JWTs from the coach Keycloak realm.

Responsibilities

  • Opportunity CRUD (create, list, accept/reject applications)
  • Form creation (IDP, 1-on-1, nomination, performance review)
  • Coach sign-off on committed forms
  • Team analytics (skill heatmap, applications pipeline, cohort readiness)
  • Employee profile lookup (anonymised or PII-unlocked)
  • Mentor Matcher — GET /employees/{pseudo_id}/mentor-suggestions?top_n=N — scores available mentors against a given employee using skill overlap (55%), seniority fit (30%), and load (15%); returns ranked list with match %, skill overlap, seniority gap, and active assignment count
  • Market intelligence and AI coaching advice (per-feature TTL from active AI Workflow Config)
Manager Service  :8004 / manager-service

Practice Manager views. Verifies JWTs from the employee Keycloak realm; additionally requires the manager realm role. Runs as a separate image: careerforge-manager-backend:latest.

Responsibilities

  • Team member listing (filtered by line_manager_pseudo_id)
  • Team member detail view (readiness score, skills, active forms)
  • Manager sign-off on forms (after coach sign-off is done)
  • Pending sign-off queue
  • Team analytics (skill gaps, progression heatmap)
  • Opportunity Publishing — Managers create and publish opportunities of type project, bench, lnd, rnd, or role; includes publish/unpublish lifecycle
  • AI Candidate Match — POST /opportunities/{id}/ai-match — deterministic skill-overlap scoring across all employees + AI narrative for top 3 candidates; results cached per the opportunities_ai_match feature TTL from the active AI Workflow Config
Admin Service  :8000 / admin-service

Platform administration. Verifies JWTs from the admin Keycloak realm.

Responsibilities

  • Skills taxonomy management (create, update, deprecate skills)
  • System-wide analytics and KPI dashboards
  • Bulk employee data import
  • Global audit log browser
  • ML model training job triggers
  • Keycloak user synchronisation
  • AI Workflow Connector — Full CRUD for AIWorkflowConfig records; activate/deactivate; per-feature coverage config (TTL, guardrails, model override per feature per portal); self-hosted LLM support via base_url; sources catalog endpoint; AI suggestion audit log browser

AI Workflow Config structure

The active config drives every AI call across all portals. Coverage config structure:

{
  "employee": {
    "role_suggestions":       { "enabled": true, "ttl_hours": 24, "guardrails": { "max_roles": 5, "min_match_score": 0.3 }, "model_override": null },
    "learning_path":          { "enabled": true, "ttl_hours": 48, "guardrails": { "max_nodes": 12, "include_certs": true }, "model_override": null },
    "opportunities_matching": { "enabled": true, "ttl_hours": 6, ... },
    "ai_chat":                { "enabled": true, "ttl_hours": 1, ... }
  },
  "coach": {
    "coaching_advice":   { "enabled": true, "ttl_hours": 4, "guardrails": { "require_pseudo_id": true, "safe_mode": true }, ... },
    "market_intelligence": { "enabled": true, "ttl_hours": 6, ... },
    "mentor_matching":   { "enabled": true, "ttl_hours": 24, "guardrails": { "min_match_score": 0.2, "max_mentors": 5 }, ... }
  },
  "manager": {
    "team_readiness":         { "enabled": true, "ttl_hours": 24, "guardrails": { "show_names": false }, ... },
    "opportunities_ai_match": { "enabled": true, "ttl_hours": 2, "guardrails": { "max_candidates": 10, "min_match_score": 0.3 }, ... }
  }
}

Authentication & SSO

All portals use OpenID Connect with PKCE (Proof Key for Code Exchange). No passwords are stored by CareerForge — credentials live in Keycloak.

Keycloak Realms

RealmUsed byIssuer URL
employeeEmployee Portal, Manager Portalhttps://auth-demo.lupulup.com/auth/realms/employee
coachCoach Portalhttps://auth-demo.lupulup.com/auth/realms/coach
adminAdmin Portalhttps://auth-demo.lupulup.com/auth/realms/admin

Manager Role Check

The Manager Portal uses the employee realm but additionally requires the manager realm role to be present in the JWT. If the user authenticates successfully but lacks this role, they are redirected to /access-denied.

OIDC PKCE Flow

Portal generates code_verifier + code_challenge

Stored in sessionStorage. Used for PKCE security.

Redirect to Keycloak authorize endpoint

With response_type=code, code_challenge_method=S256.

User logs in at Keycloak

Credentials never touch the portal app.

Callback with authorization code

Portal exchanges code + verifier for access_token + refresh_token.

Access token sent as Bearer on every API call

Backend validates signature against Keycloak's JWKS endpoint.

PII Privacy Model

Employee personally identifiable information (full name, email, phone) is stored encrypted with AES-256-GCM. The encryption key is derived per-employee and stored in a secrets manager.

Coaches and managers see only pseudo-ids by default. To reveal PII, the employee must explicitly approve a PII reveal request — a push notification is sent, and on approval a short-lived consent token is issued. The requesting party redeems the token to temporarily decrypt the profile.

Data Flow

Opportunity & Application Flow

Coach Creates Opportunity
Employee Discovers & Applies
Coach Reviews Application
Accepted / Rejected

Form Sign-off Flow

Coach Creates Form
Employee Commits
Coach Signs Off
Manager Signs Off
Form ACTIVE

Manager Opportunity & AI Match Flow

Manager Creates Opportunity
Publishes (project / bench / lnd / rnd / role)
AI Match triggered
Skill-overlap scored vs all employees
Ranked candidates + AI narrative (top 3)

Pseudo-ID Derivation

Each employee's identifier is derived deterministically from their Keycloak sub (UUID) using HMAC-SHA256 + Base32:

HMAC_KEY = bytes.fromhex("77b1c3ed6d3a24931aef148f4099eabc355c79de084f5c10701d5de6044d16f0")

def derive_pseudo_id(keycloak_sub: str) -> str:
    digest = hmac.new(key=HMAC_KEY, msg=sub.encode(), digestmod=hashlib.sha256).digest()
    encoded = base64.b32encode(digest[:4]).decode("ascii")[:5]
    return f"EMP-{encoded}"

This means pseudo-ids are stable (same sub → same id), reversible only by someone with the HMAC key, and safe to expose to coaches and managers.

Employee Portal Manual

The Employee Portal at demo.lupulup.com is your personal career development hub. Sign in with your company SSO credentials (employee realm).

Dashboard

The dashboard shows your current readiness score (0–100), recent activity, skill heatmap, and quick links to pending actions. The readiness score is computed from your skills, certifications, education, learning history, and engagement.

My Profile

Skills & Heatmap

Opportunities

My Forms

AI Advice

Notifications

Coach Portal Manual

The Coach Portal at demo-coach.lupulup.com is your workspace for managing employee development. Sign in with your coach realm credentials.

Dashboard

Overview of your active opportunities, pending applications, forms awaiting your sign-off, and cohort readiness trends.

Opportunities

Create Opportunity

Set title, description, specialisation tags, seniority range, and application deadline. Published immediately — employees can discover and apply.

Review Applications

See each applicant's pseudo-id, readiness score, and cover note. Request PII reveal if you need to contact them directly.

Accept or Reject

Accepted applicants are notified automatically. Rejected applicants receive a note. You can change decision while the opportunity is open.

Forms

Creating a Form

Select form type, target employee pseudo-id, and optionally assign a practice manager. Form types:

After creation, the form status is pending_commitment — waiting for the employee to commit.

Sign-off

Once the employee commits, the form appears in your Sign-off Queue. Review the commitment, add coach notes, and sign. The form then moves to the manager for final sign-off (if a manager is assigned).

Mentor Matcher

On any employee's detail page, the Mentor Match card shows the top-N mentors best suited to that employee — ranked by a weighted score:

Mentors are only considered if they have available_for_mentoring = true and are at equal or higher seniority than the employee. Match scores are colour-coded: green ≥70%, yellow 40–69%, grey <40%.

AI Coaching Advice

On an employee's detail page, enter a coaching question — the AI receives a strictly-grounded context (the anonymised profile, top-matched opportunities, Industry 4.0 readiness snapshot, and the active company strategy) and returns auditable advice. The system prompt forbids any invented education, certifications, or metrics; Perplexity-style citation markers and raw UUIDs are scrubbed. Every call is logged in the AI suggestion audit trail with a context_used breakdown showing which pillars actually fed the answer. Users can mark each suggestion accepted / dismissed / rated 1–5 via POST /api/v1/ai-suggestions/{log_id}/feedback; admins see acceptance rate and average rating per suggestion_type at /api/v1/admin/ai-workflow/audit/feedback-summary. The TTL and guardrails for coaching advice are configurable per-feature in the Admin → AI Workflow Connector.

Analytics

Manager Portal Manual

The Manager Portal at demo-manager.lupulup.com gives Practice Managers visibility into their team's career progression. Requires the manager Keycloak role.

Role Requirement: Only employees with the manager realm role in the employee Keycloak realm can access this portal. If you see an "Access Denied" page, contact IT to have the role assigned to your account.

Dashboard

Summary of your team: total members, average readiness score, pending sign-offs, active IDPs, and recent form activity.

My Team

Sign-offs

Opportunities

Managers can create and publish opportunities visible to all employees in the Employee Portal.

Create Opportunity

Set title, description, required skills, type (project, bench, lnd, rnd, role), and seniority range. New opportunities start as draft — not yet visible to employees.

Publish / Unpublish

Click Publish to make the opportunity live in the Employee Portal. Use Unpublish to pull it back without deleting it.

AI Candidate Match

Click AI Match on any opportunity. The system scores all employees by skill overlap and returns a ranked candidate list with match percentages, matched skills (green), and missing skills (strikethrough). The top 3 candidates also receive an AI-generated narrative. Results are cached per the opportunities_ai_match TTL in the active AI Workflow Config.

Practice Managers

ManagerPseudo-IDPractice
alice.johnsonEMP-HCUZEFrontend
bob.smithEMP-OJ5RQBackend
chris.evansEMP-IRUKSCloud & DevOps
diana.morganEMP-EN5FUData Engineering
edward.hughesEMP-HZ2CCML / AI
fiona.taylorEMP-VGB6PMobile
grace.chenEMP-LNASWQuality Assurance
henry.kimEMP-LCJZVSecurity

Admin Portal Manual

The Admin Portal at demo-admin.lupulup.com is for platform operations. Restricted to users in the admin Keycloak realm.

Caution: Admin operations affect all users. Destructive actions (bulk delete, model retrain, data purge) require confirmation and are irreversible.

Skills Taxonomy

System Analytics

Audit Log

ML Model Management

AI Workflow Connector

Configure which AI engine drives all CareerForge suggestions. Navigate to Connectors → AI Workflow tab.

Privacy note: Audit log entries use pseudo-ids, not PII. The log cannot be modified or deleted through the UI.

Forms & Commitments

Forms are the core unit of documented career development in CareerForge. Every form goes through a structured lifecycle from creation to full sign-off.

Form Lifecycle

CREATED
Coach creates form & assigns employee
→
PENDING COMMIT
Employee reads & commits to goals
→
COMMITTED
Coach sign-off queue
COACH SIGNED
Manager sign-off queue
→
ACTIVE
Live commitment, progress tracked

Form Types

TypeCreatorManager Sign-offTypical Content
idpCoachRequiredGoals, milestones, target skills, timeline, success criteria
one_on_oneCoachOptionalMeeting agenda, discussion points, action items, next meeting date
nominationCoachRequiredRole/programme being nominated for, justification, evidence
performance_reviewCoachRequiredCompetency ratings, strengths, development areas, overall rating

Commitment Object

When an employee commits to a form, a FormCommitment record is created:

{
  "form_id": "uuid",
  "employee_pseudo_id": "EMP-XXXXX",
  "status": "committed",          // or: in_progress, completed, abandoned
  "commitment_date": "2026-04-21T10:00:00Z",
  "employee_notes": "I accept and will focus on Kubernetes certification first.",
  "progress_percentage": 0,
  "milestones_completed": 0
}

AI Mechanics

How each AI suggestion is computed across all four portals — what is deterministic versus LLM, the per-feature guardrails it respects, and the audit log entry it produces. Click any row to expand.

◉  Employee Portal
Role SuggestionsLLM + RAG
How it’s computed

Employee’s skills, seniority, and specialisation are serialised into a prompt. The active AI Workflow provider (Claude / Perplexity / OpenAI / Gemini / self-hosted) is called via get_role_suggestions_for_profile().

RAG searches ai_documents for relevant roles-catalogue and trend chunks, prepends them as context, then asks the LLM to return the top N most relevant roles for this profile.

A deterministic match_score (0.0–1.0) is appended per role: |emp_skills ∩ required| / |required|. Roles below min_match_score are filtered before returning.

Guardrails (coverage_config)
// employee → role_suggestions { "enabled": true, "ttl_hours": 24, "model_override": null, "guardrails": { "max_roles": 5, "min_match_score": 0.3 } }
Audit log entry
{ "suggestion_type": "role_suggestions", "portal": "employee", "feature": "role_suggestions", "employee_pseudo_id": "EMP-XXXXX", "workflow_config_id": 1, "model_used": "claude-sonnet-4-6", "sources_used": ["ai_documents","roles_catalogue"], "generated_at": "2026-04-27T13:52Z", "expires_at": "2026-04-28T13:52Z" // TTL resolved: coverage_config → 24h }
Learning PathLLM + RAG
How it’s computed

Skill set, career goal, and seniority gap are combined with RAG context (skill docs, cert data). The LLM is asked to return a node-graph JSON with node types: skill, cert, project, milestone.

Output is validated for JSON structure. Nodes exceeding max_nodes are trimmed (lowest-priority last). If include_certs: false, cert nodes are stripped. Cached 48h — long enough to avoid redundant calls, short enough to reflect market shifts.

Guardrails (coverage_config)
// employee → learning_path { "enabled": true, "ttl_hours": 48, "model_override": null, "guardrails": { "max_nodes": 12, "include_certs": true } }
Audit log entry
{ "suggestion_type": "learning_path", "portal": "employee", "feature": "learning_path", "employee_pseudo_id": "EMP-XXXXX", "model_used": "claude-sonnet-4-6", "sources_used": ["ai_documents","skill_taxonomy"], "generated_at": "2026-04-27T13:52Z", "expires_at": "2026-04-29T13:52Z" // TTL: 48h }
Opportunity Match ScoreDeterministic
How it’s computed

No LLM. For every published opportunity, at query time:

overlap = emp_skills ∩ opp.required_skills score = len(overlap) / len(required_skills) # if required_skills empty → 0.0

Badge colour: green ≥70%, yellow 40–69%, grey <40%. Score attached to every row in the opportunities list — no separate call needed.

Guardrails (coverage_config)
// employee → opportunities_matching { "enabled": true, "ttl_hours": 6, "model_override": null, "guardrails": { "min_match_score": 0.2 } }

Opportunities below min_match_score are still shown but de-emphasised. Disabling the feature hides all badges.

Audit log entry

No row written. The score is computed inline per opportunity in the same SQL query that returns the list — it is not a discrete suggestion. The coach’s “Suggest Employees for Opportunity” uses the same scoring formula and does write an audit row (see Coach Portal → Suggest Employees).

// computed inline; nothing in ai_suggestion_logs GET /api/v1/opportunities → [{ id, title, type, required_skills, match_score: 0.67, overlap: ["Python", "SQL"] }, …]
Career Advice (Get Advice)LLM + 4 pillars
How it’s computed

Single-shot question on the dashboard’s Get Advice card. Backend assembles a strict 4-pillar context: anonymised profile (skills resolved from SkillTaxonomy — UUIDs never sent), top 5 active opportunities ranked by skill overlap, the latest Industry 4.0 readiness snapshot, and the active workflow’s company strategy notes.

Profile too thin (< 3 skills, no specialisation/seniority/career goal) → 422 refusal rather than fabrication. Off-context questions (e.g. “where did I go to university?”) get a clean “not in your profile, please add it” refusal.

Guardrails (coverage_config)
// employee → ai_advice { "enabled": true, "ttl_hours": 2, "model_override": null, "guardrails": { "min_profile": "spec+seniority+3 skills+goal", "strip_uuids": true, "strip_citations":true } }

Output passes a regex scrub for [1][2] / [digest] citation markers and stray UUIDs. Rendered as Markdown via the shared CfMdText component (marked + DOMPurify).

Audit log entry
{ "suggestion_type": "coaching_advice", "portal": "employee", "feature": "ai_advice", "employee_pseudo_id": "EMP-XXXXX", "model_used": "sonar-pro", "payload_json": { "question": "…", "context_used": { "skills": 12, "opportunities_ranked": 5, "has_readiness": true, "has_strategy": true } } }
AI Career Advisor (multi-turn chat)LLM + 4 pillars
How it’s computed

The conversational Career Advisor in My Profile. Same 4-pillar grounding as Career Advice, but with the last 6 turns of conversation history prepended. Client owns session state — backend is stateless. History is capped at 10 turns; user message at 1500 chars.

Same anti-fabrication system prompt; same UUID / citation scrubber on the way out.

Guardrails (coverage_config)
// employee → ai_chat { "enabled": true, "ttl_hours": 1, "model_override": null, "guardrails": { "max_tokens": 1024, "history_turns":10, "safe_mode": true } }
Audit log entry
{ "suggestion_type": "ai_chat", "portal": "employee", "feature": "ai_chat", "employee_pseudo_id": "EMP-XXXXX", "model_used": "sonar-pro", "payload_json": { "message": "…", "history_len": 4, "context_used": { … /* same 4 pillars */ } } }
AI Career SummaryLLM
How it’s computed

One-click generation of a first-person career narrative on the employee’s profile page. Skill UUIDs resolved to taxonomy names before the prompt; education_json is never sent (the wizard does not collect it — any value would be seed-only and would surface as fabrication).

Strict system prompt: use only the supplied profile fields; never invent education, certifications, employers, projects, salaries, locations, or numeric metrics. Output split on the literal "Growth trajectory:" token into two displayed blocks.

Guardrails (coverage_config)
// employee → profile_summary { "enabled": true, "ttl_hours": 12, "model_override": null, "guardrails": { "min_profile": "spec+seniority+3 skills+goal", "drop_education":true, "strip_uuids": true, "strip_citations":true } }

422 refusal when the profile is too thin. Citation markers and UUIDs scrubbed post-LLM.

Audit log entry
{ "suggestion_type": "profile_summary", "portal": "employee", "feature": "profile_summary", "employee_pseudo_id": "EMP-XXXXX", "model_used": "sonar-pro", "payload_json": { "summary": "first 300 chars …", "trajectory": "first 200 chars …" } }
Skill Gap (target role readiness)LLM + RAG
How it’s computed

Target role title + the employee’s current skill set. RAG retrieves role-requirement docs and recent job-posting data. The model returns the top 8 skills the employee is missing or weak in, with required_level and market_demand grounded in connector data.

Used both by the employee’s Learning page (current vs target role) and surfaced on the dashboard’s readiness card.

Guardrails (coverage_config)
// employee → readiness { "enabled": true, "ttl_hours": 12, "model_override": null, "guardrails": { "max_skills": 8 } }

Output truncated to max_skills, sorted by market_demand descending. Only absent or below-required-level skills are included.

Audit log entry
{ "suggestion_type": "skill_gap", "portal": "employee", "feature": "readiness", "employee_pseudo_id": "EMP-XXXXX", "model_used": "sonar-pro", "payload_json": { "target_role": "…", "overall_readiness": 0.42, "skill_count": 8, "gaps": 5 } }
▦  Coach Portal
Coaching AdviceLLM + 4 pillars
How it’s computed

Coach submits a question referencing an employee by pseudo-id. Backend assembles the same 4-pillar context as the employee Career Advice: anonymised profile (skills resolved from SkillTaxonomy), top-matched opportunities, latest readiness snapshot, active company strategy.

require_pseudo_id: true means the endpoint rejects any request without a valid, existing pseudo-id — no anonymous bulk queries. Strict system prompt forbids invented education, certifications, employer history, projects, salaries, locations, metrics; bans citation markers and raw UUIDs.

Guardrails (coverage_config)
// coach → coaching_advice { "enabled": true, "ttl_hours": 4, "model_override": null, "guardrails": { "require_pseudo_id": true, "strip_uuids": true, "strip_citations": true, "safe_mode": true } }

Rendered as Markdown via CfMdText. Post-LLM regex scrub strips [1][2] / [digest] / UUID artefacts.

Audit log entry
{ "suggestion_type": "coaching_advice", "portal": "coach", "feature": "coaching_advice", "employee_pseudo_id": "EMP-XXXXX", "model_used": "sonar-pro", "payload_json": { "question": "…", "context_used": { "skills": 12, "opportunities_ranked": 5, "has_readiness": true, "has_strategy": true } } // TTL: 4h — pseudo_id required, PII never leaves boundary }
Market TrendsLLM + RAG
How it’s computed

Connectors (tech news feeds, job market APIs) ingest data hourly via Celery Beat into ai_documents. The RAG layer retrieves the freshest chunks. The LLM synthesises them into trend objects: { topic, trend_direction, summary, confidence, sources[] }.

Not employee-specific — one shared cache entry per topic. Individual source URLs are cited so coaches can verify any claim.

Guardrails (coverage_config)
// coach → market_intel { "enabled": true, "ttl_hours": 6, "model_override": null, "guardrails": { "max_trends": 6 } }
Audit log entry
{ "suggestion_type": "market_trends", "portal": "coach", "feature": "market_intel", "employee_pseudo_id": null, // not employee-scoped "model_used": "sonar-pro", "payload_json": { "topic": "general", "count": 6 } // TTL: 6h }
Emerging Roles (catalogue)LLM + RAG
How it’s computed

Coach Catalogues page surfaces emerging IT roles per specialisation (or general). RAG retrieves connector docs scoped to the spec; the LLM returns a JSON array of { role_title, demand_level, avg_salary_range, required_skills[], growth_rate_percent }.

Cache is keyed by specialisation, so all coaches looking at the same practice share one warm entry. Distinct from Market Trends: roles & demand levels, not directional signals.

Guardrails (coverage_config)
// coach → market_intel (emerging_roles) { "enabled": true, "ttl_hours": 2, "model_override": null, "guardrails": { "max_roles": 10 } }

Output is parsed as JSON; _parse_json() strips fences and falls back to empty list (never free text into the UI).

Audit log entry
{ "suggestion_type": "emerging_roles", "portal": "coach", "feature": "market_intel", "employee_pseudo_id": null, "model_used": "sonar-pro", "payload_json": { "specialization": "backend", "count": 8 } // TTL: 2h }
Suggest Employees for OpportunityDeterministic
How it’s computed

Coach asks “who in the org should I shortlist for this opportunity?”. No LLM. Pool: all employees meeting the opportunity’s minimum seniority. Score per candidate:

skill_score = |emp_skills ∩ opp.required_skills| / |required| seniority_score = max(0, 1 - |emp_idx - min_idx| * 0.15) availability_bonus = 0.1 if emp.available_for_mentoring else 0 match_score = skill_score * 0.65 + seniority_score * 0.25 + availability_bonus

Top-N returned (default 5), with skill overlap and mentoring-availability flag.

Guardrails (coverage_config)
// coach → opportunities_ai_match { "enabled": true, "ttl_hours": 2, "model_override": null, "guardrails": { "top_n": 5, "min_match_score": 0.3 } }

Zero LLM cost. Tagged model_used: "rule_based" so the audit page can distinguish it from LLM rankings.

Audit log entry
{ "suggestion_type": "opportunity_match", "portal": "coach", "feature": "opportunities_ai_match", "employee_pseudo_id": null, // opp-scoped "model_used": "rule_based", "payload_json": { "opportunity_id": "…", "matches": [ { "pseudo_id": "EMP-A", "match_score": 0.92 }, … ] } }
Mentor Match (employee & opportunity)Deterministic
How it’s computed

Two sibling endpoints share one scoring formula:

  • GET /employees/{pseudo}/mentor-suggestions — on the employee detail page
  • GET /opportunities/{id}/mentor-suggestions — on the opportunity detail page

Pool: employees with available_for_mentoring=true, seniority ≥ target’s, pseudo_id ≠ target. Weighted score per candidate:

skill_score = |mentor_skills ∩ target_skills| / |target_skills| sen_gap = seniority_rank(mentor) - seniority_rank(target) seniority_score = max(0, 1 - max(0, sen_gap - 2) * 0.15) load_score = 1 - (active_assignments / 10) // from mentor_assignments table match_score = skill_score * 0.55 + seniority_score * 0.30 + load_score * 0.15
Guardrails (coverage_config)
// coach → opportunities_ai_match { "enabled": true, "ttl_hours": 24, "model_override": null, "guardrails": { "min_match_score": 0.2, "max_mentors": 5 } }

Candidates below min_match_score excluded. Capped at max_mentors. Zero LLM cost — entirely data-model driven. Only pseudo-ids exposed — no names. Tagged model_used: "rule_based" in the audit log.

Audit log entries (two distinct types)
// employee detail page: { "suggestion_type": "mentor_match_employee", "portal": "coach", "feature": "opportunities_ai_match", "employee_pseudo_id": "EMP-XXXXX", "model_used": "rule_based", "payload_json": { "matches": […] } } // opportunity detail page: { "suggestion_type": "mentor_match_opportunity", "portal": "coach", "feature": "opportunities_ai_match", "employee_pseudo_id": null, "model_used": "rule_based", "payload_json": { "opportunity_id": "…", "matches": […] } }
⊞  Manager Portal
Opportunity AI Candidate MatchLLM + Deterministic
How it’s computed

Phase 1 — Deterministic (all 212 employees):

for emp in all_employees: overlap = emp.skills ∩ opp.required_skills score = len(overlap) / len(required) candidates.append({ emp, score, overlap, missing })

Sorted by score desc. Filtered by min_match_score. Capped at max_candidates.

Phase 2 — LLM narrative (top 3 only): opportunity description + top-3 anonymised profiles sent to the active provider for a concise hiring narrative per candidate.

Guardrails (coverage_config)
// manager → opportunities_ai_match { "enabled": true, "ttl_hours": 2, "model_override": null, "guardrails": { "max_candidates": 10, "min_match_score":0.3 } }

Short 2h TTL — opportunity requirements may change. LLM only sees: pseudo_id, specialisation, seniority, skill_list. No names, no email, no personal data.

Audit log entry
{ "suggestion_type": "opportunities_ai_match", "portal": "manager", "feature": "opportunities_ai_match", "employee_pseudo_id": null, // opportunity-scoped, not per-employee "payload_json": { "opportunity_id": 42, "total_evaluated": 212, "candidates_shown": 10 }, "model_used": "claude-sonnet-4-6", "generated_at": "2026-04-27T13:52Z", "expires_at": "2026-04-27T15:52Z" // TTL: 2h }
Team Readiness ScoreML Model
How it’s computed

Not an LLM — an XGBoost model trained on historical skill progression, certification completions, IDP activity, and engagement data. Produces a score 0–100 per employee.

Features: skill_count, cert_count, idp_completion_rate, seniority_index, last_activity_days, engagement_score. Model version tracked in MLflow; promoted to production by an admin trigger — no code deploy required.

Guardrails (coverage_config)
// manager → team_readiness { "enabled": true, "ttl_hours": 24, "model_override": null, "guardrails": { "show_names": false } }

show_names: false enforces pseudo-id display at all times. PII reveal requires an explicit employee consent flow. 24h TTL matches the daily reporting cadence.

Audit log entry
{ "suggestion_type": "team_readiness", "portal": "manager", "feature": "team_readiness", "employee_pseudo_id": "EMP-XXXXX", "model_used": "xgboost-readiness-v3 (MLflow)", "sources_used": ["employee_features","skill_records","idp_history"], "generated_at": "2026-04-27T13:52Z", "expires_at": "2026-04-28T13:52Z" // TTL: 24h — ML model, no API cost }
⊕  AI Workflow Connector — Audit & Config
Outcome Feedback LoopDid it work?
How it works

The audit log answers “what did the AI say?”. The new ai_suggestion_feedback table answers “did it work?”. Each suggestion log row can be paired with one or more feedback events:

  • accepted — user actioned the suggestion (applied to a role, enrolled in a learning node, asked a follow-up)
  • dismissed — user explicitly rejected it
  • rated — thumbs / 1–5 stars; carries a numeric rating
  • auto — system-observed outcome (readiness delta 30d after a learning path was generated, application result, etc.)

Joining the two over time gives the platform real signal: acceptance rate per suggestion_type, average rating, correlation with downstream readiness changes — the data needed to tune prompts on evidence rather than vibes.

Endpoints
// Employee — record outcome on one of *their* suggestions POST /api/v1/ai-suggestions/{log_id}/feedback { "action": "accepted" | "dismissed" | "rated", "rating": 1..5, // required when "rated" "note": "free text", "payload": { /* action-specific */ } } // Employee — their own feedback history (self-review) GET /api/v1/ai-suggestions/my-feedback // Admin — rollup per suggestion_type GET /api/v1/admin/ai-workflow/audit/feedback-summary → [{ suggestion_type, total_suggestions, feedback_events, feedback_rate, accepted, dismissed, rated, avg_rating, acceptance_rate }, …] // Admin — suggestion detail now includes feedback events array GET /api/v1/admin/ai-workflow/audit/suggestions/{log_id} → { …, feedback: [{ action, rating, recorded_at, … }] }

Authorisation on POST: the suggestion must either belong to the caller (matching employee_pseudo_id) or be portal-wide / unscoped. Bad action / out-of-range rating → 422.

Telemetry
// Prometheus counter, labelled per outcome cf_suggestion_calls_total{ suggestion_type="coaching_advice", portal="employee", outcome="feedback_accepted" } // Sample summary endpoint output { "suggestion_type": "coaching_advice", "total_suggestions": 16, "feedback_events": 2, "accepted": 1, "dismissed": 0, "rated": 1, "avg_rating": 4.0, "acceptance_rate": 1.0 }
AI Observability — Prometheus metricsGrafana-ready
What is captured

The HTTP-level instrumentor already records request latency and status. These metrics target the AI surface — the things you can’t infer from request logs:

  • cf_llm_calls_total{provider, model, outcome} — success / empty / http_error / exception
  • cf_llm_call_duration_seconds{provider, model} — histogram, 0.5..90s buckets
  • cf_suggestion_calls_total{suggestion_type, portal, outcome} — success / empty_answer / refused_thin_profile / feedback_accepted / feedback_dismissed / feedback_rated
  • cf_llm_artifacts_scrubbed_total{kind} — citation_numeric / citation_named / uuid
  • cf_ai_cache_lookups_total{cache_kind, outcome} — hit / miss / expired
Why it matters

A regression like the Perplexity citation leak we caught manually mid-test would now alert itself: cf_llm_artifacts_scrubbed_total{kind="citation_numeric"} stops being zero. Same for silent regressions:

  • P95 latency for one provider creeping past 30s → switch model_override
  • refused_thin_profile ratio rising → onboarding wizard issue
  • empty_answer spikes on one suggestion_type → bad prompt template change
  • Cache hit rate dropping → TTL too aggressive or fingerprint collision
Exposed at
GET https://api-demo.lupulup.com/metrics GET https://api-demo-coach.lupulup.com/metrics GET https://api-demo-manager.lupulup.com/metrics GET https://api-demo-admin.lupulup.com/metrics // All four backend services, on the existing Prom endpoint // the FastAPI Instrumentator already publishes. // Sample line: cf_llm_call_duration_seconds_sum{ model="sonar-pro", provider="perplexity" } 5.486678252986167
Cost & Abuse ControlsThree layers
Three independent gates
  • Per-employee daily quota — default 50 LLM-backed calls per pseudo_id per UTC day. /ai-advice and /ai-chat consume one slot each. Exhausted → HTTP 429 with a clear message; resets at 00:00 UTC. Redis-backed (INCR + EXPIRE); fail-open if Redis is down.
  • Per-provider circuit breaker — after 5 consecutive failures (HTTP error / exception) the breaker opens for 120s. Open-state short-circuits the LLM call and returns ""; caller serves the configured fallback. Auto-closes after the cooldown.
  • Monthly token cost cap — rough estimate (chars/4) recorded per provider in cf:cost:YYYY-MM. coverage_config.__cost__.monthly_token_cap on the active workflow sets the limit; once hit, every LLM call returns "" until the next month. Unset = unlimited.

All three are fail-open: if Redis or a counter is unreadable, the call proceeds rather than blocking the portal.

Admin observability
GET /api/v1/admin/ai-workflow/cost-summary { "month_to_date": { "month": "2026-04", "providers": { "perplexity": { "in": 647, "out": 274 } }, "total": 921, "redis": "ok" }, "circuit_breakers": { "perplexity": { "consecutive_failures": 0, "open": false, "open_for_seconds": 0 } } }
Telemetry labels
cf_suggestion_calls_total{ outcome="quota_exceeded" // daily quota } cf_suggestion_calls_total{ outcome="blocked_breaker" // circuit open } cf_suggestion_calls_total{ outcome="blocked_monthly_cap" // month cap hit } // Same Grafana dashboards that surface success/empty // outcomes already see all three throttle modes by name.
Audit Evidence Trail — verbatim prompt & answerCompliance-ready
What is captured

Every conversational suggestion (coaching_advice, ai_chat) records into payload_json:

  • The system prompt verbatim (the strict-grounding rules + role declaration)
  • The full user prompt — all four PROFILE / OPPORTUNITIES / READINESS / STRATEGY blocks exactly as the model saw them
  • The full answer after post-LLM scrub
  • The context_used breakdown (skills count, opportunities ranked, readiness/strategy presence)
  • Every recorded ai_suggestion_feedback event (accepted / dismissed / rated 1–5)

This is the artefact HR / compliance / legal asks for when reviewing why the AI made a recommendation. No PII is in any of these blocks — only pseudo-IDs and aggregated profile fields, by construction.

Markdown export
GET /api/v1/admin/ai-workflow/audit/suggestions/{id}/evidence Content-Type: text/markdown; charset=utf-8 Content-Disposition: attachment; filename="suggestion-{id}.md" # AI Suggestion Evidence — coaching_advice - **Log ID:** `761026ed-…` - **Generated:** 2026-04-28T13:00:20Z - **Employee (pseudo):** `EMP-RHABH` - **Model used:** `sonar-pro` ## Context-used breakdown - skills: `12` - opportunities_ranked: `5` - has_readiness: `true` - has_strategy: `true` ## System prompt ``` You are CareerForge AI... [strict grounding rules verbatim] ``` ## User prompt (verbatim) ``` PROFILE - Specialization: architect - ... [four blocks] QUESTION (context: career) What are the top 3 things... ``` ## Answer [full markdown answer rendered as-is] ## Feedback events | Recorded at | Action | Rating | Actor | Note | | ... | accepted | — | EMP-RHABH | helpful |

Browser saves directly. Print-to-PDF for tickets / audits.

Why it matters

Three audiences, one artefact:

  • Compliance / GDPR Article 22 — demonstrates that any AI-assisted decision had a documented basis (the four pillars) and that the user retained the ability to contest / accept / dismiss.
  • Coach / HR review — when an employee disputes advice, the conversation is fully reproducible.
  • Prompt engineering — reading the actual prompt the model saw is the fastest way to debug a regression.
Runtime FE Configuration — one bundle deploys everywhereDevOps
How it works

The employee portal reads its environment-specific values (Keycloak URL, realm, client-id, API base URL) from window.__APP_CONFIG__ at runtime, populated by a /config.js file rendered from container env at startup.

Build-time VITE_* values still work as a fallback so pnpm dev and existing built bundles keep working unchanged.

Same image promotes from demo → staging → production. Set CF_KEYCLOAK_URL, CF_EMPLOYEE_API_URL, … on the deployment and they take effect on next pod restart.

Files
apps/employee/src/runtime-config.ts → RUNTIME_CONFIG = pick(window.__APP_CONFIG__, import.meta.env.VITE_*, default) apps/employee/index.html <script src="/config.js"></script> <script type="module" src="/src/main.ts"></script> apps/employee/docker-entrypoint.sh rendered into /docker-entrypoint.d/40-write-config-js.sh → writes /usr/share/nginx/html/config.js from CF_* env vars → nginx’s base image runs every script in /docker-entrypoint.d/ before starting nginx
Sample rendered config.js
window.__APP_CONFIG__ = { KEYCLOAK_URL: "https://auth-demo.lupulup.com", KEYCLOAK_REALM: "employee", KEYCLOAK_CLIENT_ID: "employee-portal", EMPLOYEE_API_URL: "https://api-demo.lupulup.com", };

All four portals (employee, coach, manager, admin) ship the same pattern — each has its own runtime-config.ts + docker-entrypoint.sh + nginx /config.js no-cache block. The cf-portal Helm chart accepts an optional env: array, so a single image promotes from demo → staging → production by overriding CF_KEYCLOAK_URL / CF_<APP>_API_URL at deploy time.

Audit Trail — every suggestion, every portalAppend-only
How it works

Every call to any AI function invokes _log_suggestion(portal=, feature=) before returning. The TTL is resolved by _get_feature_ttl(workflow, portal, feature): reads coverage_config[portal][feature].ttl_hours from the active config, falls back to suggestion_ttl_hours global, then to 24h hardcoded default.

The log is append-only — no UPDATE or DELETE is ever issued. Admins browse it via Admin → Connectors → Suggestion Audit tab, filterable by portal, feature, pseudo-id, or date range.

Active config resolution (5-min cache)
# rag.py — called on every AI function async def _get_active_workflow(): # in-process 5-min cache if _cache and (now - _ts) < 300: return _cache cfg = await db.scalar( select(AIWorkflowConfig) .where(AIWorkflowConfig.is_active == True) ) _cache = cfg # update cache return cfg

Switching provider in Admin UI takes effect within 5 minutes across all services — no restart required.

Full log table schema
-- ai_suggestion_logs (PostgreSQL) id SERIAL PRIMARY KEY suggestion_type VARCHAR(64) portal VARCHAR(32) feature VARCHAR(64) employee_pseudo_id VARCHAR(32) -- null if not emp-scoped workflow_config_id INT FK payload_json JSONB model_used VARCHAR(128) sources_used TEXT[] generated_at TIMESTAMPTZ DEFAULT now() expires_at TIMESTAMPTZ -- expires_at = generated_at + ttl_hours interval
Per-feature Config — live guardrail changes, no deployAdmin UI
What you can control

In Admin → Connectors → AI Workflow, each config has expandable per-feature panels. Per feature you can change:

  • Enabled — toggle off without a code change
  • TTL hours — how long results are cached before re-generation
  • Model override — cheaper model for bulk scoring, smarter model for coaching advice
  • Guardrail key/values — any key shown above; live after the 5-min cache window
Self-hosted AI support

Set provider to self_hosted. Enter a base_url (e.g. http://ollama:11434). The connector auto-appends /v1 and calls /v1/chat/completions (OpenAI-compatible). Bearer auth optional. 120s timeout.

{ "provider": "self_hosted", "model_name": "llama3.1:8b", "base_url": "http://ollama:11434", "api_key": "" // optional bearer }

Works with Ollama, LM Studio, vLLM, or any OpenAI-API-compatible server.

ai_workflow_configs schema
-- ai_workflow_configs (PostgreSQL) id SERIAL PK name VARCHAR(128) provider VARCHAR(32) -- anthropic/openai/perplexity/gemini/self_hosted model_name VARCHAR(128) api_key_encrypted TEXT -- AES-256-GCM base_url VARCHAR(512) -- self-hosted only is_active BOOLEAN suggestion_ttl_hours INT -- global fallback TTL coverage_config JSONB -- per-feature overrides sources_config JSONB notes TEXT
◉  AI-Ops & Cost Resilience — cross-cutting
Five-layer cost & resilience stackall providers
What it does

Sits in front of every conversational AI endpoint. Layers 1–4 ship benefits regardless of which provider the active workflow uses; layer 5 sits behind a capability predicate that activates only when Anthropic is selected.

  1. Prompt-shape budget — top-3 opportunities, strategy 800 chars, chat history 4 turns. 25–35% input-token cut on every provider.
  2. Response-hash cache — Redis key cf:airesp:<sha>, 5-min TTL. Double-clicks & reloads cost zero.
  3. Multi-replica circuit breaker — Redis-backed open state across all replicas.
  4. Connector freshness gauge — cf_connector_last_sync_seconds + alert at 24h.
  5. Anthropic prompt cache — ephemeral cache_control on the system prompt; ~90% input-cost cut on cache hits.
Cache key (layer 2)
# services/ai_connector/cache.py sha256( pseudo_id | question.strip().lower() | workflow_id | skill_count | ",".join(sorted(opp_top_ids)) | ...extra ) → "cf:airesp:<hex>"

/ai-chat additionally folds a hash of recent turns in so a follow-up question with the same words can’t collide with the original.

Observability
# Prometheus metrics emitted cf_suggestion_calls_total{outcome="cache_hit"} cf_prompt_cache_tokens_total{provider,direction} cf_connector_last_sync_seconds{connector} cf_llm_calls_total{provider,model,outcome} cf_llm_artifacts_scrubbed_total{kind}

Fail-open everywhere: cache, breaker, and quota helpers all degrade to no-op when Redis is unreachable.

Vector cosine RAG retrievalall providers
What it does

Every AI function that needs grounding chunks from ai_documents calls _cached_text_search(). That helper now embeds the query via the configured embedding provider (Ollama nomic-embed-text by default, OpenAI text-embedding-3-large when OPENAI_API_KEY is set) and orders ai_document_vectors by cosine distance.

Cosine similarity matches synonyms and paraphrases that LIKE missed: "AWS" ↔ "Amazon Web Services", "k8s" ↔ "Kubernetes", "microservices" ↔ "service-oriented architecture".

SQL shape
# services/ai_connector/rag.py SELECT d.title, d.source_name, substring(v.chunk_text from 1 for 600), 1 - (v.embedding <=> CAST(:qvec AS vector)) AS score FROM ai_document_vectors v JOIN ai_documents d ON d.id = v.document_id WHERE :ns = '' OR d.source_name = :ns ORDER BY v.embedding <=> CAST(:qvec AS vector) LIMIT :k
Fallback

If embedding generation fails (no embedding service reachable, transient HTTP error) or the vector query returns nothing, falls through to the original LIKE-based search. Both paths return the same dict shape, so every existing caller is unchanged. RAG is never blocked.

Daily AI-quota indicatorUX
What it does

The employee portal sidebar footer shows AI: 48 / 50 today under the user name, polled every 60s. Turns orange when remaining ≤ 5 so the user gets a visible heads-up before hitting the daily ceiling. Hidden when Redis is unreachable (remaining = -1) so the chip never lies.

Reads from GET /api/v1/quota/me which returns the same Redis counter the Phase 1 daily-quota breaker enforces — single source of truth, no drift between what the user sees and what the platform actually applies.

Endpoint shape
# services/employee/routers/quota.py GET /api/v1/quota/me → { "remaining": 48, "limit": 50, "resets_at": "2026-04-30T00:00:00+00:00" }

Empty remaining = -1 when the Redis sidecar is down — same fail-open posture as the rest of Phase 1.

Where it surfaces

For now, employee portal only (apps/employee/src/layouts/AppLayout.vue). Coach / manager / admin portals don’t have a quota concept on their AI surfaces yet, so no chip there. The endpoint itself is auth-scoped to the calling Keycloak subject so each employee only sees their own counter.

Drift gate (two-gate setup)CI
What it catches

Two complementary CI gates, paired so neither bug class slips through:

  • alembic check — fails when a SQLModel field has no matching migration column. The bug class that manifests as UndefinedColumn 500s after a fresh deploy.
  • pytest test_raw_sql_ghost_tables.py — fails when a router uses raw SQL against a table that has no SQLModel at all. The bug class behind the api_keys incident.
How alembic check stays useful

migrations/env.py ships an _include_object filter that skips index, FK, and unique-constraint naming divergence (cosmetic only — same b-tree, same referential semantics). Real drift — missing columns, nullable changes, missing tables — is still caught.

# migrations/env.py def _include_object(obj, name, type_, ...): if type_ in {"index", "foreign_key_constraint", "unique_constraint"}: return False # skip cosmetic return True
First catches

api_keys table: admin /api-keys returned 500 because the table referenced via raw SQL had no SQLModel and no migration. Caught by the ghost-table test.

coach_forms + skill_taxonomy: SQLModel had fields (employee_committed_at, cc_signed_off_at, manager_signed_off_at, opportunity_id, description) the migrations never created. Caught by alembic check when the strict gate was restored; reconciled via migrations 20260429_0002 + _0003 (both idempotent).

API Reference

All services expose OpenAPI docs at /docs (Swagger UI) and /redoc (ReDoc). Base paths are /api/v1.

Employee Service — demo.lupulup.com/api/v1
GET/profile— Own anonymised profile
GET/profile/full— Full profile with PII (requires consent token header)
GET/skills/heatmap— Own skill heatmap by domain
GET/readiness-score— Current readiness score breakdown
GET/opportunities— Browse open opportunities
POST/opportunities/{id}/apply— Submit application
DELETE/applications/{id}— Withdraw application
GET/forms— My assigned forms
POST/forms/{id}/commit— Commit to a form
POST/ai/advice— Request AI coaching advice
GET/notifications— Inbox (unread + read)
POST/notifications/{id}/respond— Approve or deny PII reveal
Coach Service — demo-coach.lupulup.com/api/v1
POST/opportunities— Create opportunity
GET/opportunities— List own opportunities
GET/opportunities/{id}/applications— Applications for an opportunity
POST/applications/{id}/decide— Accept or reject application
POST/forms— Create form (IDP, 1-on-1, etc.)
GET/forms— All forms created by me
POST/forms/{id}/sign-off— Coach sign-off on committed form
GET/analytics/skills-heatmap— Cohort skill heatmap
GET/analytics/applications— Applications pipeline analytics
GET/analytics/readiness— Cohort readiness scores
GET/employees/{pseudo_id}/mentor-suggestions— Top-N mentor fits for an employee (score = skill overlap 55% + seniority fit 30% + load 15%)
Manager Service — demo-manager.lupulup.com/api/v1
GET/team— List team members
GET/team/{pseudo_id}— Team member detail & readiness
GET/sign-offs— Pending sign-off queue
POST/sign-offs/{form_id}— Manager sign-off
GET/analytics/team— Team progression analytics
GET/opportunities— List all opportunities (draft + published)
POST/opportunities— Create opportunity (type: project/bench/lnd/rnd/role)
PATCH/opportunities/{id}— Update opportunity details
POST/opportunities/{id}/publish— Publish (makes visible to employees)
POST/opportunities/{id}/unpublish— Unpublish (returns to draft)
DELETE/opportunities/{id}— Delete opportunity
POST/opportunities/{id}/ai-match— AI candidate matching: skill-overlap score for all employees + narrative for top 3
Admin Service — demo-admin.lupulup.com/api/v1/admin
GET/ai-workflows— List all AI Workflow configs
POST/ai-workflows— Create AI Workflow config
PUT/ai-workflows/{id}— Update config (provider, model, coverage, base_url)
POST/ai-workflows/{id}/activate— Activate config (deactivates all others)
DELETE/ai-workflows/{id}— Delete config
GET/ai-workflows/sources-catalog— Available data sources for AI grounding
GET/ai-suggestion-logs— Paginated suggestion audit log (filter by type/portal/feature/date)

Users & Roles

Dev environment user directory. The employee realm contains 108 users (employees + managers); the coach realm contains 2 coaches; the admin realm contains 1 administrator. All dev accounts use the password Demo2026!.

Administrators (admin realm)

UsernameFull NamePortalPassword
admin1Admin Userdemo-admin.lupulup.comDemo2026!

Coaches (coach realm)

UsernameFull NamePortalPassword
coach1Andrei Popademo-coach.lupulup.comDemo2026!
coach.demoCoach Demodemo-coach.lupulup.comDemo2026!

Employees (employee realm)

Username Full Name Pseudo-ID Job Title Seniority Specialization Reports To Keycloak Role

Access Credentials

Simulation Accounts (all portals)

RealmUsersPassword
employee108 accounts — see Users & Roles table above. Notable: employee1, employee2, manager1, alex.manager, ioana.lead, stefan.headDemo2026!
coachcoach1 (Andrei Popa), coach.demo (Coach Demo)Demo2026!
adminadmin1 (Admin User)Demo2026!

Container Registry

Registrygit.lupulup.com
Namespaceadmin/
Imagescareerforge-backend:latest, careerforge-employee:latest, careerforge-coach:latest, careerforge-manager:latest, careerforge-admin:latest

Kubernetes

Clusterk3d-cf-demo (local dev)
Namespacecf-demo
ArgoCDhttps://argocd.lupulup.com