DEV Community

Cover image for From Score to Offer: Generating a Real Offer Letter On-Device
Alex DevOps engineer
Alex DevOps engineer

Posted on

From Score to Offer: Generating a Real Offer Letter On-Device

DevOps Interview AI runs realistic mock DevOps job interviews entirely on-device — no backend, no account, nothing leaving the phone. Its Android app has a 4-round hiring pipeline: Recruiter Screening → Technical → CTO → HR, ending on a Final Offer screen. Until v1.12.0, that screen was a scoreboard: an average of your model and keyword scores. Useful, but not what "Congratulations, here's your offer" implies. The web sibling app had generated a real offer letter from Stage 1 answers since a much earlier release — Android never had the equivalent, because its Screening round asked whatever the shuffled question bank happened to pick, with no guarantee any of it was reusable afterward.

Here's how that gap got closed.

The core idea: tag the question, not the answer

The obvious-looking approach is to bolt a short form onto the Offer screen — "before we show your offer, tell us your name, stack, salary expectations…" That's an extra step nobody asked for, right after four rounds of talking.

Instead, five existing Screening questions got tagged with what they already answer:

enum class ProfileField { NAME, TECH_STACK, LOCATION, SALARY, NOTICE_PERIOD }

data class TechnicalQuestion(
    val text: LocalizedText,
    val level: SkillLevel,
    val category: String,
    val keywords: LocalizedList = LocalizedList(emptyList(), emptyList()),
    val profileField: ProfileField? = null,
)
Enter fullscreen mode Exit fullscreen mode

"What's your full name?", "What's your primary tech stack?", "Where are you currently based…?", "What are your salary expectations…?", "What's your notice period…?" — each gets a profileField tag on the question bank entry. Nothing about how the interview feels changes: these are ordinary Recruiter-round questions, asked in whatever shuffled order the session picks, alongside the untagged ones.

The one thing that has to change is the guarantee. A plain shuffled pick of count questions from the Recruiter pool could easily skip one or more of the five — fine for free practice, useless for an offer letter with holes in it. So the pipeline's Screening round doesn't use the general-purpose pickSession(); it gets its own selector:

fun pickPipelineScreeningSession(count: Int): List<TechnicalQuestion> {
    val (tagged, untagged) = recruiterScreening.partition { it.profileField != null }
    val remaining = (count - tagged.size).coerceAtLeast(0)
    return (tagged + untagged.shuffled().take(remaining)).shuffled()
}
Enter fullscreen mode Exit fullscreen mode

All five tagged questions go in unconditionally; the rest of the session count is filled from the shuffled remainder, then the whole thing is reshuffled so the profile questions don't visibly cluster. Free-practice interviews with the Recruiter persona keep calling pickSession() — this only changes the pipeline's Stage 1.

Capturing the answer where it's already being scored

InterviewViewModel already does something with every answer the moment it's submitted: score it against the question's keyword list. Piggybacking the profile capture onto that exact point means no separate "did we get everything" pass is needed:

sessionQuestions.getOrNull(answeredQuestion - 1)?.let { answeredQ ->
    keywordScores.add(KeywordEvaluator.score(userText, answeredQ.keywordsIn(language)))
    answeredQ.profileField?.let { field ->
        _uiState.update { it.copy(capturedProfile = it.capturedProfile.with(field, userText)) }
    }
}
Enter fullscreen mode Exit fullscreen mode

If the question just answered happens to carry a profileField, the candidate's own words — untouched, not reformatted — get folded into a CandidateProfile sitting in UI state. CandidateProfile.with() is a small copy() dispatch over the enum, so adding a sixth field later is a one-line change in two places, not a when rewritten from scratch.

Surviving a killed app: DataStore, same pattern as stage completion

UI state doesn't survive a killed app or a mid-pipeline restart — and neither should it have to, because the pipeline already had a durable store for stage completion (PipelineProgressStore, backed by Jetpack DataStore). The candidate profile got the same treatment instead of a parallel mechanism:

suspend fun saveCandidateProfile(profile: CandidateProfile) {
    context.pipelineDataStore.edit { prefs ->
        profile.candidateName?.let { prefs[Keys.candidateName] = it }
        profile.techStackOverview?.let { prefs[Keys.techStack] = it }
        profile.location?.let { prefs[Keys.location] = it }
        profile.salaryExpectations?.let { prefs[Keys.salary] = it }
        profile.noticePeriod?.let { prefs[Keys.noticePeriod] = it }
    }
}
Enter fullscreen mode Exit fullscreen mode

Note what this is not: it's not prefs.clear() + rewrite. Each field is only written if the incoming profile actually has it, so a save after Screening merges into whatever was already persisted rather than clobbering it. That matters because the Screening round streams answers in one at a time as the candidate talks — the profile a PipelineViewModel eventually persists is built up turn-by-turn, not delivered as one complete object at the end.

Rendering it, with an honest fallback

The Final Offer screen reads the persisted profile and turns it into an OfferLetter:

private const val PLACEHOLDER = "—"
private fun String?.orPlaceholder(): String = this?.trim().orEmpty().ifBlank { PLACEHOLDER }

fun generateOfferLetter(profile: CandidateProfile, position: String = "DevOps Engineer"): OfferLetter =
    OfferLetter(
        candidateName = profile.candidateName.orPlaceholder(),
        position = position,
        salaryExpectations = profile.salaryExpectations.orPlaceholder(),
        noticePeriod = profile.noticePeriod.orPlaceholder(),
        location = profile.location.orPlaceholder(),
        techStackOverview = profile.techStackOverview.orPlaceholder(),
    )
Enter fullscreen mode Exit fullscreen mode

A pipeline run that skips a question — or was started before this release even existed, so it has no captured profile at all — doesn't crash or show an empty string; it shows for that one field, same convention the web app already used. The screen adds candidate name, position, salary, notice period, location and tech stack, plus a copy-to-clipboard button for pasting the letter elsewhere.

Final Offer screen showing a generated offer letter with candidate name, position, salary, notice period, location, and tech stack

What shipped alongside it

Two smaller things rode in the same release:

  • A dead engine got deleted. LiteRtLmEngine was a parallel implementation for .litertlm model bundles, started as a migration that never finished — no working .litertlm asset exists for the native Android engine, so the class sat unreferenced by any code path. Removing it also dropped the litertlm-android dependency and two LlmConfig constants that existed only to feed it. The active engine stays MediaPipe's LlmInference against the Gemma 3 1B .task bundle.
  • Three new unit-test classes: OfferLetterTest (placeholder fallback per field, full-field fill, custom position), KeywordEvaluatorTest (case insensitivity, partial-stem matching, empty-keyword-list handling), and PipelineStageTest (the sequential stage-gating guard, including the offer stage's own gate).

The actual lesson

The tempting version of this feature adds a form. The shipped version adds a profileField: ProfileField? to a data class that already existed, reuses the selection function's shuffle logic instead of writing a new one, and hooks into the exact line where answers were already being scored. Every piece of plumbing this feature needed — question metadata, per-question answer capture, durable per-run storage — was already there for a different reason. The feature is small because the app's existing seams happened to line up with where it needed to attach.

All four pipeline rounds marked Completed, Final Offer unlocked


About the author

I'm Alex — a DevOps/engineer who builds things end-to-end and writes about the parts that don't make it into the README.

Where would you draw the line between "reuse an existing seam" and "this really does need its own new abstraction"? I'd like to hear how you decide.

Top comments (0)