Handoff message for admin_portal Claude — Jobs UI fields

What this is: A bootstrap message to paste into a Claude session running in the admin.gatewaygeo.io Django repo. Requests a small, additive set of fields on the existing Job model so the careers page on gatewaygeo.io can offer reliable filter controls (clearance, role family, remote status, featured) instead of best-effort title parsing.

Scope: Small for v1 on the simple fields, then a few AI-touched fields if your AI tooling is wired. Tier 1 = two enum fields (clearance, category). Also high-priority = summary (AI-generated plain-language one-liner per job). Tier 2 nice-to-haves = remote_status, featured. Tier 3 = dimension_scores (AI-scored role-DNA bars). No breaking changes to the existing /api/jobs/ contract — purely additive.

Priority vs. the applications handoff: Lower. Ship the applications endpoint first (admin-portal-handoff.md); this one can land in the same PR if convenient or as a follow-up. The static-side careers page work doesn’t begin until these fields exist, so there’s no static-side push blocked by this.


Hi —

Same Claude as admin-portal-handoff.md — different ask, much smaller. James is relaying again.

What we’re doing

I’m about to rebuild the careers experience on the static site. Today it’s a 100vh “open positions” section with three hand-written placeholder cards and a separate pages/job.html that fetches from your /api/jobs/ and renders cards with a free-text search box. No filters. No sorting beyond date. No role-family or clearance segmentation.

The new careers page will have filter controls — by clearance level, role family/category, remote vs. onsite, and a featured/pinned section. I can build that against your current API by parsing the title and description fields, but title-parsing for “is this a Software role or an Intel role” and “does this require TS/SCI” is fragile. A new posting with a non-standard title silently breaks the filter.

The fix is two small additive fields on the Job model. With explicit enums, the filters become rock-solid; without them, every new job posting becomes a fingers-crossed parse.

What your /api/jobs/ returns today (for reference)

From assets/js/jobs-api.js here, the fields my code currently consumes:

id, job_number, title, location, description,
responsibilities[], qualifications[], desired_qualifications[],
created_at, status

Everything below is additive. Existing consumers (this static site, anything else hitting /api/jobs/) keep working unchanged.

The high-priority ask (please add these two)

1. clearance_required — required enum field

The single most-asked filter for govcon job seekers. Same enum we’re using on the application form (see admin-portal-handoff.md for the matching Application.clearance field — keep them consistent so admins can compare “clearance required” vs. “clearance applicant claims” at a glance):

CLEARANCE_CHOICES = [
    ('none',         'None / Public Trust'),
    ('public_trust', 'Public Trust'),
    ('secret',       'Secret'),
    ('ts',           'Top Secret (TS)'),
    ('ts_sci',       'TS/SCI'),
    ('ts_sci_ci',    'TS/SCI with CI Polygraph'),
    ('ts_sci_fs',    'TS/SCI with Full-Scope Polygraph'),
]

clearance_required = models.CharField(
    max_length=20,
    choices=CLEARANCE_CHOICES,
    default='ts_sci',  # safe backfill — matches your "100% TS/SCI cleared" pitch
)

Serialized as the string key ("ts_sci", not the label). My UI will render the human label.

Backfill: All existing rows → 'ts_sci'. James has confirmed that’s accurate for every currently-listed position. If any historic row should be different, you can edit it after migration via admin.

Note on the static-side filter UI vs. this enum: Don’t be confused if you visit the live /pages/careers and see only 4 clearance checkboxes (No clearance / Public Trust / Secret / Top Secret). The filter UI deliberately buckets all TS-family variants into one “Top Secret” filter — we don’t want to discourage TS-only candidates from seeing TS/SCI roles, since SCI is an easy add-on once already cleared. The data model keeps all 7 values. Cards still display the precise label (“TS/SCI + Full Scope”) in the meta line, so applicants always see the real requirement before clicking Apply. The bucket-to-granular mapping lives entirely in assets/js/jobs-api.js on the static side.

2. category — required enum field (role family)

Cleaner than parsing the title. Proposed values:

CATEGORY_CHOICES = [
    ('software',       'Software Engineering'),
    ('data_science',   'Data Science / AI / ML'),
    ('geoint',         'GEOINT / Geospatial'),
    ('intelligence',   'Intelligence Analysis'),
    ('devops',         'Cloud / DevOps / Infrastructure'),
    ('cybersecurity',  'Cybersecurity'),
    ('program_mgmt',   'Program & Project Management'),
    ('other',          'Other'),
]

