feat(#59): LLM-assisted generation for all settings form fields
Some checks failed
CI / test (push) Failing after 21s
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:
parent
4f825d0f00
commit
42c9c882ee
4 changed files with 200 additions and 8 deletions
156
dev-api.py
156
dev-api.py
|
|
@ -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 2–3 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 1–2 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 5–8 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 ─────────────────────────
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
Loading…
Reference in a new issue