CODEX // oaoisme wiki

§ unswayed-backend · API contract & docs

Lenux AI — JD optimization (Phase 13, UN-104)

updated 2026-06-16

Lenux AI — job-description optimization (Phase 13, T-13.3 / UN-104)

This slice helps recruiters improve a job posting before it goes live: scan it for biased language, suggest market-relevant skills, score its readability, recommend SEO improvements, and apply accepted fixes back into the JD. It lives at src/lenux-ai/jd-optimization/ and rides the shared Lenux /api/v1/* surface (bare camelCase JSON, employer-only, per-company / per-user rate limits). It is the third slice of Phase 13, built on the common primitives in src/lenux-ai/common/.

What it does

Five POST .../optimize/* actions, all behind @RequireLenuxFeature('jdOptimization'):

Method & path Limit Engine What it does
POST /api/v1/jobs/:jobId/optimize/bias-scan 20/hour/company dictionary + LLM Flag biased terms with offsets + neutral replacements
POST /api/v1/jobs/:jobId/optimize/skills-suggest 20/hour/company LLM Suggest in-demand skills for the role title
POST /api/v1/jobs/:jobId/optimize/readability 60/min/user pure compute Flesch reading-ease score + long-sentence rewrites
POST /api/v1/jobs/:jobId/optimize/seo 20/hour/company LLM Keyword + structure recommendations
POST /api/v1/jobs/:jobId/optimize/apply 20/hour/company pure + re-scan Apply accepted suggestions to the JD, then re-scan

jobId is the numeric EmployerJob.id. Note the readability endpoint is the odd one out: it is per-minute / per-user (cheap and deterministic), while the LLM-backed endpoints are per-hour / per-company (expensive).

The three engines

The slice deliberately uses three different computation strategies, one per kind of suggestion:

1. Bias scan — hybrid dictionary + LLM (BiasScanService)

Two passes, merged:

  • Dictionary pass (pure). A whole-word, case-insensitive regex (\bterm\b) over the JD finds every occurrence of each BiasTermDictionary row, recording its {start, end} character offsets. The curated replacements come straight from the dictionary row. "Rockstars" does not match the term "rockstar" (word boundary); "He" matches "he".
  • LLM contextual pass. AiCompletionPort is asked for STRICT JSON of extra contextual flags (coded slang like "ninja", phrases the dictionary can't list), each with offsets. Out-of-range / non-numeric / unknown-category items are dropped.

The two flag sets are merged and deduped by position (the flag id is bias:<start>:<end>). On a collision the dictionary wins — it carries the curated replacement list. The result is sorted by start offset. A malformed or empty LLM reply degrades gracefully to the dictionary-only result (it retries once first); only an unconfigured provider (LexiUnavailableError) becomes a 503.

2. Readability — pure Flesch-Kincaid (ReadabilityService), no LLM

The formula is implemented directly, no model call:

FRE = 206.835 − 1.015·(words/sentences) − 84.6·(syllables/words)

Syllables are counted with a vowel-group heuristic (count runs of vowels, drop a silent trailing "e", floor at 1 for any word with letters). The score is mapped to a US grade-level band (5, 6, 7, 8-9, 10-12, college, graduate). Rewrite suggestions are heuristic: sentences over 25 words are flagged with a "split it near the middle" rewrite hint, each carrying its {start, end} offsets and a read:<start>:<end> id. Because it's pure, it never touches the AI and is safe to call 60×/minute.

3. Skills & SEO — grounded LLM (JdSuggestionsService)

Both ask AiCompletionPort for STRICT JSON, parse + dedupe, retry once, and 422 on persistent garbage. Skills maps a role title to in-demand skills; SEO is grounded with a job-board best-practice prompt (clear title, scannable sections, in-demand keywords, inclusive language) and returns {keywords, structure}. An unconfigured provider → 503.

Apply + the stale-scan guard (the interesting edge case)

POST .../optimize/apply takes {acceptedSuggestions:[ids]} and:

  1. Checks freshness first. If the JD was edited externally since the last scan — job.updatedAt > job.lastOptimizedAt, or the job was never scanned — the stored offsets are stale, so it does not apply and returns {rescanRequired:true, appliedCount:0}. The recruiter must re-scan first.
  2. Applies right-to-left. ApplyService looks up each accepted id in the stored bias flags + readability suggestions, then splices each replacement into the description from the highest offset down, so earlier offsets stay valid. Overlapping spans are de-conflicted (a span is edited once). A bias flag uses its first suggestion; a readability suggestion uses its rewrite.
  3. Re-scans (never trusts stale offsets). After writing the new description it recomputes the bias scan + readability against the new text and stores those.

A subtle persistence detail makes the freshness guard robust: Prisma's @updatedAt clock (DB now()) and a JS new Date() differ by microseconds, which would make every fresh scan look stale. So after persisting, the slice pins lastOptimizedAt to the row's own updatedAt with a raw UPDATE … SET "lastOptimizedAt" = "updatedAt" that does not re-trigger the @updatedAt clock. The guard then reads fresh after our own write and only flips on a genuine external edit.

Persistence

All optimization state lives on the EmployerJob row (no new tables for the endpoints):

  • biasScanResults (JSON) — stored as {flags, readability} so the bias scan and the readability scan each update their own key without clobbering the other; both are read back by apply.
  • readabilityScore (Float) — the last Flesch score.
  • seoSuggestions (JSON) — {keywords, structure}.
  • lastOptimizedAt (DateTime) — drives the stale-scan guard.

The one new table is reference data: bias_term_dictionary (term unique, category enum gendered|age|exclusionary, suggestedReplacements JSON array), seeded by a CLI command (below).

The seed:bias-terms CLI command

BiasTermsSeedService (in the slice) holds a curated BIAS_TERM_SEED list across all three categories with neutral replacements (e.g. rockstar → skilled professional, young → energetic, chairman → chairperson). The seed:bias-terms command (src/cli/seed-bias-terms.command.ts, registered in CliModule) upserts it idempotently keyed on the unique term, so re-running is a safe no-op that only refreshes replacements:

npx ts-node -r tsconfig-paths/register src/cli.ts seed:bias-terms

Rate limiting & the 429 body

The LLM endpoints are 20/hour/company; readability is 60/min/user. When a window is exhausted the shared LenuxRateLimitGuard emits the verbatim Jira body and a Retry-After header:

{ "error": "rate_limit_exceeded", "limit": 20, "window": "1h", "retryAfterSeconds": 3600 }

(readability returns "window": "1m"). The global per-IP throttler is @SkipThrottle()'d off on this controller.

Testing

  • Unit (74 tests) — the readability formula + syllable counter + grade bands, the bias scanner (dictionary whole-word matching, hybrid merge/dedupe, malformed
    • invalid-offset degradation, 503 passthrough), the skills/SEO services (dedupe, retry→422, 503, prompt grounding), the offset-based apply (right-to-left splice, overlap de-conflict, no-replacement skip), the controller (persist + stale-scan guard + 503 mapping + bare shaping), the llm-json helper, the constants, and the seed service/command.
  • e2e (18 tests, test/lenux-jd-optimization.e2e-spec.ts) — boots the real app with AiCompletionPort overridden by a scripted fake and seeds its own BiasTermDictionary rows (shared reference data, untouched by the user-graph truncate). It drives every endpoint against Postgres: the hybrid scan + persistence, dictionary-only degradation, 503, cross-tenant 404, employer-only 403, 401, feature-flag 403, the verbatim 429 (1h + 1m windows), skills/SEO happy + 422/503, the pure readability path (asserting the AI is never called and the score is persisted), and the full apply flow including the stale-scan guard (external edit → rescanRequired:true, never-scanned → rescanRequired:true).

Coverage on the slice's logic files is 100% lines/functions and ≥91% branches, above the repo's 90% gate on all four metrics.