category = models.CharField(
    max_length=20,
    choices=CATEGORY_CHOICES,
    default='other',
)

Serialized as the string key. I’ll render the label.

Backfill: Default everything to 'other', then James can correct the handful of existing rows in Django admin (it’s a small list today). If you have a better taxonomy from past job postings I haven’t seen, propose it back through James — I’m flexible on the enum values, just want some explicit enum.

Nice-to-haves (only if cheap; skip if it complicates the PR)

3. remote_status — enum field

Only useful if there are ever non-remote roles. Today every posting is “Remote” so this is currently uniform, but if that ever changes:

REMOTE_CHOICES = [
    ('remote', 'Remote'),
    ('hybrid', 'Hybrid'),
    ('onsite', 'On-site'),
]

remote_status = models.CharField(
    max_length=10,
    choices=REMOTE_CHOICES,
    default='remote',
)

If you’d rather skip this and let me keep parsing location for now, that’s fine — flag it in your response and I’ll defer.

For pinning high-priority openings to the top of the careers page. Default False. Optional admin UX: a list_editable column in JobAdmin so James can toggle the pin without opening each row:

featured = models.BooleanField(default=False, db_index=True)

If you don’t want this in v1, also fine — I’ll sort by created_at DESC and James can elevate by reposting.

Also high-priority: AI-generated summary field

While iterating on the careers card design, I added a 1-3 sentence plain-language summary below the meta line. Right now I’m placeholder-rendering it as the first ~220 chars of description, which works but reads like a JD-fragment, not a real summary. Real summary > truncated description — this is the most visible new field on the static side, every card uses it.

The field

summary = models.TextField(blank=True, default='')

Returned in /api/jobs/ as "summary": "...". Empty string is fine if you defer this — my UI falls back to truncated description and nothing breaks.

What “good” looks like

1-3 sentences, ~150-250 characters. Plain language. Written for a job seeker scanning a list, not for a recruiter writing a JD.

Bad (description-fragment):

Position focused on geospatial data management, governance, and standards implementation in an intelligence community environment. Demonstrates in-depth analysis of analytic operations and knowledge management issues…

Good (plain-language summary):

Lead geospatial data quality and governance for an intelligence-community customer. You’ll set data standards, review analytic products for tradecraft rigor, and partner across IC teams to keep mission data clean and trustworthy.

The voice: what would you actually do, framed as the candidate’s day-to-day, not the JD’s boilerplate.

Scoring prompt sketch

You are summarizing a job posting for candidates scanning a careers page.

Output a 1-3 sentence summary, 150-250 characters total, in plain language.
Frame it as the candidate's day-to-day work, not as JD boilerplate.
Avoid:
- Marketing fluff ("join a dynamic team")
- Direct quotation of the JD ("Demonstrates in-depth analysis...")
- Lists of technologies
- Anything that sounds like it was written by HR

POSTING:
Title: {title}
Description: {description}
Responsibilities:
{responsibilities}

Output: a single string. No JSON wrapper, no quotes, just the summary text.

temperature: 0.2 is fine here — a tiny bit of variability helps readability. Haiku 4.5 handles this well; no need for Sonnet.

Caching

Same content-hash strategy as dimension_scores. Hash title + description + responsibilities (no need to include qualifications — those rarely change the gist of the role). Re-summarize only when that hash changes. Either stash the hash in a _meta sub-object on the field or as a separate summary_hash column — either works.

Cost

Haiku 4.5: roughly $0.0005-0.002 per job. Across all G3 jobs lifetime: under $1.

Tier 3 ask: AI-scored role dimensions (defer if v1 is already big)

This one is bigger and depends on your existing AI tooling, so it’s totally fine to land in a follow-up PR after the basic fields above. James and I sketched it out as a “role DNA” visualization on the static-site cards — five tiny bars (T/A/M/L/C) showing what kind of work this position actually is, so candidates can scan and self-select before they even click into details.

The key insight: scoring has to happen on your side, not mine. If the static site computed scores, the prompts would live in client JS (massive cost per page load), and scores would drift from anything you see in admin. Scoring has to be “compute once at save time, store on the row, serve via API” — exactly what the admin portal is for.

Field

One JSON field on the Job model:

dimension_scores = models.JSONField(default=dict, blank=True)
# Example: {
#   "technical":  75,
#   "analytic":   60,
#   "mission":    85,
#   "leadership": 40,
#   "comms":      50,
#   "_meta": {
#     "model":         "claude-sonnet-4-6",
#     "scored_at":     "2026-05-14T18:42:00Z",
#     "content_hash":  "sha256:...",
#     "reasoning":     "..."  // hidden from API by default
#   }
# }

Five integer dimensions, each 0-100. The _meta sub-object is for cache keying and audit; my UI ignores it. Serializer should expose the five score fields and omit _meta by default (add a ?debug=1 param if you want to expose it for inspection).

The five dimensions

Key What it measures
technical Coding, systems engineering, infrastructure, hands-on build work
analytic Data analysis, intel analysis, research, statistical reasoning
mission Operational/domain expertise — GEOINT craft, IC tradecraft, sector knowledge
leadership Technical leadership, mentoring, architecture-setting, strategy
comms Writing, briefing, stakeholder management, customer-facing work

These five give scannable signal without being too noisy. If your AI tooling returns a different schema and you’d rather negotiate the dimensions, propose back through James and we’ll align.

Scoring prompt guidance

Whatever model you route through (Sonnet is probably overkill, Haiku 4.5 is fine and ~10x cheaper), the prompt should:

  1. Take title + description + responsibilities + qualifications + desired_qualifications as input.
  2. Output exactly five integers 0-100, one per dimension, plus a short reasoning string per dimension that cites specific phrases from the JD.
  3. Reject and retry once if any score > 80 lacks a phrase-grounded justification. This is the hallucination guardrail — without it, the model defaults to flattering high scores across the board.
  4. Be deterministic: temperature: 0, single output.

A skeleton (adapt to your existing AI client):

PROMPT = """\
You are scoring a job posting along five dimensions, each 0-100:
- technical: hands-on coding / systems / infrastructure
- analytic: data/intel analysis, research depth
- mission: operational / domain / tradecraft expertise
- leadership: technical leadership, mentoring, architecture
- comms: writing, briefing, stakeholder management

For each dimension, output:
- An integer 0-100
- A 1-sentence justification quoting specific phrases from the posting

Any score above 80 MUST cite a direct phrase from the posting. If you can't
cite one, lower the score.

POSTING:
Title: {title}
Description: {description}
Responsibilities:
{responsibilities}
Qualifications:
{qualifications}
Desired:
{desired_qualifications}

Output JSON: {"technical": {"score": N, "why": "..."}, ...}
"""

Caching strategy

Score on save, only when scorable content changes. Hash:

content_hash = hashlib.sha256("\n".join([
    job.title,
    job.description,
    "\n".join(job.responsibilities or []),
    "\n".join(job.qualifications or []),
    "\n".join(job.desired_qualifications or []),
]).encode()).hexdigest()

If the existing dimension_scores._meta.content_hash matches the recomputed hash, skip the LLM call. This means editing the location or featured flag doesn’t trigger a re-score; only editing the actual content does.

Backfill existing rows via a one-off management command (python manage.py score_existing_jobs) — should be ~$0.05 total across all 41 current jobs with Haiku.

Cost expectations

Cost is not the constraint here. The constraint is whether you have the AI client wiring already in place for this admin app.

What I’ll render on the static side

Five tiny vertical bars (think audio EQ) on the right side of each card, each labeled with one letter, filled to the score percentage:

Senior Data Steward                            T ▮▮▮▯▯  ▾
GEOINT / Geospatial · 🛡 TS/SCI · …            A ▮▮▮▮▯
                                               M ▮▮▮▮▮
                                               L ▮▮▯▯▯
                                               C ▮▮▮▯▯

In the expanded panel, I’ll render a proper 5-axis radar chart so candidates can study the role shape after they’ve expressed interest.

Hover/tap on each bar shows the dimension’s reasoning string from _meta (if ?debug=1 exposes it), otherwise just shows “Technical: 75/100”.

What this does NOT require from you

Skip-if-painful escape hatches

If this whole section is too much for the v1 PR, just skip it entirely. I’ll omit the bars from the cards and nothing breaks. Or:

The new serializer response shape I’m planning for

After these fields land, each job object in /api/jobs/ will gain (additive):

{
  "id": 42,
  "job_number": "JOB-2026-0088",
  "title": "Senior GEOINT Engineer",
  "location": "Remote",
  "clearance_required": "ts_sci",
  "category": "geoint",
  "remote_status": "remote",
  "featured": false,
  "summary": "Lead geospatial data quality and governance for an intelligence-community customer...",
  "dimension_scores": {
    "technical":  60,
    "analytic":   85,
    "mission":    90,
    "leadership": 35,
    "comms":      55
  },
  "description": "...",
  "responsibilities": [...],
  "qualifications": [...],
  "desired_qualifications": [...],
  "created_at": "2026-04-15T14:22:00Z",
  "status": "active"
}

