Skip to content

Commit 78184fe

Browse files
LEANDERANTONYclaude
andcommitted
feat(resume-builder): Slice 1B — full conversation history + proactive_offer chip
The two user complaints behind this slice: 1. "Is each new question going to a fresh gpt instance?" — agent was forgetting mid-session because build_resume_builder_prompt hard- sliced history at [-12:]. 2. "I want it to suggest things — like draft a summary based on what I've shared" — agent never volunteered, only answered. Slice 1B addresses both end-to-end (backend + UI): - Dropped the [-12:] truncation. New _slice_history_for_budget helper takes the most recent suffix that fits under 30k chars (~7.5k tokens); always keeps at least the newest entry. In-memory cap bumped 48 → 200 entries (the per-prompt char budget defends the token budget; the entry cap is a long-session memory safety valve). - Added proactive_offer: str | None as a 5th key to the resume_builder LLM contract. Prompt teaches the model when to fire (enough signal to draft something) and HOW to phrase it (first-person imperative CTA, not a question or vague offer). Concrete good/bad examples in the prompt; cap of 200 chars at the backend boundary. - _run_llm_turn returns (assistant_message, proactive_offer); threaded through _serialize_session into the response payload. - Frontend: ResumeBuilderSessionResponse gets proactive_offer field. ResumeIntake renders it as a "✨ ..." pill below the assistant message when present. handleResumeBuilderAnswer now accepts an optional overrideText so the chip submits the offer directly without round-tripping through the textarea state. - 4 new tests for the budget-aware history slicer (under/over budget, single-entry-over-budget kept, empty input). Verification: 172 tests across affected suites all green (168 → 172). Frontend tsc + eslint exit 0. What's still parked (report.md Phase 2/3): pending_followups[] promise tracking, web_search tool, full conversational eval (15-20 rubric- scored fixtures), ADR-031. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 055fd60 commit 78184fe

8 files changed

Lines changed: 347 additions & 20 deletions

File tree

backend/services/resume_builder_service.py

