feat(#59): LLM-assisted generation for all settings form fields
Some checks failed
CI / test (push) Failing after 21s

API endpoints (dev-api.py):
- POST /api/settings/profile/generate-summary → {summary}
- POST /api/settings/profile/generate-missions → {mission_preferences}
- POST /api/settings/profile/generate-voice → {voice}
- POST /api/settings/search/suggest → replaces stub; handles titles/locations/exclude_keywords

Vue (MyProfileView.vue):
- Generate ✦ button on candidate_voice textarea (was missing)

Vue (SearchPrefsView.vue + search store):
- Suggest button for Exclude Keywords section (matches titles/locations pattern)
- suggestExcludeKeywords() in search store
- acceptSuggestion() extended to 'exclude' type
This commit is contained in:
pyr0ball 2026-04-04 22:27:20 -07:00
parent 4f825d0f00
commit 42c9c882ee
4 changed files with 200 additions and 8 deletions

View file

@ -1532,6 +1532,108 @@ def save_profile(payload: UserProfilePayload):
raise HTTPException(500, f"Could not save profile: {e}")
# ── Settings: My Profile — LLM generation endpoints ─────────────────────────
def _resume_context_snippet() -> str:
"""Load a concise resume snippet for use as LLM generation context."""
try:
rp = _resume_path()
if not rp.exists():
return ""
with open(rp) as f:
resume_data = yaml.safe_load(f) or {}
parts: list[str] = []
if resume_data.get("name"):
parts.append(f"Candidate: {resume_data['name']}")
if resume_data.get("skills"):
parts.append(f"Skills: {', '.join(resume_data['skills'][:20])}")
if resume_data.get("experience"):
exp = resume_data["experience"]
if isinstance(exp, list) and exp:
titles = [e.get("title", "") for e in exp[:3] if e.get("title")]
if titles:
parts.append(f"Recent roles: {', '.join(titles)}")
return "\n".join(parts)
except Exception:
return ""
@app.post("/api/settings/profile/generate-summary")
def generate_career_summary():
"""LLM-generate a career summary from the candidate's resume profile."""
context = _resume_context_snippet()
if not context:
raise HTTPException(400, "Resume profile is empty — add experience and skills first")
prompt = (
"You are a professional resume writer.\n\n"
f"Candidate background:\n{context}\n\n"
"Write a 23 sentence professional career summary in first person. "
"Be specific, highlight key strengths, and avoid hollow filler phrases like "
"'results-driven' or 'passionate self-starter'."
)
try:
from scripts.llm_router import LLMRouter
summary = LLMRouter().complete(prompt)
return {"summary": summary.strip()}
except Exception as e:
raise HTTPException(500, f"LLM generation failed: {e}")
@app.post("/api/settings/profile/generate-missions")
def generate_mission_preferences():
"""LLM-generate 3 mission/industry preferences from the candidate's resume."""
context = _resume_context_snippet()
prompt = (
"You are helping a job seeker identify mission-aligned industries they would enjoy working in.\n\n"
+ (f"Candidate background:\n{context}\n\n" if context else "")
+ "Suggest 3 mission-aligned industries or causes the candidate might care about "
"(e.g. animal welfare, education, accessibility, climate tech, healthcare). "
"Return a JSON array with exactly 3 objects, each with 'tag' (slug, no spaces), "
"'label' (human-readable name), and 'note' (one sentence on why it fits). "
"Only output the JSON array, no other text."
)
try:
from scripts.llm_router import LLMRouter
import json as _json
raw = LLMRouter().complete(prompt)
# Extract JSON array from the response
start = raw.find("[")
end = raw.rfind("]") + 1
if start == -1 or end == 0:
raise ValueError("LLM did not return a JSON array")
items = _json.loads(raw[start:end])
# Normalise to {industry, note} — LLM may return {tag, label, note}
missions = [
{"industry": m.get("label") or m.get("tag") or str(m), "note": m.get("note", "")}
for m in items if isinstance(m, dict)
]
return {"mission_preferences": missions}
except Exception as e:
raise HTTPException(500, f"LLM generation failed: {e}")
@app.post("/api/settings/profile/generate-voice")
def generate_candidate_voice():
"""LLM-generate a candidate voice/writing-style note from the resume profile."""
context = _resume_context_snippet()
if not context:
raise HTTPException(400, "Resume profile is empty — add experience and skills first")
prompt = (
"You are a professional writing coach helping a job seeker articulate their communication style.\n\n"
f"Candidate background:\n{context}\n\n"
"Write a 12 sentence note describing the candidate's professional voice and writing style "
"for use in cover letter generation. This should capture tone (e.g. direct, warm, precise), "
"values that come through in their writing, and any standout personality. "
"Write it in third person as a style directive (e.g. 'Writes in a clear, direct tone...')."
)
try:
from scripts.llm_router import LLMRouter
voice = LLMRouter().complete(prompt)
return {"voice": voice.strip()}
except Exception as e:
raise HTTPException(500, f"LLM generation failed: {e}")
# ── Settings: Resume Profile endpoints ───────────────────────────────────────
class WorkEntry(BaseModel):
@ -1710,13 +1812,59 @@ def save_search_prefs(payload: SearchPrefsPayload):
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
class SearchSuggestPayload(BaseModel):
type: str # "titles" | "locations" | "exclude_keywords"
current: List[str] = []
@app.post("/api/settings/search/suggest")
def suggest_search(body: dict):
def suggest_search(payload: SearchSuggestPayload):
"""LLM-generate suggestions for job titles, locations, or exclude keywords."""
context = _resume_context_snippet()
current_str = ", ".join(payload.current) if payload.current else "none"
if payload.type == "titles":
prompt = (
"You are a career advisor helping a job seeker identify relevant job titles.\n\n"
+ (f"Candidate background:\n{context}\n\n" if context else "")
+ f"Current job titles they're searching for: {current_str}\n\n"
"Suggest 5 additional relevant job titles they may have missed. "
"Return only a JSON array of strings, no other text. "
"Example: [\"Senior Software Engineer\", \"Staff Engineer\"]"
)
elif payload.type == "locations":
prompt = (
"You are a career advisor helping a job seeker identify relevant job markets.\n\n"
+ (f"Candidate background:\n{context}\n\n" if context else "")
+ f"Current locations they're searching in: {current_str}\n\n"
"Suggest 5 relevant locations or remote options they may have missed. "
"Include 'Remote' if not already listed. "
"Return only a JSON array of strings, no other text."
)
elif payload.type == "exclude_keywords":
prompt = (
"You are a job search assistant helping a job seeker filter out irrelevant listings.\n\n"
+ (f"Candidate background:\n{context}\n\n" if context else "")
+ f"Keywords they already exclude: {current_str}\n\n"
"Suggest 58 keywords or phrases they should add to their exclude list to avoid "
"irrelevant postings (e.g. management roles they don't want, clearance requirements, "
"technologies they don't work with). "
"Return only a JSON array of strings, no other text."
)
else:
raise HTTPException(400, f"Unknown suggestion type: {payload.type}")
try:
# Stub — LLM suggest for paid tier
return {"suggestions": []}
import json as _json
from scripts.llm_router import LLMRouter
raw = LLMRouter().complete(prompt)
start = raw.find("[")
end = raw.rfind("]") + 1
if start == -1 or end == 0:
return {"suggestions": []}
suggestions = _json.loads(raw[start:end])
return {"suggestions": [str(s) for s in suggestions if s]}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
raise HTTPException(500, f"LLM generation failed: {e}")
# ── Settings: System — LLM Backends + BYOK endpoints ─────────────────────────

View file

@ -18,6 +18,7 @@ export const useSearchStore = defineStore('settings/search', () => {
const titleSuggestions = ref<string[]>([])
const locationSuggestions = ref<string[]>([])
const excludeSuggestions = ref<string[]>([])
const loading = ref(false)
const saving = ref(false)
@ -99,10 +100,24 @@ export const useSearchStore = defineStore('settings/search', () => {
arr.value = arr.value.filter(v => v !== value)
}
function acceptSuggestion(type: 'title' | 'location', value: string) {
async function suggestExcludeKeywords() {
const { data } = await useApiFetch<{ suggestions: string[] }>('/api/settings/search/suggest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'exclude_keywords', current: exclude_keywords.value }),
})
if (data?.suggestions) {
excludeSuggestions.value = data.suggestions.filter(s => !exclude_keywords.value.includes(s))
}
}
function acceptSuggestion(type: 'title' | 'location' | 'exclude', value: string) {
if (type === 'title') {
if (!job_titles.value.includes(value)) job_titles.value = [...job_titles.value, value]
titleSuggestions.value = titleSuggestions.value.filter(s => s !== value)
} else if (type === 'exclude') {
if (!exclude_keywords.value.includes(value)) exclude_keywords.value = [...exclude_keywords.value, value]
excludeSuggestions.value = excludeSuggestions.value.filter(s => s !== value)
} else {
if (!locations.value.includes(value)) locations.value = [...locations.value, value]
locationSuggestions.value = locationSuggestions.value.filter(s => s !== value)
@ -118,8 +133,9 @@ export const useSearchStore = defineStore('settings/search', () => {
return {
remote_preference, job_titles, locations, exclude_keywords, job_boards,
custom_board_urls, blocklist_companies, blocklist_industries, blocklist_locations,
titleSuggestions, locationSuggestions,
titleSuggestions, locationSuggestions, excludeSuggestions,
loading, saving, saveError, loadError,
load, save, suggestTitles, suggestLocations, addTag, removeTag, acceptSuggestion, toggleBoard,
load, save, suggestTitles, suggestLocations, suggestExcludeKeywords,
addTag, removeTag, acceptSuggestion, toggleBoard,
}
})

View file

@ -62,6 +62,13 @@
rows="3"
placeholder="How you write and communicate — used to shape cover letter voice."
/>
<button
v-if="config.tier !== 'free'"
class="btn-generate"
type="button"
@click="generateVoice"
:disabled="generatingVoice"
>{{ generatingVoice ? 'Generating…' : 'Generate ✦' }}</button>
</div>
<div v-if="!config.isCloud" class="field-row">
@ -210,6 +217,7 @@ const config = useAppConfigStore()
const newNdaCompany = ref('')
const generatingSummary = ref(false)
const generatingMissions = ref(false)
const generatingVoice = ref(false)
onMounted(() => { store.load() })
@ -265,6 +273,15 @@ async function generateMissions() {
}))
}
}
async function generateVoice() {
generatingVoice.value = true
const { data, error } = await useApiFetch<{ voice?: string }>(
'/api/settings/profile/generate-voice', { method: 'POST' }
)
generatingVoice.value = false
if (!error && data?.voice) store.candidate_voice = data.voice
}
</script>
<style scoped>

View file

@ -69,7 +69,18 @@
{{ kw }} <button @click="store.removeTag('exclude_keywords', kw)">×</button>
</span>
</div>
<input v-model="excludeInput" @keydown.enter.prevent="store.addTag('exclude_keywords', excludeInput); excludeInput = ''" placeholder="Add keyword, press Enter" />
<div class="tag-input-row">
<input v-model="excludeInput" @keydown.enter.prevent="store.addTag('exclude_keywords', excludeInput); excludeInput = ''" placeholder="Add keyword, press Enter" />
<button @click="store.suggestExcludeKeywords()" class="btn-suggest">Suggest</button>
</div>
<div v-if="store.excludeSuggestions.length > 0" class="suggestions">
<span
v-for="s in store.excludeSuggestions"
:key="s"
class="suggestion-chip"
@click="store.acceptSuggestion('exclude', s)"
>+ {{ s }}</span>
</div>
</section>
<!-- Job Boards -->