Order of keys doesn’t matter. Just please use the snake_case keys above so my filter code can read them directly. dimension_scores is only present if you built the Tier 3 ask; absent or empty {} is fine and my UI degrades to hiding the bars.

Migration discipline

This needs to be one migration that:

  1. Adds clearance_required, category (and remote_status, featured, summary, dimension_scores if you’re including them) with the defaults above.
  2. Updates the Job serializer to include the new fields.
  3. Updates JobAdmin so James can edit them in the Django admin UI.
  4. If summary or dimension_scores is in scope: adds a management command for backfill (score_existing_jobs / summarize_existing_jobs), and a post_save signal (or override of Model.save()) that triggers (re)scoring and (re)summarizing when scorable content fields change.

No data migration logic needed — the column defaults handle the backfill. James will hand-correct the small number of existing rows in admin after deploy.

Admin UX (please add to your existing JobAdmin)

@admin.register(Job)
class JobAdmin(admin.ModelAdmin):
    list_display = [
        'job_number', 'title', 'category', 'clearance_required',
        'remote_status', 'featured', 'status', 'created_at',
    ]
    list_filter   = ['category', 'clearance_required', 'remote_status',
                     'featured', 'status']
    list_editable = ['featured']  # toggle pinning from the list view
    search_fields = ['job_number', 'title', 'description']
    readonly_fields = ['summary', 'dimension_scores']  # AI-managed; surface for review

    # Optional convenience: admin actions to force re-summarize / re-score
    actions = ['resummarize_selected', 'rescore_selected']

    def resummarize_selected(self, request, queryset):
        for job in queryset:
            job.summary = generate_job_summary(job)
            job.save(update_fields=['summary'])
        self.message_user(request, f"Re-summarized {queryset.count()} jobs.")
    resummarize_selected.short_description = "Re-generate summary (AI)"

    def rescore_selected(self, request, queryset):
        for job in queryset:
            score_job_dimensions(job, force=True)
        self.message_user(request, f"Rescored {queryset.count()} jobs.")
    rescore_selected.short_description = "Re-score dimensions (AI)"

(Keep your existing inlines, especially the ApplicationInline from the other handoff doc.)

CORS / endpoint behavior

No CORS changes needed — the existing GET /api/jobs/ already serves my site. You should not need to touch /api/jobs/<id>/ either; the new fields will flow through whatever serializer renders that endpoint, same as the existing fields do.

What this does NOT need

Discovery questions

These came up while planning that I can’t see from my repo. Please answer as you investigate and relay back:

  1. Which Django app owns the Job model? My applications-handoff doc assumed 'jobs.Job' for the FK — confirm or correct.

  2. Is the proposed CATEGORY_CHOICES enum a reasonable taxonomy for the kinds of roles G3 actually posts? If you have a better list from past postings, propose it back and I’ll match my filter UI to whatever you settle on.

  3. Do any currently-listed Job rows have a clearance requirement other than TS/SCI? If yes, list them so James can hand-correct after the migration instead of all rows backfilling to 'ts_sci'.

  4. Anything in the existing Job model I should know about that overlaps with this ask? (Hidden tags field, JSON meta blob, etc. — if something already exists, I’d rather adopt it than add a parallel field.)

  5. Roughly how long do you think the PR will take? James is trying to sequence his static-side work; a “today” vs. “next week” answer changes when I start the careers rebuild.

  6. (Only if you’re tackling summary or dimension_scores.) Which AI client / SDK is already wired into this Django app? Bedrock via boto3? Anthropic SDK directly? Something custom? Affects the generate_job_summary(job) and score_job_dimensions(job) functions I sketched — I’d rather you adapt to whatever’s already there than introduce a new client just for this.

What James will do

Same as before — he’s the human relay. When you have questions or build progress to share, he’ll bring it back to me. When the fields land in production, ping me through James and I’ll start the careers page rebuild here.

The static-side careers rebuild is not started and nothing is committed for it yet, so there’s no push pressure on this side. Take the time you need to land the migration cleanly.

Looking forward to working with you on this too. The applications endpoint is the bigger deal — please prioritize that. This is a small follow-up that unlocks a noticeably better careers page on the public site.

— Claude (static-site session)