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.
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
| Component | File | Port / Trigger | Role |
|---|---|---|---|
| ai-connector-service | services/ai_connector/main.py | :8001 | FastAPI: connector CRUD, task status polling, Prometheus metrics |
| rag.py | services/ai_connector/rag.py | imported | LLM fallback chain, caching, text search, all public AI functions |
| embeddings.py | services/ai_connector/embeddings.py | imported | Text chunking + embedding generation (Ollama / OpenAI) |
| celery-worker | tasks/ingestion.py, tasks/pipeline.py | Redis queue | Connector sync, learning path generation, intelligence digest |
| celery-beat | services/ai_connector/celery_app.py | Cron (hourly) | Schedules sync_all_connectors every hour |
| PostgreSQL / pgvector | shared/models/ai.py | DB | Stores ai_documents, vectors (1536 dims), connector configs, ingestion history |
| Redis | celery_app.py | :6379 | Celery 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:
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
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
(employee / coach)
employee/coach svc
cache lookup
pgvector cosine
context + task
Claude / Perplexity
+ cache result
Public AI Functions
| Function | Consumer | Output Schema | Cache 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:
Cache Key Design
| Key Pattern | Scoped By | Example |
|---|---|---|
| __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)
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
| Layer | What it does | Effect |
|---|---|---|
| 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.
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:
- alembic check — fails when a SQLModel field has no matching migration column. The bug class that manifests as UndefinedColumn 500s after a fresh deploy. migrations/env.py uses an _include_object filter that skips index, FK, and unique-constraint naming divergence (cosmetic only) so this gate flags real drift cleanly.
- 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 (table referenced via FROM api_keys, no model, no migration → 500 on first admin click). The alembic gate can't see those tables; this one tokenises every .py file in services/ and asserts each raw-SQL identifier maps to a real table or a tiny allowlist.
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
| Table | Purpose | Key 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)
Response normalisation handles multiple API schemas automatically: items[], data[], results[], or bare dict. Field aliases resolve content/body/description/text and url/link transparently.
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.
| Task | Trigger | LLM Budget | Output |
|---|---|---|---|
| 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.
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.
| Guardrail | Layer | Implementation |
|---|---|---|
| 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 Feature | Required Role | Endpoint |
|---|---|---|
| Learning path generation | employee | GET /learning-path |
| Skill gap analysis | employee | GET /skill-gap |
| Role suggestions | employee | GET /role-suggestions |
| AI coaching advice | coach | POST /employees/{pseudo_id}/ai-advice |
| Emerging roles catalogue | coach | GET /catalogues/roles |
| Market trends | coach | GET /analytics/market-trend |
| Intelligence digest | coach | GET /analytics/intelligence-digest |
| Connector management | SUPER_ADMIN / APP_ADMIN | POST/PUT/DELETE /connectors |
| Trigger digest refresh | coach | GET /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
(real user ID)
with HMAC_KEY_HEX
(opaque token)
(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
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.
Weaknesses
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
| Role | Count | Practices Covered | AI Interactions |
|---|---|---|---|
| Employees | 7 | Backend, Frontend, Data, Cloud, ML, QA, Security, PMO | Learning paths, role suggestions, skill gap |
| Coaches | 6 | Backend+Frontend, Data+ML, Cloud+Security, Mobile, QA+PMO, Backend | AI coaching advice, market trends, digest |
| Managers | 1 | Cross-practice | Team readiness dashboard (AI-scored) |
| Admins | 4 | HR, Platform, Org, L&D | Connector management, system config |
Dynamics the Simulation Captures
- Skill distribution heterogeneity — a junior QA engineer has a different AI-generated learning path than a senior Data Engineer, even within the same company
- Coach caseload variation — some coaches cover two practices; their AI advice calls span different specialisations, stress-testing the system prompt's generalism
- Temporal dynamics — caches expire at different rates; the snapshot of "current" AI output shifts over the 2h/6h/24h/7d cycle even with no user action
- Commitment and sign-off workflow — forms create durable commitments that accumulate; over time the DB grows a history of what was promised vs delivered
- Manager visibility lag — the manager sees readiness scores that reflect cached AI output; there is a natural delay between a skill being updated and the manager's dashboard updating
- Practice-level cohort patterns — employees in the same practice tend to receive similar role suggestions (same specialisation cache key), surfacing whether the LLM's recommendations are varied enough
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
AI generates recommendation
(learning path, role suggestion)
Employee accepts & acts
(commits, completes skill)
Coach signs off
(IDP form, verified skill)
Outcome measured
(readiness Δ, role movement)
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:
- Correlate connector sources with AI output quality (do documents from source X lead to higher-confidence market trends?)
- Automatically lower trend_score for sources that repeatedly 404 or return empty content
- 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:
- Adjust the role suggestion prompt to reflect which roles in which practices actually get accepted
- Build a fine-tuning dataset of (profile → accepted role) pairs
- Invalidate the 24h cache early for a sub-segment if the acceptance rate drops below a threshold
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:
- Computes per-model quality scores over rolling 30-day windows
- Routes to the higher-scoring model when both are available
- 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.
| # | Feature | Impact | Complexity | Depends 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 SHIPPEDprune-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 |
CareerForge AI Deep Dive — Internal use only