feat: cover letter training export (Phase 1) #111
1 changed files with 143 additions and 1 deletions
|
|
@ -3,16 +3,31 @@ import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useFineTuneStore } from '../../stores/settings/fineTune'
|
import { useFineTuneStore } from '../../stores/settings/fineTune'
|
||||||
import { useAppConfigStore } from '../../stores/appConfig'
|
import { useAppConfigStore } from '../../stores/appConfig'
|
||||||
|
import { showToast } from '../../composables/useToast'
|
||||||
|
|
||||||
const store = useFineTuneStore()
|
const store = useFineTuneStore()
|
||||||
const config = useAppConfigStore()
|
const config = useAppConfigStore()
|
||||||
const { step, inFlightJob, jobStatus, pairsCount, quotaRemaining, pairs, pairsLoading } = storeToRefs(store)
|
const { step, inFlightJob, jobStatus, pairsCount, quotaRemaining, pairs, pairsLoading,
|
||||||
|
optedIn, dbPairs, dbPairsLoading, dbExcludedCount } = storeToRefs(store)
|
||||||
|
|
||||||
const fileInput = ref<HTMLInputElement | null>(null)
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
const selectedFiles = ref<File[]>([])
|
const selectedFiles = ref<File[]>([])
|
||||||
const uploadResult = ref<{ file_count: number } | null>(null)
|
const uploadResult = ref<{ file_count: number } | null>(null)
|
||||||
const extractError = ref<string | null>(null)
|
const extractError = ref<string | null>(null)
|
||||||
const modelReady = ref<boolean | null>(null)
|
const modelReady = ref<boolean | null>(null)
|
||||||
|
const toggling = ref(false)
|
||||||
|
const toggleSaved = ref(false)
|
||||||
|
|
||||||
|
async function handleOptInChange(e: Event) {
|
||||||
|
const enabled = (e.target as HTMLInputElement).checked
|
||||||
|
toggling.value = true
|
||||||
|
toggleSaved.value = false
|
||||||
|
await store.toggleOptIn(enabled)
|
||||||
|
await store.loadDbPairs()
|
||||||
|
toggling.value = false
|
||||||
|
toggleSaved.value = true
|
||||||
|
setTimeout(() => { toggleSaved.value = false }, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
async function handleUpload() {
|
async function handleUpload() {
|
||||||
if (!selectedFiles.value.length) return
|
if (!selectedFiles.value.length) return
|
||||||
|
|
@ -46,6 +61,7 @@ async function checkLocalModel() {
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
store.startPolling()
|
store.startPolling()
|
||||||
await store.loadPairs()
|
await store.loadPairs()
|
||||||
|
await store.loadDbPairs()
|
||||||
if (store.step === 3 && !config.isCloud) await checkLocalModel()
|
if (store.step === 3 && !config.isCloud) await checkLocalModel()
|
||||||
})
|
})
|
||||||
onUnmounted(() => { store.stopPolling(); store.resetStep() })
|
onUnmounted(() => { store.stopPolling(); store.resetStep() })
|
||||||
|
|
@ -55,6 +71,115 @@ onUnmounted(() => { store.stopPolling(); store.resetStep() })
|
||||||
<div class="fine-tune-view">
|
<div class="fine-tune-view">
|
||||||
<h2>Fine-Tune Model</h2>
|
<h2>Fine-Tune Model</h2>
|
||||||
|
|
||||||
|
<!-- Training Export: consent toggle (always visible) -->
|
||||||
|
<section class="form-section training-export-consent">
|
||||||
|
<h3>Training Export</h3>
|
||||||
|
<p class="section-note">
|
||||||
|
When enabled, your applied-job cover letters are available as a local dataset file
|
||||||
|
for fine-tuning a language model to your writing style.
|
||||||
|
</p>
|
||||||
|
<label class="toggle-label" :class="{ 'toggle-saving': toggling }">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="optedIn"
|
||||||
|
:disabled="toggling"
|
||||||
|
@change="handleOptInChange"
|
||||||
|
aria-describedby="opt-in-desc"
|
||||||
|
/>
|
||||||
|
Include cover letters in training export
|
||||||
|
<span v-if="toggling" class="toggle-status" aria-live="polite">Saving…</span>
|
||||||
|
<span v-else-if="toggleSaved" class="toggle-status" aria-live="polite">Saved</span>
|
||||||
|
</label>
|
||||||
|
<p class="section-note" id="opt-in-desc">
|
||||||
|
<template v-if="!config.isCloud">
|
||||||
|
Your cover letters stay on your device unless you explicitly request cloud fine-tuning.
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
Your cover letters are stored on your CircuitForge account and are not shared with any
|
||||||
|
third party unless you explicitly request cloud fine-tuning.
|
||||||
|
</template>
|
||||||
|
<span v-if="!optedIn" class="opt-out-receipt">
|
||||||
|
Training export is off — cover letters remain local only.
|
||||||
|
You can change this in Settings at any time.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- From Applied Jobs: curation list (only when opted in) -->
|
||||||
|
<section v-if="optedIn" class="form-section">
|
||||||
|
<h3>From Applied Jobs</h3>
|
||||||
|
<div class="db-pairs-header">
|
||||||
|
<span class="pairs-count">
|
||||||
|
{{
|
||||||
|
dbPairs.filter(p => !p.excluded).length === 1
|
||||||
|
? '1 pair available'
|
||||||
|
: `${dbPairs.filter(p => !p.excluded).length} pairs available`
|
||||||
|
}}
|
||||||
|
<span
|
||||||
|
v-if="dbExcludedCount > 0"
|
||||||
|
class="excluded-badge"
|
||||||
|
:title="`${dbExcludedCount} pair(s) excluded — use Restore to re-include`"
|
||||||
|
>{{ dbExcludedCount }} excluded</span>
|
||||||
|
</span>
|
||||||
|
<div class="db-pairs-actions">
|
||||||
|
<button
|
||||||
|
class="btn-secondary"
|
||||||
|
:disabled="dbPairs.filter(p => !p.excluded).length === 0"
|
||||||
|
@click="store.downloadExport()"
|
||||||
|
>
|
||||||
|
Download JSONL <span aria-hidden="true">↓</span>
|
||||||
|
</button>
|
||||||
|
<div class="cloud-finetune-wrap">
|
||||||
|
<button
|
||||||
|
class="btn-secondary"
|
||||||
|
:disabled="config.tier !== 'premium' || dbPairs.filter(p => !p.excluded).length === 0"
|
||||||
|
@click="config.tier === 'premium' && showToast('Cloud fine-tuning coming soon')"
|
||||||
|
>
|
||||||
|
Request Cloud Fine-Tune
|
||||||
|
</button>
|
||||||
|
<p v-if="config.tier !== 'premium'" class="tier-gate-note">
|
||||||
|
Available on Premium.
|
||||||
|
<a href="/settings?tab=license" class="upgrade-link">Upgrade your plan →</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="section-note download-advisory">
|
||||||
|
The downloaded file contains your cover letters in plain text (JSONL format).
|
||||||
|
Store it in a secure location.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div aria-live="polite" aria-atomic="false" aria-label="Applied jobs training pairs">
|
||||||
|
<div v-if="dbPairsLoading" class="pairs-loading">Loading…</div>
|
||||||
|
<ul v-else-if="dbPairs.length > 0" class="pairs-items db-pairs-items">
|
||||||
|
<li
|
||||||
|
v-for="pair in dbPairs"
|
||||||
|
:key="pair.job_id"
|
||||||
|
class="pair-item"
|
||||||
|
:class="{ 'pair-excluded': pair.excluded }"
|
||||||
|
>
|
||||||
|
<div class="pair-info">
|
||||||
|
<span class="pair-instruction">{{ pair.title }} · {{ pair.company }}</span>
|
||||||
|
<span class="pair-source">{{ pair.status }}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="!pair.excluded"
|
||||||
|
class="pair-delete"
|
||||||
|
title="Exclude from training export"
|
||||||
|
@click="store.excludeDbPair(pair.job_id)"
|
||||||
|
>Exclude</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="pair-restore"
|
||||||
|
title="Restore to training export"
|
||||||
|
@click="store.includeDbPair(pair.job_id)"
|
||||||
|
>Restore</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-else class="section-note">No applied jobs with cover letters found.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Wizard steps indicator -->
|
<!-- Wizard steps indicator -->
|
||||||
<div class="wizard-steps">
|
<div class="wizard-steps">
|
||||||
<span :class="['step', step >= 1 ? 'active' : '']">1. Upload</span>
|
<span :class="['step', step >= 1 ? 'active' : '']">1. Upload</span>
|
||||||
|
|
@ -189,4 +314,21 @@ onUnmounted(() => { store.stopPolling(); store.resetStep() })
|
||||||
.pair-source { font-size: 0.75rem; color: var(--color-text-muted); }
|
.pair-source { font-size: 0.75rem; color: var(--color-text-muted); }
|
||||||
.pair-delete { flex-shrink: 0; background: none; border: none; color: var(--color-error); cursor: pointer; font-size: 0.9rem; padding: 2px 4px; border-radius: var(--radius-sm); transition: background 150ms; }
|
.pair-delete { flex-shrink: 0; background: none; border: none; color: var(--color-error); cursor: pointer; font-size: 0.9rem; padding: 2px 4px; border-radius: var(--radius-sm); transition: background 150ms; }
|
||||||
.pair-delete:hover { background: var(--color-error); color: #fff; }
|
.pair-delete:hover { background: var(--color-error); color: #fff; }
|
||||||
|
.training-export-consent { border: 1px solid var(--color-border-light); border-radius: var(--radius-md); padding: var(--space-4, 1rem); margin-bottom: var(--space-6, 1.5rem); }
|
||||||
|
.toggle-label { display: flex; align-items: center; gap: var(--space-2, 0.5rem); font-size: 0.9rem; font-weight: 500; cursor: pointer; flex-wrap: wrap; }
|
||||||
|
.toggle-label.toggle-saving { opacity: 0.7; }
|
||||||
|
.toggle-label input[type="checkbox"] { width: 16px; height: 16px; accent-color: var(--color-primary); cursor: pointer; flex-shrink: 0; }
|
||||||
|
.toggle-status { font-size: 0.8rem; color: var(--color-text-muted); margin-left: var(--space-1, 0.25rem); }
|
||||||
|
.opt-out-receipt { display: block; margin-top: var(--space-1, 0.25rem); color: var(--color-text-muted); font-size: 0.8rem; }
|
||||||
|
.db-pairs-header { display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: var(--space-3, 0.75rem); margin-bottom: var(--space-4, 1rem); }
|
||||||
|
.db-pairs-actions { display: flex; align-items: flex-start; gap: var(--space-2, 0.5rem); flex-wrap: wrap; }
|
||||||
|
.cloud-finetune-wrap { display: flex; flex-direction: column; gap: var(--space-1, 0.25rem); }
|
||||||
|
.tier-gate-note { font-size: 0.8rem; color: var(--color-text-muted); margin: 0; }
|
||||||
|
.upgrade-link { color: var(--color-primary); text-decoration: underline; }
|
||||||
|
.excluded-badge { margin-left: var(--space-2, 0.5rem); background: var(--color-warning-bg, #fef3c7); color: var(--color-warning-fg, #92400e); font-size: 0.75rem; padding: 1px 6px; border-radius: var(--radius-full, 9999px); }
|
||||||
|
.db-pairs-items { max-height: 320px; }
|
||||||
|
.pair-excluded { opacity: 0.5; }
|
||||||
|
.pair-restore { flex-shrink: 0; background: none; border: 1px solid var(--color-border); color: var(--color-text-muted); cursor: pointer; font-size: 0.8rem; padding: 2px 8px; border-radius: var(--radius-sm); }
|
||||||
|
.pair-restore:hover { background: var(--color-surface-alt); }
|
||||||
|
.download-advisory { margin-top: var(--space-2, 0.5rem); font-style: italic; }
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue