§ unswayed-backend · API contract & docs
Lenux AI — JD optimization (Phase 13, UN-104)
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 eachBiasTermDictionaryrow, 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.
AiCompletionPortis 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:
- 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. - Applies right-to-left.
ApplyServicelooks 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. - 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 byapply.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-jsonhelper, the constants, and the seed service/command.
- 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
- e2e (18 tests,
test/lenux-jd-optimization.e2e-spec.ts) — boots the real app withAiCompletionPortoverridden by a scripted fake and seeds its ownBiasTermDictionaryrows (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.