AI in CareerForge
Deep Dive

A complete technical breakdown of how artificial intelligence is embedded in CareerForge — architecture, implementation code, guardrails, current strengths and weaknesses, and the path toward a self-improving, simulation-driven platform.

Last updated: 23 April 2026  ·  Internal use only

What AI Does in CareerForge

AI in CareerForge is not a single feature — it is an intelligence layer that runs across all four portals, generating personalised career guidance, synthesising market data, and building structured learning paths. Every AI call is grounded in real business context (the employee's profile, skill inventory, anonymised coaching relationship) and constrained to JSON-structured output.

Learning Paths
Claude generates a personalised node-graph learning path for each employee at registration, spanning skills, certifications, projects, and milestones.
Role Suggestions
Based on the employee's specialisation and seniority, AI proposes 5 emerging roles with match scores and required skills.
Market Intelligence
Six technology market trends updated every 6 hours. Role demand catalogue refreshed every 2 hours with high/medium/low distribution.
Coaching Advice
Coaches submit a question about an employee (using their pseudo-ID only). AI responds with specific, actionable advice grounded in market context.
Skill Gap Analysis
AI maps the top 8 required skills for any target role, with required proficiency level and market demand score, cached for 12 hours.
Intelligence Digest
A weekly digest synthesising connector-ingested market data into a structured report: top rising skills, role movements, and recommended actions.
Zero hallucination surface on user identity Every AI prompt uses anonymised profiles only. The system prompt for coaching advice explicitly says "Never reveal or speculate about personal identity or PII." Employee names, emails, and exact locations are never sent to any LLM.

AI Architecture

The AI layer is a dedicated microservice (ai-connector-service, port 8001) plus two Celery workers (celery-worker, celery-beat). All other backend services import directly from the shared RAG module — there is no inter-service HTTP call to the AI connector for inference; only for connector management (CRUD) and async task status polling.

Component Map

ComponentFilePort / TriggerRole
ai-connector-serviceservices/ai_connector/main.py:8001FastAPI: connector CRUD, task status polling, Prometheus metrics
rag.pyservices/ai_connector/rag.pyimportedLLM fallback chain, caching, text search, all public AI functions
embeddings.pyservices/ai_connector/embeddings.pyimportedText chunking + embedding generation (Ollama / OpenAI)
celery-workertasks/ingestion.py, tasks/pipeline.pyRedis queueConnector sync, learning path generation, intelligence digest
celery-beatservices/ai_connector/celery_app.pyCron (hourly)Schedules sync_all_connectors every hour
PostgreSQL / pgvectorshared/models/ai.pyDBStores ai_documents, vectors (1536 dims), connector configs, ingestion history
Rediscelery_app.py:6379Celery broker + result backend

LLM Provider — AI Workflow Connector

The LLM provider is no longer hardcoded. Admins configure an AI Workflow Config in the Admin portal (Connectors → AI Workflow tab). The active config is loaded from PostgreSQL with a 5-minute in-process cache. Supported providers:

Anthropic
Claude models
→
Perplexity
Live web search
→
OpenAI
GPT / o-series
→
Gemini
Google models
→
Self-hosted
Ollama / vLLM / LM Studio

The _ai_chat() dispatcher reads the active workflow config and routes to the correct provider. For self-hosted, it calls an OpenAI-compatible /v1/chat/completions endpoint at the configured base_url (auto-appends /v1 if missing; 120s timeout):

async def _ai_chat(prompt: str, system: str = "") -> str:
    # rag.py — routes to active AI Workflow Config provider
    workflow = await _get_active_workflow()
    provider = workflow.get("provider", "anthropic")
    if provider == "anthropic":   return await _anthropic_chat(...)
    elif provider == "openai":    return await _openai_chat(...)
    elif provider == "perplexity": return await _perplexity_chat(...)
    elif provider == "gemini":    return await _gemini_chat(...)
    elif provider == "self_hosted":return await _self_hosted_chat(..., base_url=workflow["base_url"])
    return ""

Component Map

AI Workflow Config is also stored in PostgreSQL (ai_workflow_configs table) alongside the existing ai_documents and connector tables. The ai_suggestion_logs table records every AI call with portal, feature, pseudo-id, model used, and expiry.

Embedding Provider Fallback

Ollama nomic-embed-text
Default (Local, free)
→
OpenAI text-embedding-3-large
Override (OPENAI_API_KEY set)

Both providers produce 1536-dimensional vectors, stored in PostgreSQL with the pgvector extension.

The RAG Pattern

RAG — Retrieval-Augmented Generation — grounds LLM responses in real documents. In CareerForge the knowledge base is the ai_documents table, populated by connector ingestion. Every AI function first searches this table, prepends relevant chunks to the prompt, then calls the LLM.

Request Flow

Portal request
(employee / coach)
→
Backend service
employee/coach svc
→
rag.py
cache lookup
↓ cache miss
Vector search
pgvector cosine
→
Build prompt
context + task
→
LLM call
Claude / Perplexity
→
Parse JSON
+ cache result

Public AI Functions

FunctionConsumerOutput SchemaCache TTL
get_role_suggestions_for_profile()
rag.py:236
Employee portal [{role_title, match_score, description, required_skills[]}] 24 hours
get_role_skill_requirements()
rag.py:272
Employee portal [{skill, skill_ids[], required_level, market_demand}] 12 hours
get_coaching_advice()
rag.py:301
Coach portal {answer, sources[], model_used, generated_at} No cache
get_emerging_roles()
rag.py:340
Coach portal (Catalogues) [{role_title, demand_level, avg_salary_range, required_skills[], growth_rate_percent}] 2 hours
get_market_trends()
rag.py:373
Coach portal (Analytics) [{topic, trend_direction, summary, confidence, sources[]}] 6 hours
suggest_mentors_for_employee()
coach/routers/mentors.py
Coach portal (Employee Detail) [{pseudo_id, specialization, seniority, match_score, skill_overlap, seniority_gap, active_assignments}] 24 hours (configurable)
ai_match_opportunity()
manager/routers/opportunities.py
Manager portal (Opportunities) {candidates[{pseudo_id, match_score, matched_skills[], missing_skills[]}], ai_summary} 2 hours (configurable)

Caching Strategy

AI responses are expensive — both in latency (1–5 seconds) and API cost. CareerForge uses the ai_documents PostgreSQL table as a key-value cache. This gives the cache an automatic audit trail: every cache entry is a document with an ingestion timestamp.

TTL Tiers (by volatility) — now admin-configurable per feature

TTLs for AI suggestion results (audit log expiry + cache) are configured per-feature in the active AI Workflow Config. The defaults below reflect what the system ships with, but any admin can override them without a code deploy:

ai_chat (employee)
1h
opportunities_ai_match
2h
coaching_advice
4h
market_intelligence
6h
opportunities_matching
6h
skill_gap (per role)
12h
role_suggestions (per employee)
24h
mentor_matching
24h
learning_path
48h
intelligence_digest
7 days (168h)

Cache Key Design

Key PatternScoped ByExample
__cache_roles_{pseudo_id}__Individual employee__cache_roles_a3f7b2__
__cache_roles_{spec}_{seniority}__Specialisation + level__cache_roles_backend_senior__
__cache_roles_catalogue_{spec}__Specialisation (coach view)__cache_roles_catalogue_general__
__cache_gap_{safe_role}__Target role__cache_gap_data_engineer__
__cache_trends_{topic}__Topic keyword__cache_trends_cloud__
__digest__Global singleton__digest__
# rag.py:120 — cache lookup with TTL check
async def _get_cached_response(cache_key: str, ttl_hours: int) -> list | dict | None:
    async with AsyncSessionLocal() as session:
        row = (await session.execute(
            sql_text("SELECT content, ingested_at FROM ai_documents "
                     "WHERE source_name = :key ORDER BY ingested_at DESC LIMIT 1"),
            {"key": cache_key}
        )).fetchone()

    if not row:
        return None
    gen_at = datetime.fromisoformat(row.ingested_at)
    if (datetime.now(UTC) - gen_at) > timedelta(hours=ttl_hours):
        return None              # expired — caller will regenerate
    return json.loads(row.content)
Cache accumulation Old cache entries are never deleted — they simply age past their TTL and become invisible to the lookup query. The table grows over time. A scheduled cleanup task (not yet implemented) should prune entries older than 2× their TTL.

AI-Ops & Cost Resilience

A second cost & reliability layer sits in front of every conversational AI endpoint. The goal: shrink the input token bill on every provider, swallow duplicate requests for free, and stop a tripped provider from bleeding the whole replica fleet. Provider-specific optimisations (Anthropic prompt caching) sit behind a capability predicate so deployments on Perplexity / OpenAI / Ollama get the universal wins without any opt-in.

Five layers, in order

LayerWhat it doesEffect
1. Prompt-shape budget
grounded.py
Top-3 opportunities (was 5), strategy block 800 chars (was 1500), chat history 4 turns (was 6). 25–35% input-token cut on every provider — universal first.
2. Response-hash cache
cache.py
Redis key cf:airesp:<sha256>, 5-minute TTL, keyed on pseudo + question + workflow + skill_count + opp_top_ids. Double-clicks, page reloads, and accidental retries hit cache instead of the LLM. Cache hits are recorded as cf_suggestion_calls_total{outcome="cache_hit"}.
3. Multi-replica circuit breaker
quotas.py
Per-provider open-state lives in Redis (cf:breaker:open_until:{provider}). After N consecutive failures the breaker trips for K seconds across all replicas. 3-replica deploy: one tripped replica protects the other two. Previously each replica had to re-hit the threshold independently.
4. Connector freshness gauge
metrics.py
Unix-timestamp gauge cf_connector_last_sync_seconds{connector} set after each successful ingestion run. PrometheusRule alerts when time() − gauge > 86400 for 10m. Stale RAG context is no longer silently invisible.
5. Anthropic prompt cache
capabilities.py + rag.py
Predicate supports_prompt_caching(provider); the Anthropic path sends system as [{type:text, cache_control:{type:ephemeral}}]. No-op for every other provider. ~90% input-cost cut on cache hits when deployed against Anthropic. Cache-creation/cache-read tokens recorded as cf_prompt_cache_tokens_total{provider,direction}.

Cache key construction

# services/ai_connector/cache.py
def make_cache_key(
    *, pseudo_id: str, question: str,
    workflow_id: str | None, skill_count: int,
    opp_top_ids: list[str], extra: list[str] | None = None,
) -> str:
    parts = [pseudo_id, question.strip().lower(), workflow_id or "",
             str(skill_count), ",".join(sorted(opp_top_ids))]
    if extra: parts.extend(extra)
    digest = hashlib.sha256("|".join(parts).encode()).hexdigest()
    return f"cf:airesp:{digest}"

/ai-chat additionally folds a hash of the recent conversation turns into extra= so a follow-up question with the same words doesn't collide with the original.

Provider-portability principle

Layers 1–4 ship benefits regardless of which AI provider the deployment uses. Layer 5 sits behind a capability predicate that returns False on Perplexity, OpenAI (which auto-caches server-side), Ollama, and Gemini. The same call site path will extend to Gemini's CachedContent by adding one entry to the predicate and a body-rewrite branch in _gemini_chat — no caller changes needed.

Fail-open everywhere Cache, breaker, and quota helpers all degrade to no-op when Redis is unreachable. Better to serve traffic on a healthy LLM than to block the whole portal on a sidecar outage.

Daily quota visible to the user

Phase 1's daily-quota counter is enforced server-side, but historically the user couldn't see it — the first sign that you'd hit the limit was a 429 on your next AI call. GET /api/v1/quota/me exposes the same Redis counter the breaker enforces (no parallel state, no drift) and the employee portal sidebar polls it every 60s. The chip turns orange at ≤ 5 remaining so you get a heads-up before the ceiling.

# services/employee/routers/quota.py
@router.get("/quota/me")
async def get_my_quota(...) -> dict[str, Any]:
    pseudo_id = await _resolve_from_keycloak_sub(...)
    remaining = await remaining_daily_quota(pseudo_id)
    return {
        "remaining": remaining,        # -1 if Redis is unreachable
        "limit":     DEFAULT_DAILY_LIMIT, # 50 / day default
        "resets_at": _next_utc_midnight(),
    }

Same fail-open posture as the rest of Phase 1: when Redis is down the chip just disappears (remaining = -1) rather than lying about state.

Drift gate (two-gate setup)

CI runs both gates in sequence, paired so neither bug class can slip through:

Connector Ingestion

Connectors are configurable data sources — REST APIs, RSS feeds, JSON endpoints — that feed real-world market intelligence into CareerForge. The Admin portal provides CRUD for connectors. Ingestion runs hourly via Celery Beat.

Data Models

TablePurposeKey Fields
connector_configs Registry of data sources name, source_type, config_json_encrypted, fetch_interval_minutes, trend_score
connector_sources Individual URLs per connector connector_id, url, label, last_status_code
ai_documents Ingested content + AI cache source_name, content, topics[], trend_score, metadata_json
ai_document_vectors Chunk embeddings document_id, chunk_index, chunk_text, embedding vector(1536)
ingestion_history Audit trail per sync connector_id, chunks_ingested, duration_seconds, success, error_message

Ingestion Pipeline (tasks/ingestion.py)

Beat (hourly)
→
HTTP GET each source
→
Normalise fields
→
SHA-256 dedup
→
Chunk (512 words, 64 overlap)
→
Embed + store vector

Response normalisation handles multiple API schemas automatically: items[], data[], results[], or bare dict. Field aliases resolve content/body/description/text and url/link transparently.

Content-addressed dedup Each document is hashed (SHA-256 of content). Re-ingesting the same document has no effect — the hash is checked in metadata_json["content_hash"] before insertion. This makes repeated hourly syncs of slow-updating sources cheap.

Celery Pipeline Tasks

Long-running AI generation — learning paths and the weekly digest — runs asynchronously in Celery workers, so API responses remain fast. The client polls GET /api/v1/tasks/{task_id} for status.

TaskTriggerLLM BudgetOutput
generate_initial_profile
pipeline.py:19
Employee registration (fire-and-forget) Claude with thinking 2048 LearningPath node graph stored in DB
generate_learning_path
pipeline.py:125
Manual refresh by employee Claude with thinking 2048 Updated LearningPath, task_id returned for polling
generate_intelligence_digest
pipeline.py:227
Weekly (Beat) or coach requests refresh Claude with thinking 4096 JSON digest: rising skills, role movements, recommended actions
sync_all_connectors
ingestion.py:249
Hourly Beat schedule No LLM — embedding model only New documents + vectors in ai_documents

Learning Path Output Schema

{
  "target_role": "Senior Data Engineer",
  "summary": "12-month path targeting 8 skills across 4 phases...",
  "nodes": [
    {
      "id": "n1",
      "title": "Apache Kafka Fundamentals",
      "type": "skill",            // skill | cert | project | milestone
      "description": "...",
      "estimated_hours": 40,
      "prerequisites": [],
      "resources": [{ "type": "course", "title": "...", "url": "..." }]
    }
  ]
}

Intelligence Digest Schema

{
  "week_summary": "...",
  "top_skills_rising": [{ "skill": "Rust", "trend": "growing 34% YoY" }],
  "role_movements": [{ "role": "AI Engineer", "direction": "rising", "insight": "..." }],
  "recommended_actions": ["Upskill Python ML team in LangGraph", "..."],
  "generated_at": "2026-04-21T08:00:00Z"
}

Code Walkthrough

The following are the key implementation files with their roles and the most important code patterns.

services/
  ai_connector/
    rag.py  ← LLM orchestration, caching, all public AI functions (401 lines)
    embeddings.py  ← Ollama/OpenAI embedding + chunking (67 lines)
    main.py  ← FastAPI app: connector CRUD + task status API (52 lines)
    celery_app.py  ← Celery config + Beat schedule (39 lines)
    db.py  ← Async SQLAlchemy session (46 lines)
    tasks/
      ingestion.py  ← Connector sync: HTTP fetch → chunk → embed → store (273 lines)
      pipeline.py  ← Learning path + digest generation with extended thinking (333 lines)
    routers/
      tasks.py  ← GET /tasks/{id} status polling (35 lines)
  employee/routers/
    learning.py  ← /learning-path, /skill-gap, /role-suggestions (214 lines)
    registration.py  ← triggers generate_initial_profile.delay() (175 lines)
  coach/routers/
    advice.py  ← POST /employees/{pseudo_id}/ai-advice (79 lines)
    catalogues.py  ← GET /catalogues/roles (194 lines)
    analytics.py  ← GET /analytics/market-trend, /intelligence-digest (288 lines)
shared/
  models/
    ai.py  ← ConnectorConfig, AiDocument, AiDocumentVector, IngestionHistory (103 lines)
  auth/
    keycloak.py  ← RS256 JWT verify, JWKS caching 300s, realm-based verifiers (169 lines)

Prompt Engineering Pattern

All prompts follow a consistent 3-part template: identity declaration → context injection → JSON-only output constraint.

# rag.py:253–265 — role suggestions prompt assembly
system = (
    "You are a career intelligence system specialising in IT career paths. "
    "Suggest emerging roles based on current 2025 market data. "
    "Respond ONLY with a valid JSON array of objects with keys: "
    "role_title (string), match_score (0.0-1.0), description (string), "
    "required_skills (array of strings)."
)
context_block = ("Connector market data:\n" + context + "\n\n") if context else ""
prompt = (
    context_block
    + f"Suggest 5 emerging IT career roles for a {seniority} {specialization} professional "
    + "in 2025. Focus on high-demand, growing roles. Return JSON array only."
)

JSON Parsing with Graceful Fallback

# rag.py:81 — robust JSON extraction from LLM output
def _parse_json(text: str) -> list:
    if not text: return []
    text = text.strip()
    if "```" in text:                        # strip markdown code fences
        for part in text.split("```"):
            p = part.strip()
            if p.startswith("json"): p = p[4:].strip()
            if p.startswith("[") or p.startswith("{"): text = p; break
    try:
        result = json.loads(text)
        return result if isinstance(result, list) else [result]
    except json.JSONDecodeError:
        m = re.search(r"\[.*\]", text, re.DOTALL)  # last-chance regex
        if m:
            try: return json.loads(m.group())
            except: pass
    return []                                      # graceful empty on failure

Safety & Guardrails

Guardrails in CareerForge operate at three levels: prompt-level constraints, input validation, and infrastructure-level access control. The goal is to prevent PII leaks, hallucinated identities, runaway API spend, and prompt injection from malicious connector data.

GuardrailLayerImplementation
PII never sent to LLM Prompt Coaching prompts use pseudo_id + aggregated profile only. System prompt: "Never reveal or speculate about personal identity or PII."
4-pillar grounding (advice / chat / summary) Prompt The system prompt feeds only four named blocks — PROFILE, OPPORTUNITIES, INDUSTRY 4.0 READINESS, COMPANY STRATEGY — and explicitly forbids invented education, certifications, employer history, projects, salaries, locations, or metrics. Off-context questions get a clean refusal asking the user to add the missing detail to their profile.
Skill UUIDs resolved to names Prompt Skill IDs are joined against SkillTaxonomy before composing the prompt; the model never sees raw UUIDs. Custom user-typed skills round-trip as their name.
Citation & UUID scrub Post-processing Output passes through a regex pass that strips Perplexity-style citation markers ([1][2], [digest], [cache_roles]) and any stray UUIDs that survived the prompt rules. Whitespace and orphaned punctuation are normalised.
Profile-completeness gate API AI Advice / Career Summary / Career Advisor refuse with 422 when the profile is too thin (specialisation + seniority + 3 skills + a career goal). Better a clear "complete your profile first" than a confident fabrication.
Auditable suggestion trail DB Every AI surface (role suggestions, learning path, skill gap, profile summary, AI advice, AI chat, mentor match, opportunity match, market trends, emerging roles) writes an immutable row to ai_suggestion_logs with the suggestion type, employee pseudo-id, model used, and a context_used breakdown. Surfaced in the admin Suggestion Audit page with summaries that lead on the relevant identity.
Outcome feedback (accepted / dismissed / rated) DB + API Each suggestion log can be paired with one or more ai_suggestion_feedback rows. Employee endpoint POST /ai-suggestions/{log_id}/feedback validates ownership; admin endpoint /audit/feedback-summary aggregates acceptance rate and average rating per suggestion_type. The cf_suggestion_calls_total{outcome="feedback_accepted|feedback_dismissed|feedback_rated"} counter makes the same signal visible in Grafana.
Per-employee daily quota Redis Default 50 LLM-backed AI calls per pseudo_id per UTC day. /ai-advice and /ai-chat consume one slot each; quota exhausted → HTTP 429 with a clear message; resets at 00:00 UTC. Backed by Redis INCR + EXPIRE; degrades open if Redis is unreachable.
Per-provider circuit breaker Process state After 5 consecutive failures (http_error / exception) the breaker opens for 120s. Open-state short-circuits the LLM call and returns "" so callers serve the configured fallback message instead of a 5xx. Auto-closes after the cooldown. Visible at GET /ai-workflow/cost-summary and indirectly via the cf_suggestion_calls_total{outcome="blocked_breaker"} counter.
Monthly token cost cap Redis + Workflow Config Token estimate (~chars/4) recorded per provider in cf:cost:YYYY-MM Redis hash. Set coverage_config.__cost__.monthly_token_cap on the active workflow; once hit, every LLM call returns "" until the next month. Unset = unlimited. Surfaces as { providers: { perplexity: { in, out }}, total } in the admin cost-summary endpoint.
Audit evidence trail (verbatim prompt + answer) DB + API payload_json on conversational suggestions stores the full system prompt, the full user prompt (the four grounding blocks verbatim), and the full answer. Admin endpoint GET /audit/suggestions/{log_id}/evidence returns a downloadable text/markdown document with system prompt, user prompt, answer, context-used breakdown and every feedback event — ready to print-to-PDF for compliance / HR review.
JSON-only output (cataloguing endpoints) Prompt For listing endpoints (role suggestions, market trends, emerging roles), the system prompt ends with "Respond ONLY with a valid JSON array". _parse_json() strips markdown fences and falls back to empty list. Conversational endpoints (advice / chat / summary) return Markdown rendered safely via the shared CfMdText component (marked + DOMPurify).
Input length cap API Coach advice question: max 2000 chars (FastAPI validator, advice.py:42). 422 returned if exceeded.
LLM timeout HTTP httpx.AsyncClient(timeout=45.0) for all LLM calls. Connector fetches use 60s timeout. Prevents stuck requests blocking workers.
Celery task isolation Infra task_acks_late=True, worker_prefetch_multiplier=1: tasks only ACK on success, preventing duplicate processing on crash.
Connector dedup Ingestion SHA-256 content hash checked before insertion. Prevents prompt poisoning from re-injecting modified documents at the same URL.
Encrypted connector config DB Connector API keys stored in config_json_encrypted (AES-256-GCM via Vault-backed key). Never logged.
API key injection Infra LLM keys come from Kubernetes Sealed Secrets → env vars. Never in code or ConfigMaps.
Retry cap Celery max_retries=3 on connector sync tasks. Failed ingestion writes to IngestionHistory.error_message and stops retrying after 3 attempts.
CORS allow-list Network Only the four portal domains are allowed; wildcard CORS is explicitly not configured.

Role-Based AI Access

Keycloak enforces which user roles can access which AI features. Each portal authenticates against its own realm; the token's realm_access.roles claim is verified on every request.

AI FeatureRequired RoleEndpoint
Learning path generationemployeeGET /learning-path
Skill gap analysisemployeeGET /skill-gap
Role suggestionsemployeeGET /role-suggestions
AI coaching advicecoachPOST /employees/{pseudo_id}/ai-advice
Emerging roles cataloguecoachGET /catalogues/roles
Market trendscoachGET /analytics/market-trend
Intelligence digestcoachGET /analytics/intelligence-digest
Connector managementSUPER_ADMIN / APP_ADMINPOST/PUT/DELETE /connectors
Trigger digest refreshcoachGET /analytics/intelligence-digest?refresh=true

PII Protection

Personal data is protected at two levels: pseudonymisation of the employee identity before it reaches any AI prompt, and AES-256-GCM encryption of the stored PII fields.

Pseudonymisation Flow

Keycloak sub
(real user ID)
→
HMAC-SHA256
with HMAC_KEY_HEX
→
pseudo_id
(opaque token)
→
Sent to LLM
(no name/email)

Coaches see only the pseudo_id, the employee's specialisation, seniority, experience years, and region (not city). The HMAC key is injected from Sealed Secrets — not derivable from any data in the LLM prompt.

At-rest Encryption

Employee PII fields (name, email, exact location) are stored encrypted in employee.encrypted_pii using AES-256-GCM. The key is injected at runtime from DEV_PII_KEY_HEX (Vault-backed in production, Sealed Secret in the demo cluster).

Strengths & Weaknesses

An honest assessment of what the current AI implementation does well and where it falls short.

Strengths

Dual-LLM fallback with graceful degradation The Claude → Perplexity → empty chain means the platform stays usable even if the primary LLM is down. Users see a "being configured" message rather than a crash.
AI-surface observability Prometheus counters & histograms cover the bits the HTTP-level instrumentator can’t see: cf_llm_calls_total (per provider/model, with outcome success | empty | http_error | exception), cf_llm_call_duration_seconds histogram (0.5…90s buckets), cf_suggestion_calls_total per suggestion_type & portal (incl. refused_thin_profile / empty_answer / feedback_*), and cf_llm_artifacts_scrubbed_total — if Perplexity citations or UUIDs start leaking again, that counter spikes and the alert fires before any user notices.
Schema/migration drift gate (CI) CI runs alembic check after alembic upgrade head; a SQLModel class without a matching migration fails the build. A pytest under tests/integration/ runs the same check locally. Closes the class of bug where production 500-ed with UndefinedTableError on the first call to a new endpoint.
Cost & abuse controls Three layers, all observable in Grafana: a per-employee daily quota (default 50, configurable, Redis-backed, UTC reset), a per-provider circuit breaker that opens after 5 consecutive failures and short-circuits LLM calls for 120s, and a monthly token cost cap configurable per active workflow (coverage_config.__cost__.monthly_token_cap). Admin endpoint /ai-workflow/cost-summary returns the live state. Fail-open on Redis outage so a sidecar problem doesn’t take the AI surface down.
Audit evidence trail (verbatim, downloadable) Every conversational suggestion stores the system prompt, the full user prompt (the four grounding blocks verbatim), and the full answer in payload_json. GET /audit/suggestions/{id}/evidence returns a printable Markdown document with that evidence plus every recorded feedback event. The admin Suggestion-Audit drawer now ships a Download evidence button that fetches and saves it as suggestion-{id}-evidence.md — the artefact HR / compliance / legal asks for when reviewing why the AI made a recommendation, in one click.
Runtime FE configuration on every portal All four portals (employee, coach, manager, admin) read their KEYCLOAK_URL / API_URL / realm / client-id from window.__APP_CONFIG__, populated by a /config.js file rendered from container env at startup (nginx /docker-entrypoint.d/ hook). Build-time VITE_* still works as a fallback so pnpm dev is unaffected. The cf-portal Helm chart accepts an optional env: block, so a single image promotes from demo → staging → production by overriding CF_KEYCLOAK_URL / CF_<APP>_API_URL at deploy time — no rebuilds.
Tiered caching in the DB TTLs are set by volatility: digest 7 days, role suggestions 24h, market trends 6h, roles catalogue 2h. LLM is called only when the cache expires — typical users never trigger a live call.
PII-safe by design Pseudo-IDs and HMAC pseudonymisation ensure real identity never reaches any LLM, regardless of whether the connector documents happen to mention company names.
Structured output enforcement Every prompt specifies the exact JSON schema. _parse_json() strips markdown and falls back to empty list. The UI never receives free-form prose from the LLM.
Modular connector ingestion New data sources can be added via the Admin portal without code changes. The ingestion task normalises heterogeneous API schemas automatically.
Async + fire-and-forget Learning path generation is queued at registration — API responds immediately. If Celery is unavailable, the path is generated on-demand instead. Zero hard dependency.
Extended thinking for deep tasks Learning path and digest generation use Claude with budget_tokens 2048–4096 for extended thinking, producing higher-quality, more coherent multi-node paths.
Vector cosine RAG retrieval _cached_text_search() embeds the query (Ollama nomic-embed-text or OpenAI text-embedding-3-large) and orders ai_document_vectors by cosine distance. "AWS" matches "Amazon Web Services", "k8s" matches "Kubernetes". Falls back to LIKE-based search when no embedding service is reachable so retrieval is never blocked.
Conversational endpoints deduplicated (response-hash cache) /ai-advice and /ai-chat compute sha256(pseudo + question + workflow + skill_count + opp_top_ids) and check Redis (5-min TTL) before any LLM call. Page reloads, double-clicks, and identical follow-up retries cost zero. Cache hits are recorded as cf_suggestion_calls_total{outcome="cache_hit"}.
Daily AI-quota visibility GET /api/v1/quota/me exposes the same Redis counter the breaker enforces. Employee portal sidebar polls it every 60s and shows AI: 48 / 50 today; turns orange at ≤ 5 remaining. Single source of truth — no parallel state, no drift between what the user sees and what the platform applies.
Outcome feedback loop — foundation shipped Every ai_suggestion_logs row can now be paired with one or more ai_suggestion_feedback rows: accepted, dismissed, rated (1–5), or auto. Employees record their own feedback via POST /api/v1/ai-suggestions/{log_id}/feedback; admins see acceptance-rate and average-rating per suggestion_type via /audit/feedback-summary. Still missing: the rules engine that emits automatic auto events (readiness delta after a learning path, application outcome after an opportunity match) — the substrate is there; the events aren’t.
AI-document cache pruning A daily Celery beat task (prune-ai-documents-cache, 86 400 s schedule) deletes ai_documents rows older than 2× their per-key TTL — roles 24h×2, gap 12h×2, roles_catalogue 2h×2, trends 6h×2, digest 168h×2. The 2× headroom avoids racing in-flight reads. The table no longer grows unbounded.
Strict ESLint gate in FE CI Flat-config eslint.config.mjs (eslint v9 + typescript-eslint v8 + eslint-plugin-vue v10) covers every apps/* and packages/* source file. Root pnpm lint runs --max-warnings 0; the FE Type-check workflow fails on any warning regression. Catches the bug class vue-tsc misses: dead bindings, unused imports, empty catch blocks, abandoned refs.

Weaknesses

No automatic outcome-feedback events The ai_suggestion_feedback table accepts the auto action but nothing emits those events yet. Without them, signal stays dependent on humans clicking thumbs-up / dismiss — rare enough that the rules engine on top can’t fire reliably. Wait for manual feedback volume before building.
No structured-output value-range validation Conversational surfaces are hardened against fabrication (4-pillar grounding, anti-citation system prompt, post-hoc UUID/citation scrub, 422 refusal on thin profiles). What’s still missing is value-range validation on JSON outputs — asserting match_score stays in [0,1], suggested roles exist in the taxonomy, and a human-review step for high-stakes recommendations.
Single embedding dimension assumption The schema hardcodes Vector(1536). Switching to a newer embedding model with different dimensions requires a schema migration.

Company Simulation

CareerForge is seeded with 14 realistic personas across 8 engineering practices, spanning 5 seniority levels and 4 user roles. This creates a miniature simulation of a real outsourcing company's internal dynamics — skill distributions, coaching relationships, cohort readiness, and management visibility.

Simulated Population

RoleCountPractices CoveredAI Interactions
Employees7Backend, Frontend, Data, Cloud, ML, QA, Security, PMOLearning paths, role suggestions, skill gap
Coaches6Backend+Frontend, Data+ML, Cloud+Security, Mobile, QA+PMO, BackendAI coaching advice, market trends, digest
Managers1Cross-practiceTeam readiness dashboard (AI-scored)
Admins4HR, Platform, Org, L&DConnector management, system config

Dynamics the Simulation Captures

The simulation is a stress test and a data generator Running all 14 personas through their natural workflows for 30 days produces a dataset of: coaching questions → advice pairs, employee learning events, skill gap evolutions, role suggestion accept/reject rates, and digest quality scores. This dataset is the foundation for the reconciliation loops described below.

Reconciliation Loops

A reconciliation loop is a closed feedback cycle: the platform observes the outcome of its AI recommendations, compares them to what actually happened, and adjusts future recommendations accordingly. CareerForge has the infrastructure to support several such loops — some partially built, some requiring additional work.

The Current Data Signals

Reconciliation Loop — Current + Future
1

AI generates recommendation
(learning path, role suggestion)

2

Employee accepts & acts
(commits, completes skill)

3

Coach signs off
(IDP form, verified skill)

4

Outcome measured
(readiness Δ, role movement)

5

Signal fed back
(update prompt, weights)

Loop 1 — Connector Quality Feedback

Current state: fully implemented infrastructure, loop not closed.

Every connector source has a trend_score field and last_status_code. The ingestion task records IngestionHistory.chunks_ingested and success. A reconciliation job could:

  1. Correlate connector sources with AI output quality (do documents from source X lead to higher-confidence market trends?)
  2. Automatically lower trend_score for sources that repeatedly 404 or return empty content
  3. Promote sources whose topics appear frequently in accepted learning paths

Loop 2 — Learning Path Acceptance Rate

Current state: data exists, loop not implemented.

The learning_path_nodes table records which nodes employees mark complete. A weekly digest job could compute:

-- Proposed reconciliation query
SELECT node_type, skill_name,
       COUNT(*) FILTER (WHERE status = 'completed') AS completed,
       COUNT(*) FILTER (WHERE status = 'skipped')  AS skipped,
       COUNT(*) AS total
FROM learning_path_nodes
WHERE created_at > NOW() - INTERVAL '30 days'
GROUP BY node_type, skill_name
ORDER BY (skipped::float / NULLIF(total, 0)) DESC;

Nodes with high skip rates could feed back into the system prompt: "Avoid recommending X for specialisation at seniority level — historically low completion rate."

Loop 3 — Role Suggestion Relevance

Current state: data partially exists.

If an employee clicks through a role suggestion and sets it as their target role, that is an acceptance signal. The ratio of suggested-to-accepted roles, broken down by specialisation and seniority, tells us whether the LLM is recommending roles people actually want. This can be used to:

Loop 4 — Coaching Advice Quality Score

Current state: not implemented. Requires UI addition.

Add a thumbs-up/down widget after each AI coaching response. Store the score in a new ai_feedback table keyed by (pseudo_id, question_hash, model_used, score). The reconciliation job then:

  1. Computes per-model quality scores over rolling 30-day windows
  2. Routes to the higher-scoring model when both are available
  3. Feeds low-scored (question, answer) pairs back to the prompt as negative examples

Loop 5 — Market Trend Accuracy

Current state: infrastructure exists for trend_direction tracking. Loop not closed.

Perplexity sonar-pro returns real-time web search context. Store the predicted trend_direction at time T. At time T+30d, re-run the trend query and compare. If the direction is consistent, increase the confidence score for that topic. If it reversed, lower it and flag for human review. Over time this builds a calibration curve for the AI's trend forecasting ability.

Self-Improvement Architecture (Target State)

# Proposed: weekly reconciliation task
@celery_app.task(name="reconcile_ai_quality")
async def reconcile_ai_quality():
    signals = {
        "path_skip_rates":   await compute_node_skip_rates(),
        "role_accept_rates": await compute_role_acceptance(),
        "advice_scores":     await aggregate_feedback_scores(),
        "connector_quality": await score_connector_sources(),
    }
    # Write signals as a new ai_document for RAG context injection
    await store_cached_response("__reconciliation_signals__", signals)
    # Update connector trend_scores based on quality signals
    await update_connector_scores(signals["connector_quality"])
    # Invalidate stale caches for sub-segments with poor outcomes
    await invalidate_poor_performing_caches(signals)

Future Roadmap

The following developments are ordered by impact and implementation complexity. Each builds on the existing infrastructure without requiring architectural rewrites.

#FeatureImpactComplexityDepends On
1 Vector similarity search (replace LIKE) SHIPPED
_cached_text_search() now embeds the query (Ollama or OpenAI), orders ai_document_vectors by cosine distance, and falls back to LIKE on any error so retrieval never blocks. See AI-Ops & Cost Resilience above.
High Low Done
2 LLM rate limiter per user/hour SHIPPED
Implemented as a per-employee daily quota (default 50/day, UTC reset) plus a per-provider circuit breaker and a monthly token cost cap. See the Guardrails table.
Medium Low Redis — done
3 Cache the coaching advice on question hash SHIPPED
Response-hash cache implemented with sha256(pseudo + question + workflow_id + skill_count + opp_top_ids), 5-min TTL, Redis-backed. Wired into /ai-advice and /ai-chat; double-clicks & reloads cost zero.
Medium Low Done
4 Cache pruning task SHIPPED
prune-ai-documents-cache Celery beat task runs every 86 400 s and deletes ai_documents rows older than 2× their per-key TTL (per _CACHE_TTL_HOURS: roles 24h, gap 12h, roles_catalogue 2h, trends 6h, digest 168h). The 2× headroom avoids racing in-flight reads.
Low-med Low Done
5 Coaching advice feedback widget BACKEND SHIPPED
Backend half complete: ai_suggestion_feedback table + employee POST/GET endpoints + admin /audit/feedback-summary rollup (acceptance_rate, avg_rating). FE button in the audit drawer is the remaining ~30 LoC.
High Medium FE drawer button
5b Skill data model rewrite + semantic matching
Replace the skills_json.skill_ids JSONB array with a typed employee_skills association table and a single attach_skill() normalisation funnel. Add vector(1536) embeddings to skill_taxonomy for cosine matching so "AWS" ≈ "Amazon Web Services". ADR 0002 with two-phase online migration is ready to execute.
Very high High 3-week project, ADR signed off
6 Reconciliation Celery task
Weekly job: compute skip rates, acceptance rates, feedback scores; update connector trend_score; inject signals into next prompt
High Medium Feedback widget (#5) + path completion tracking
7 Anthropic prompt caching SHIPPED
Implemented behind the supports_prompt_caching(provider) capability predicate so it stays inert on Perplexity / Ollama / OpenAI deployments. The Anthropic path sends system as [{type:text, cache_control:{type:ephemeral}}]. Cache-creation / cache-read tokens recorded as cf_prompt_cache_tokens_total{provider,direction}.
High Low Done (gated; activates on Anthropic deploys)
8 Fine-tuning dataset pipeline
Export (profile, question) → (accepted advice) pairs as JSONL for fine-tuning Claude or a smaller local model
High High Feedback loop (#6) + 3+ months of data
9 Local LLM option (Ollama Mistral/Llama)
Add Ollama as a third fallback in _ai_chat() for air-gapped environments; already used for embeddings
Low-med Medium Ollama service already in cluster for embeddings
10 Anomaly detection on skill gap evolution
Alert when an employee's readiness score drops unexpectedly fast — possible skill obsolescence or coaching relationship issue
Medium High Reconciliation task (#6) + Alertmanager integration
Items 1, 2, 3, 4, 5, 7 all shipped on 2026-04-29 Vector similarity, LLM rate limiter (per-employee daily quota + per-provider circuit breaker + monthly cost cap), response-hash cache, daily AI-document cache pruning, outcome feedback foundation, and Anthropic prompt caching are all live. The remaining big-impact items are #5b skill-data-model rewrite (3-week project, ADR signed off), #6 reconciliation Celery task (needs FE feedback widget + path-completion data), and #8–10 (fine-tuning, local LLM, anomaly detection — all dependent on #6).

CareerForge AI Deep Dive — Internal use only

← Back to Info Portal