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.ioDjango repo. Requests a small, additive set of fields on the existingJobmodel so the careers page ongatewaygeo.iocan 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.
4. featured — boolean field
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:
- Take
title + description + responsibilities + qualifications + desired_qualificationsas input. - Output exactly five integers 0-100, one per dimension, plus a short reasoning string per dimension that cites specific phrases from the JD.
- 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.
- 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
- Per job (Haiku 4.5): roughly $0.001-0.003
- Per job (Sonnet 4.6): roughly $0.01-0.03
- Total lifetime cost across all jobs G3 will ever post: well under $10
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
- No new endpoint — just the new field on the existing serializer
- No filter on this field — bars are purely visualization, not a filter dimension
- No real-time scoring API — all batch, at save time
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:
- Cheaper escape: do this in a follow-up PR after the simple fields land
- Even cheaper: do it as a periodic background task instead of save-time (less timely, but lower latency on save)
- Cheapest: stub the scoring to return uniform scores until you wire up the AI client. My UI will render fine; bars will all be the same height, which is at least visually obvious that “we haven’t scored these yet”
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:
- Adds
clearance_required,category(andremote_status,featured,summary,dimension_scoresif you’re including them) with the defaults above. - Updates the Job serializer to include the new fields.
- Updates
JobAdminso James can edit them in the Django admin UI. - If
summaryordimension_scoresis in scope: adds a management command for backfill (score_existing_jobs/summarize_existing_jobs), and apost_savesignal (or override ofModel.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
- No new endpoint
- No new permissions / auth
- No rate limiting (this is a public read endpoint)
- No new tests beyond a single serializer test confirming the new fields
appear in
/api/jobs/output
Discovery questions
These came up while planning that I can’t see from my repo. Please answer as you investigate and relay back:
-
Which Django app owns the
Jobmodel? My applications-handoff doc assumed'jobs.Job'for the FK — confirm or correct. -
Is the proposed
CATEGORY_CHOICESenum 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. -
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'. -
Anything in the existing Job model I should know about that overlaps with this ask? (Hidden tags field, JSON
metablob, etc. — if something already exists, I’d rather adopt it than add a parallel field.) -
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.
-
(Only if you’re tackling
summaryordimension_scores.) Which AI client / SDK is already wired into this Django app? Bedrock via boto3? Anthropic SDK directly? Something custom? Affects thegenerate_job_summary(job)andscore_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)