peregrine/web/src/stores/prep.ts
pyr0ball 8e36863a49
Some checks failed
CI / Backend (Python) (push) Failing after 2m15s
CI / Frontend (Vue) (push) Failing after 21s
Mirror / mirror (push) Failing after 9s
feat: Interview prep Q&A, cf-orch hardware profile, a11y fixes, dark theme
Backend
- dev-api.py: Q&A suggest endpoint, Log Contact, cf-orch node detection in wizard
  hardware step, canonical search_profiles format (profiles:[...]), connections
  settings endpoints, Resume Library endpoints
- db_migrate.py: migrations 002/003/004 — ATS columns, resume review, final
  resume struct
- discover.py: _normalize_profiles() for legacy wizard YAML format compat
- resume_optimizer.py: section-by-section resume parsing + scoring
- task_runner.py: Q&A and contact-log task types
- company_research.py: accessibility brief column wiring
- generate_cover_letter.py: restore _candidate module-level binding

Frontend
- InterviewPrepView.vue: Q&A chat tab, Log Contact form, MarkdownView rendering
- InterviewCard.vue: new reusable card component for interviews kanban
- InterviewsView.vue: rejected analytics section with stage breakdown chips
- ResumeProfileView.vue: sync with new resume store shape
- SearchPrefsView.vue: cf-orch toggle, profile format migration
- SystemSettingsView.vue: connections settings wiring
- ConnectionsSettingsView.vue: new view for integration connections
- MarkdownView.vue: new component for safe markdown rendering
- ApplyWorkspace.vue: a11y — h1→h2 demotion, aria-expanded on Q&A toggle,
  confirmation dialog on Reject action (#98 #99 #100)
- peregrine.css: explicit [data-theme="dark"] token block for light-OS users (#101),
  :focus-visible outline (#97)
- wizard.css: cf-orch hardware step styles
- WizardHardwareStep.vue: cf-orch node display, profile selection with orch option
- WizardLayout.vue: hardware step wiring

Infra
- compose.yml / compose.cloud.yml: cf-orch agent sidecar, llm.cloud.yaml mount
- Dockerfile.cfcore: cf-core editable install in image build
- HANDOFF-xanderland.md: Podman/systemd setup guide for beta tester
- podman-standalone.sh: standalone Podman run script

Tests
- test_dev_api_settings.py: remove stale worktree path bootstrap (credential_store
  now in main repo); fix job_boards fixture to use non-empty list
- test_wizard_api.py: update profiles assertion to superset check (cf-orch added);
  update step6 assertion to canonical profiles[].titles format
2026-04-14 17:01:18 -07:00

207 lines
5.8 KiB
TypeScript

import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useApiFetch } from '../composables/useApi'
export interface ResearchBrief {
company_brief: string | null
ceo_brief: string | null
talking_points: string | null
tech_brief: string | null
funding_brief: string | null
red_flags: string | null
accessibility_brief: string | null
generated_at: string | null
}
export interface Contact {
id: number
direction: 'inbound' | 'outbound'
subject: string | null
from_addr: string | null
body: string | null
received_at: string | null
}
export interface QAItem {
question: string
answer: string
}
export interface TaskStatus {
status: 'queued' | 'running' | 'completed' | 'failed' | 'none' | null
stage: string | null
message: string | null
}
export interface FullJobDetail {
id: number
title: string
company: string
url: string | null
description: string | null
cover_letter: string | null
match_score: number | null
keyword_gaps: string | null
}
export const usePrepStore = defineStore('prep', () => {
const research = ref<ResearchBrief | null>(null)
const contacts = ref<Contact[]>([])
const contactsError = ref<string | null>(null)
const qaItems = ref<QAItem[]>([])
const qaError = ref<string | null>(null)
const taskStatus = ref<TaskStatus>({ status: null, stage: null, message: null })
const fullJob = ref<FullJobDetail | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const currentJobId = ref<number | null>(null)
let pollInterval: ReturnType<typeof setInterval> | null = null
function _clearInterval() {
if (pollInterval !== null) {
clearInterval(pollInterval)
pollInterval = null
}
}
async function fetchFor(jobId: number) {
if (jobId !== currentJobId.value) {
_clearInterval()
research.value = null
contacts.value = []
contactsError.value = null
qaItems.value = []
qaError.value = null
taskStatus.value = { status: null, stage: null, message: null }
fullJob.value = null
error.value = null
currentJobId.value = jobId
}
loading.value = true
try {
const [researchResult, contactsResult, qaResult, taskResult, jobResult] = await Promise.all([
useApiFetch<ResearchBrief>(`/api/jobs/${jobId}/research`),
useApiFetch<Contact[]>(`/api/jobs/${jobId}/contacts`),
useApiFetch<QAItem[]>(`/api/jobs/${jobId}/qa`),
useApiFetch<TaskStatus>(`/api/jobs/${jobId}/research/task`),
useApiFetch<FullJobDetail>(`/api/jobs/${jobId}`),
])
// Research 404 is expected (no research yet) — only surface non-404 errors
if (researchResult.error && !(researchResult.error.kind === 'http' && researchResult.error.status === 404)) {
error.value = 'Failed to load research data'
return
}
if (jobResult.error) {
error.value = 'Failed to load job details'
return
}
research.value = researchResult.data ?? null
// Contacts failure is non-fatal — degrade the Email tab only
if (contactsResult.error) {
contactsError.value = 'Could not load email history.'
contacts.value = []
} else {
contacts.value = contactsResult.data ?? []
contactsError.value = null
}
// Q&A failure is non-fatal — degrade the Practice Q&A tab only
if (qaResult.error && !(qaResult.error.kind === 'http' && qaResult.error.status === 404)) {
qaError.value = 'Could not load Q&A history.'
qaItems.value = []
} else {
qaItems.value = qaResult.data ?? []
qaError.value = null
}
taskStatus.value = taskResult.data ?? { status: null, stage: null, message: null }
fullJob.value = jobResult.data ?? null
// If a task is already running/queued, start polling
const ts = taskStatus.value.status
if (ts === 'queued' || ts === 'running') {
pollTask(jobId)
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load prep data'
} finally {
loading.value = false
}
}
async function generateResearch(jobId: number) {
const { data, error: fetchError } = await useApiFetch<{ task_id: number; is_new: boolean }>(
`/api/jobs/${jobId}/research/generate`,
{ method: 'POST' }
)
if (fetchError || !data) {
error.value = 'Failed to start research generation'
return
}
pollTask(jobId)
}
/** @internal — called by fetchFor and generateResearch; not for component use */
function pollTask(jobId: number) {
_clearInterval()
pollInterval = setInterval(async () => {
const { data } = await useApiFetch<TaskStatus>(`/api/jobs/${jobId}/research/task`)
if (data) {
taskStatus.value = data
if (data.status === 'completed' || data.status === 'failed') {
_clearInterval()
if (data.status === 'completed') {
await fetchFor(jobId)
}
}
}
}, 3000)
}
async function fetchContacts(jobId: number) {
const { data, error: fetchError } = await useApiFetch<Contact[]>(`/api/jobs/${jobId}/contacts`)
if (fetchError) {
contactsError.value = 'Could not load email history.'
} else {
contacts.value = data ?? []
contactsError.value = null
}
}
function clear() {
_clearInterval()
research.value = null
contacts.value = []
contactsError.value = null
qaItems.value = []
qaError.value = null
taskStatus.value = { status: null, stage: null, message: null }
fullJob.value = null
loading.value = false
error.value = null
currentJobId.value = null
}
return {
research,
contacts,
contactsError,
qaItems,
qaError,
taskStatus,
fullJob,
loading,
error,
currentJobId,
fetchFor,
fetchContacts,
generateResearch,
pollTask,
clear,
}
})