Lines changed: 50 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1512,6 +1512,7 @@ def _serialize_session(
15121512
session: ResumeBuilderSession,
15131513
*,
15141514
assistant_message: str | None = None,
1515+
proactive_offer: str | None = None,
15151516
):
15161517
# `_step_index("review")` returns 0 because "review" isn't in
15171518
# RESUME_BUILDER_STEPS — that used to flicker the UI back to 0%
@@ -1533,6 +1534,12 @@ def _serialize_session(
15331534
"total_steps": len(RESUME_BUILDER_STEPS),
15341535
"progress_percent": progress_percent,
15351536
"assistant_message": assistant_message or _current_prompt(session.current_step),
1537+
# Slice 1B: optional click-to-accept CTA the UI renders as a
1538+
# chip below the assistant_message. Carried only on turns
1539+
# where the LLM fired one — other call sites (regex fallback,
1540+
# /update, /generate, /commit) leave this null because there's
1541+
# no LLM payload to read it from.
1542+
"proactive_offer": proactive_offer,
15361543
"draft_profile": asdict(session.draft),
15371544
"generated_resume_markdown": session.generated_resume_markdown,
15381545
"generated_resume_plain_text": session.generated_resume_plain_text,
@@ -1899,11 +1906,14 @@ def _run_llm_turn(
18991906
):
19001907
"""Drive one conversational turn through the LLM intake prompt.
19011908
1902-
Returns the assistant message text on success. Mutates the session
1903-
in place: applies `draft_updates`, updates `status` and
1904-
`current_step`, appends the user/assistant turn pair to
1905-
`conversation_history`. Raises `AgentExecutionError` on any failure
1906-
so the caller can swallow it and fall back to the regex flow.
1909+
Returns a tuple ``(assistant_message, proactive_offer)`` on
1910+
success. ``proactive_offer`` is a short CTA string the UI renders
1911+
as a clickable chip below the assistant_message, or None when the
1912+
model didn't fire one this turn. Mutates the session in place:
1913+
applies `draft_updates`, updates `status` and `current_step`,
1914+
appends the user/assistant turn pair to `conversation_history`.
1915+
Raises `AgentExecutionError` on any failure so the caller can
1916+
swallow it and fall back to the regex flow.
19071917
19081918
Slice 1A: when the OpenAIService exposes a `run_tool_loop`
19091919
method (the agentic-loop entry-point that supports Responses-API
@@ -1973,6 +1983,25 @@ def _run_llm_turn(
19731983
if not assistant_message:
19741984
raise AgentExecutionError("LLM returned an empty assistant_message.")
19751985

1986+
# Slice 1B: proactive_offer rides on the assistant_message turn but
1987+
# is rendered separately by the UI (as a clickable chip the user
1988+
# can accept with one click). Null / empty / placeholder values
1989+
# are normalized to None so the UI doesn't render a dead chip.
1990+
raw_offer = payload.get("proactive_offer")
1991+
proactive_offer: str | None
1992+
if isinstance(raw_offer, str):
1993+
stripped = raw_offer.strip()
1994+
# Cap at a friendly length — the chip button can't render a
1995+
# full sentence comfortably. Past this the prompt is doing
1996+
# something wrong (writing a question, not a CTA); cropping
1997+
# also defends the UI from accidental paragraph blobs.
1998+
if 0 < len(stripped) <= 200:
1999+
proactive_offer = stripped
2000+
else:
2001+
proactive_offer = None
2002+
else:
2003+
proactive_offer = None
2004+
19762005
raw_status = str(payload.get("status") or "").strip().lower()
19772006
status = raw_status if raw_status in _VALID_STATUSES else "collecting"
19782007
session.status = status
@@ -2015,14 +2044,20 @@ def _run_llm_turn(
20152044
session.conversation_history.append(
20162045
{"role": "assistant", "content": assistant_message}
20172046
)
2018-
# Cap memory: keep only the last 24 turn pairs (+ any interleaved
2019-
# tool events) so a long session doesn't blow the prompt budget.
2020-
# The model still sees enough context for back-references; older
2021-
# turns are summarized by the current `draft` state itself.
2022-
if len(session.conversation_history) > 48:
2023-
session.conversation_history = session.conversation_history[-48:]
2024-
2025-
return assistant_message
2047+
# Cap memory: keep up to ~100 turn pairs (+ any interleaved tool
2048+
# events) per session. Slice 1B widened this from the previous 48
2049+
# entries because the user complaint was "is each new question
2050+
# going to a fresh gpt instance?" — the agent was forgetting
2051+
# mid-session, and a 12-pair window was clearly too narrow. The
2052+
# prompt-time guard
2053+
# (``prompts._slice_history_for_budget`` with a 30k-char cap) is
2054+
# what actually defends the per-turn token budget; this hard cap
2055+
# is just a long-session memory safety valve so pathological
2056+
# sessions don't grow unboundedly in process memory.
2057+
if len(session.conversation_history) > 200:
2058+
session.conversation_history = session.conversation_history[-200:]
2059+
2060+
return assistant_message, proactive_offer
20262061

20272062

20282063
def _advance_step_after_regex_apply(session: ResumeBuilderSession, current_step: str):
@@ -2068,14 +2103,15 @@ def answer_resume_builder_message(
20682103
# on JSON-decode failures, or on any other LLM error.
20692104
if openai_service is not None and openai_service.is_available():
20702105
try:
2071-
assistant_message = _run_llm_turn(
2106+
assistant_message, proactive_offer = _run_llm_turn(
20722107
session=session,
20732108
user_message=normalized_message,
20742109
openai_service=openai_service,
20752110
)
20762111
return _serialize_session(
20772112
session,
20782113
assistant_message=assistant_message,
2114+
proactive_offer=proactive_offer,
20792115
)
20802116
except AgentExecutionError as exc:
20812117
log_event(

docs/DEVLOG.md

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1687,3 +1687,107 @@ plan):
16871687
Slice 1A demo path: paste a public GitHub URL into the resume builder
16881688
and watch the agent read it. The honesty patch + tool dispatch fall
16891689
out for free.
1690+
1691+
## Day 57: Résumé-builder Slice 1B — full conversation history + `proactive_offer` chip
1692+
1693+
Slice 1A made the agent capable; Slice 1B makes it FEEL intelligent.
1694+
The two user complaints behind this slice:
1695+
1696+
1. "Is each new question going to a fresh gpt instance?" — the agent
1697+
was forgetting mid-conversation. Why: `build_resume_builder_prompt`
1698+
hard-sliced history at `[-12:]` (the last 6 user/assistant pairs).
1699+
Past that point the model saw nothing — no context, no callbacks,
1700+
no "as I said earlier." Felt like a fresh instance because, prompt-
1701+
wise, it nearly was.
1702+
2. "I want it to suggest things — like draft a summary based on the
1703+
projects I shared, the way you do." — the agent never volunteered.
1704+
It asked questions and saved answers; it never said "given what
1705+
we have, want me to draft your professional summary?"
1706+
1707+
Slice 1B addresses both, end-to-end (backend + UI):
1708+
1709+
**(1) Full conversation history with a character-budget guard.**
1710+
1711+
- Dropped the hard `history[-12:]` slice in `build_resume_builder_prompt`.
1712+
- New `_slice_history_for_budget(history, max_chars=...)` helper
1713+
(default 30 000 chars ≈ ~7 500 tokens) walks newest-first,
1714+
accumulating serialized entries until adding the next would exceed
1715+
budget. Returns a contiguous SUFFIX of the history in chronological
1716+
order. ALWAYS keeps at least the most recent entry so an over-budget
1717+
newest message doesn't silently break the chat.
1718+
- The in-memory `conversation_history` cap bumped from 48 to 200
1719+
(~100 turn pairs + interleaved tool events) — the soft prompt-time
1720+
guard is what defends per-turn token budget; the hard cap is a
1721+
pathological-session memory safety valve.
1722+
- New unit tests for the slicer (4 cases: under-budget passthrough;
1723+
over-budget keeps a suffix; single-entry-over-budget still returned;
1724+
empty/None input).
1725+
1726+
**(2) `proactive_offer` JSON channel + UI chip.**
1727+
1728+
- Added `proactive_offer: str | None` as a 5th key to the resume-
1729+
builder LLM contract. Documented in
1730+
`prompts/resume_builder/v1.json` with concrete examples of GOOD
1731+
offers ("Draft my professional summary"; "Group my skills into
1732+
Languages / Frameworks / Tools") and BAD ones ("Help me with my
1733+
resume" — too vague; "Continue" — not an action; "Are you done?"
1734+
— that's a question, not an offer). The prompt also tells the
1735+
model the CTA is rendered FROM THE USER'S point of view (first-
1736+
person imperative), so the user clicks the chip to say "yes, do
1737+
that." Mirrored byte-for-byte in `tests/test_prompts.py` and added
1738+
to the `expected_keys` assertion so a typo in the JSON fails at
1739+
parse time. Slice 1B also caps the offer at 200 chars at the
1740+
backend boundary — defense against a model that returns a full
1741+
paragraph instead of a CTA.
1742+
- `_run_llm_turn` returns a `(assistant_message, proactive_offer)`
1743+
tuple; the caller (`answer_resume_builder_message`) threads the
1744+
offer through to `_serialize_session`, which adds it to the
1745+
response payload alongside `assistant_message`.
1746+
- Frontend: added `proactive_offer?: string | null` to
1747+
`ResumeBuilderSessionResponse`. In `ResumeIntake.tsx`, when the
1748+
field is populated, render a single pill-shaped chip below the
1749+
assistant message ("✨ Draft my professional summary"). The chip
1750+
calls a new `onBuilderProactiveOfferAccept(offer)` callback —
1751+
`handleResumeBuilderAnswer` in `WorkspaceShell` now accepts an
1752+
optional `overrideText` parameter, so the chip submits the offer
1753+
text directly without round-tripping through the textarea state
1754+
(avoiding the React setState async window where a Continue click
1755+
fires before the prefilled value commits).
1756+
1757+
What this changes for users:
1758+
1759+
- The agent remembers across the whole session — you can correct a
1760+
field you mentioned 15 turns ago and the model knows what you're
1761+
referring to. Within prompt-budget anyway (30k chars ≈ ~25 dense
1762+
turn pairs; for typical sessions, the whole thing fits).
1763+
- The agent volunteers. Once you've shared a few projects and
1764+
experience entries, you'll see a "✨ Draft my professional summary"
1765+
chip pop up — one click and the model goes off and writes it from
1766+
what you've already told it. No more typing "yes, do that" by hand.
1767+
1768+
What's still parked (`report.md` Phase 2/3):
1769+
1770+
- Promise tracking (`pending_followups[]` session state, so the agent
1771+
remembers things it said it would do later)
1772+
- `web_search` tool for company / role / domain context
1773+
- Full conversational eval (15–20 hand-curated transcript fixtures
1774+
+ rubric) — important quality infra; the smaller 5–8 fixture
1775+
minimal eval is also still parked. Slice 1B intentionally doesn't
1776+
block on eval; the changes are additive (existing behavior
1777+
preserved when `proactive_offer` is null and full history fits
1778+
under budget) and locally tested.
1779+
- ADR-031 for the agentic shape (draft during Slice 1C, accept when
1780+
the rest of Phase 2 lands).
1781+
1782+
Verification: **172 tests** across `tests/test_prompts.py` +
1783+
`tests/test_resume_builder.py` + the Slice 1A tool tests +
1784+
`test_prompt_registry` + `test_cost_tracking` +
1785+
`test_structured_outputs` + `test_backend_workspace` — all green
1786+
(168 → 172, the 4 added are the budget-slicer unit tests). Frontend
1787+
`tsc --noEmit` clean (exit 0). Frontend `eslint src` clean (exit 0).
1788+
1789+
Demo path: open the resume builder, give the agent a couple of
1790+
projects + an experience entry, watch the "✨ Draft my professional
1791+
summary" chip appear below its next reply. Click it — the agent
1792+
writes the summary from what you've told it so far. No retyping, no
1793+
restating.

frontend/src/components/workspace/ResumeIntake.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,12 @@ export type ResumeIntakeProps = {
162162
onBuilderExportThemeChange: (theme: ArtifactTheme) => void;
163163
onBuilderExport: (format: WorkspaceArtifactExportFormat) => void;
164164
onBuilderAnswerSubmit: () => void;
165+
/** Slice 1B: accept the assistant's proactive offer (one-click CTA
166+
* chip). The offer text is submitted as the next user turn so the
167+
* agent can act on it immediately. Optional so callers that
168+
* haven't been updated still work — when omitted, the chip just
169+
* doesn't render. */
170+
onBuilderProactiveOfferAccept?: (offer: string) => void;
165171
onBuilderGenerate: () => void;
166172
onBuilderCommit: () => void;
167173
onBuilderDraftSave: () => void;
@@ -206,6 +212,7 @@ export function ResumeIntake({
206212
onBuilderExportThemeChange,
207213
onBuilderExport,
208214
onBuilderAnswerSubmit,
215+
onBuilderProactiveOfferAccept,
209216
onBuilderGenerate,
210217
onBuilderCommit,
211218
onBuilderDraftSave,
@@ -588,6 +595,41 @@ export function ResumeIntake({
588595
</p>
589596
)}
590597

598+
{/* Slice 1B: proactive_offer renders as a single
599+
one-click CTA chip below the latest assistant
600+
reply. Hidden during loading so the user can't
601+
double-submit while a turn is in flight. The
602+
parent owns the actual submit — clicking calls
603+
`onBuilderProactiveOfferAccept(offer)` which
604+
bypasses the textarea state and dispatches the
605+
offer text as the next user turn. */}
606+
{builderSession?.proactive_offer &&
607+
onBuilderProactiveOfferAccept &&
608+
!builderLoading ? (
609+
<button
610+
aria-label={`Accept assistant suggestion: ${builderSession.proactive_offer}`}
611+
className="rd-btn rd-btn-sm"
612+
onClick={() =>
613+
onBuilderProactiveOfferAccept(
614+
builderSession.proactive_offer ?? "",
615+
)
616+
}
617+
style={{
618+
alignSelf: "flex-start",
619+
background: "var(--surface-2)",
620+
border: "1px solid var(--border-1)",
621+
borderRadius: 999,
622+
color: "var(--fg-1)",
623+
fontSize: 12.5,
624+
fontWeight: 500,
625+
padding: "6px 12px",
626+
}}
627+
type="button"
628+
>
629+
{builderSession.proactive_offer}
630+
</button>
631+
) : null}
632+
591633
{builderNotice ? (
592634
<div className={noticeClassName(builderNotice.level)}>
593635
{builderNotice.message}

frontend/src/components/workspace/WorkspaceShell.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1281,13 +1281,17 @@ export function WorkspaceShell() {
12811281
}
12821282
}
12831283

1284-
async function handleResumeBuilderAnswer() {
1284+
async function handleResumeBuilderAnswer(overrideText?: string) {
12851285
if (!resumeBuilderSession) {
12861286
await handleStartResumeBuilder();
12871287
return;
12881288
}
12891289

1290-
if (!resumeBuilderAnswer.trim()) {
1290+
// Slice 1B: the proactive_offer chip submits the offer text
1291+
// directly, bypassing the textarea state — this is the
1292+
// override path. Plain Continue clicks use the textarea state.
1293+
const rawText = overrideText ?? resumeBuilderAnswer;
1294+
if (!rawText.trim()) {
12911295
setResumeBuilderNotice({
12921296
level: "warning",
12931297
message: "Add an answer before continuing.",
@@ -1301,7 +1305,7 @@ export function WorkspaceShell() {
13011305
message: "Saving your answer and moving to the next step...",
13021306
});
13031307

1304-
const userMessage = resumeBuilderAnswer.trim();
1308+
const userMessage = rawText.trim();
13051309
try {
13061310
const response = await sendResumeBuilderMessage(
13071311
resumeBuilderSession.session_id,
@@ -2235,6 +2239,9 @@ export function WorkspaceShell() {
22352239
mode={resumeIntakeMode}
22362240
onBuilderAnswerChange={setResumeBuilderAnswer}
22372241
onBuilderAnswerSubmit={() => void handleResumeBuilderAnswer()}
2242+
onBuilderProactiveOfferAccept={(offer) =>
2243+
void handleResumeBuilderAnswer(offer)
2244+
}
22382245
onBuilderCommit={() => void handleResumeBuilderCommit()}
22392246
onBuilderDraftSave={() => void handleResumeBuilderDraftSave()}
22402247
onBuilderExport={(format) =>

frontend/src/lib/api-types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,14 @@ export type ResumeBuilderSessionResponse = {
363363
total_steps: number;
364364
progress_percent: number;
365365
assistant_message: string;
366+
/** Slice 1B: optional one-click CTA the LLM fires when it has
367+
* enough signal to draft a piece of the resume (e.g. "Draft my
368+
* professional summary from what we have so far"). UI renders as
369+
* a small chip below the assistant_message; click submits the
370+
* offer text as the next user turn. Null on turns where the model
371+
* didn't fire one — most turns leave this null because asking the
372+
* next question is more useful than offering a draft. */
373+
proactive_offer?: string | null;
366374
draft_profile: ResumeBuilderDraftProfile;
367375
generated_resume_markdown: string;
368376
generated_resume_plain_text: string;

0 commit comments

Comments
 (0)