Compare commits
No commits in common. "b44a7975bcb698e1f86c0c472f2864a64b6d8fea" and "5c4992dbeb89ca3831be17a4360825383102bb4f" have entirely different histories.
b44a7975bc
...
5c4992dbeb
10 changed files with 54 additions and 159 deletions
|
|
@ -45,8 +45,7 @@ FORGEJO_API_URL=https://git.opensourcesolarpunk.com/api/v1
|
||||||
# Set CF_LICENSE_KEY to authenticate with the hosted coordinator.
|
# Set CF_LICENSE_KEY to authenticate with the hosted coordinator.
|
||||||
# Leave both blank for local self-hosted cf-orch or bare-metal inference.
|
# Leave both blank for local self-hosted cf-orch or bare-metal inference.
|
||||||
CF_LICENSE_KEY=
|
CF_LICENSE_KEY=
|
||||||
GPU_SERVER_URL=https://orch.circuitforge.tech
|
CF_ORCH_URL=https://orch.circuitforge.tech
|
||||||
# CF_ORCH_URL is also accepted as a backward-compat alias for GPU_SERVER_URL
|
|
||||||
|
|
||||||
# cf-orch agent — GPU profiles only (single-gpu, dual-gpu-*)
|
# cf-orch agent — GPU profiles only (single-gpu, dual-gpu-*)
|
||||||
# The agent registers this node with the cf-orch coordinator and reports VRAM stats.
|
# The agent registers this node with the cf-orch coordinator and reports VRAM stats.
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,6 @@ jobs:
|
||||||
python-version: '3.12'
|
python-version: '3.12'
|
||||||
cache: pip
|
cache: pip
|
||||||
|
|
||||||
- name: Install system dependencies
|
|
||||||
run: sudo apt-get update -q && sudo apt-get install -y libsqlcipher-dev
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pip install -r requirements.txt
|
run: pip install -r requirements.txt
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,7 @@ services:
|
||||||
- HEIMDALL_ADMIN_TOKEN=${HEIMDALL_ADMIN_TOKEN}
|
- HEIMDALL_ADMIN_TOKEN=${HEIMDALL_ADMIN_TOKEN}
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- FORGEJO_API_TOKEN=${FORGEJO_API_TOKEN:-}
|
- FORGEJO_API_TOKEN=${FORGEJO_API_TOKEN:-}
|
||||||
- GPU_SERVER_URL=${GPU_SERVER_URL:-http://host.docker.internal:7700}
|
- CF_ORCH_URL=http://host.docker.internal:7700
|
||||||
- CF_ORCH_URL=${CF_ORCH_URL:-${GPU_SERVER_URL:-http://host.docker.internal:7700}}
|
|
||||||
- CF_APP_NAME=peregrine
|
- CF_APP_NAME=peregrine
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,7 @@ services:
|
||||||
- STAGING_DB=/devl/job-seeker/staging.db
|
- STAGING_DB=/devl/job-seeker/staging.db
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- STREAMLIT_SERVER_BASE_URL_PATH=
|
- STREAMLIT_SERVER_BASE_URL_PATH=
|
||||||
- GPU_SERVER_URL=${GPU_SERVER_URL:-http://host.docker.internal:7700}
|
- CF_ORCH_URL=http://host.docker.internal:7700
|
||||||
- CF_ORCH_URL=${CF_ORCH_URL:-${GPU_SERVER_URL:-http://host.docker.internal:7700}}
|
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
restart: "no"
|
restart: "no"
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,7 @@ services:
|
||||||
- OPENAI_COMPAT_KEY=${OPENAI_COMPAT_KEY:-}
|
- OPENAI_COMPAT_KEY=${OPENAI_COMPAT_KEY:-}
|
||||||
- PEREGRINE_GPU_COUNT=${PEREGRINE_GPU_COUNT:-0}
|
- PEREGRINE_GPU_COUNT=${PEREGRINE_GPU_COUNT:-0}
|
||||||
- PEREGRINE_GPU_NAMES=${PEREGRINE_GPU_NAMES:-}
|
- PEREGRINE_GPU_NAMES=${PEREGRINE_GPU_NAMES:-}
|
||||||
- GPU_SERVER_URL=${GPU_SERVER_URL:-${CF_ORCH_URL:-http://host.docker.internal:7700}}
|
- CF_ORCH_URL=${CF_ORCH_URL:-http://host.docker.internal:7700}
|
||||||
- CF_ORCH_URL=${CF_ORCH_URL:-${GPU_SERVER_URL:-http://host.docker.internal:7700}}
|
|
||||||
- CF_APP_NAME=peregrine
|
- CF_APP_NAME=peregrine
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
|
|
|
||||||
|
|
@ -46,61 +46,11 @@ backends:
|
||||||
type: vision_service
|
type: vision_service
|
||||||
supports_images: true
|
supports_images: true
|
||||||
|
|
||||||
# ── cf-orch task-routed backends (preferred for GPU inference) ────────────
|
# ── cf-orch trunk services ─────────────────────────────────────────────────
|
||||||
# Use these when GPU_SERVER_URL is configured. The coordinator resolves
|
# These backends allocate via cf-orch rather than connecting to a static URL.
|
||||||
# product+task → model_id → node via assignments.yaml; no model IDs needed here.
|
# cf-orch starts the service on-demand and returns its URL; the router then
|
||||||
# Set enabled: true once GPU_SERVER_URL is configured.
|
# calls it directly using the openai_compat path.
|
||||||
cf_cover_letter:
|
# Set CF_ORCH_URL (env) or url below; leave enabled: false if cf-orch is
|
||||||
type: openai_compat
|
|
||||||
enabled: false
|
|
||||||
base_url: http://localhost:8008/v1 # fallback when cf-orch is unavailable
|
|
||||||
model: __auto__
|
|
||||||
api_key: any
|
|
||||||
supports_images: false
|
|
||||||
cf_orch:
|
|
||||||
product: peregrine
|
|
||||||
task: cover_letter
|
|
||||||
ttl_s: 3600
|
|
||||||
|
|
||||||
cf_ats_rewrite:
|
|
||||||
type: openai_compat
|
|
||||||
enabled: false
|
|
||||||
base_url: http://localhost:8008/v1
|
|
||||||
model: __auto__
|
|
||||||
api_key: any
|
|
||||||
supports_images: false
|
|
||||||
cf_orch:
|
|
||||||
product: peregrine
|
|
||||||
task: ats_rewrite
|
|
||||||
ttl_s: 3600
|
|
||||||
|
|
||||||
cf_job_research:
|
|
||||||
type: openai_compat
|
|
||||||
enabled: false
|
|
||||||
base_url: http://localhost:8008/v1
|
|
||||||
model: __auto__
|
|
||||||
api_key: any
|
|
||||||
supports_images: false
|
|
||||||
cf_orch:
|
|
||||||
product: peregrine
|
|
||||||
task: job_research
|
|
||||||
ttl_s: 3600
|
|
||||||
|
|
||||||
cf_interview_prep:
|
|
||||||
type: openai_compat
|
|
||||||
enabled: false
|
|
||||||
base_url: http://localhost:8008/v1
|
|
||||||
model: __auto__
|
|
||||||
api_key: any
|
|
||||||
supports_images: false
|
|
||||||
cf_orch:
|
|
||||||
product: peregrine
|
|
||||||
task: interview_prep
|
|
||||||
ttl_s: 3600
|
|
||||||
|
|
||||||
# ── cf-orch trunk services (service-based, legacy) ─────────────────────────
|
|
||||||
# Generic service allocation — use the task-routed backends above when possible.
|
|
||||||
# Set GPU_SERVER_URL (env) or url below; leave enabled: false if cf-orch is
|
|
||||||
# not deployed in your environment.
|
# not deployed in your environment.
|
||||||
cf_text:
|
cf_text:
|
||||||
type: openai_compat
|
type: openai_compat
|
||||||
|
|
|
||||||
77
dev-api.py
77
dev-api.py
|
|
@ -48,21 +48,6 @@ _CLOUD_DATA_ROOT = Path(os.environ.get("CLOUD_DATA_ROOT", "/devl/menagerie-data
|
||||||
_DIRECTUS_SECRET = os.environ.get("DIRECTUS_JWT_SECRET", "")
|
_DIRECTUS_SECRET = os.environ.get("DIRECTUS_JWT_SECRET", "")
|
||||||
IS_DEMO: bool = os.environ.get("DEMO_MODE", "").lower() in ("1", "true", "yes")
|
IS_DEMO: bool = os.environ.get("DEMO_MODE", "").lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
# Resolve GPU inference server URL.
|
|
||||||
# Priority: GPU_SERVER_URL → CF_ORCH_URL (backward compat) → cloud default when licensed.
|
|
||||||
# Result is written back to CF_ORCH_URL so all downstream callers need no changes.
|
|
||||||
_GPU_SERVER_URL: str | None = (
|
|
||||||
os.environ.get("GPU_SERVER_URL")
|
|
||||||
or os.environ.get("CF_ORCH_URL")
|
|
||||||
or (
|
|
||||||
"https://orch.circuitforge.tech"
|
|
||||||
if os.environ.get("CF_LICENSE_KEY")
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if _GPU_SERVER_URL:
|
|
||||||
os.environ["CF_ORCH_URL"] = _GPU_SERVER_URL
|
|
||||||
|
|
||||||
# Per-request DB path — set by cloud_session_middleware; falls back to DB_PATH
|
# Per-request DB path — set by cloud_session_middleware; falls back to DB_PATH
|
||||||
_request_db: ContextVar[str | None] = ContextVar("_request_db", default=None)
|
_request_db: ContextVar[str | None] = ContextVar("_request_db", default=None)
|
||||||
|
|
||||||
|
|
@ -651,51 +636,6 @@ def resume_optimizer_task_status(job_id: int):
|
||||||
return {"status": row["status"], "stage": row["stage"], "message": row["error"]}
|
return {"status": row["status"], "stage": row["stage"], "message": row["error"]}
|
||||||
|
|
||||||
|
|
||||||
def _capture_review_corrections(
|
|
||||||
db_path: Path,
|
|
||||||
job_id: int,
|
|
||||||
draft: dict,
|
|
||||||
decisions: dict,
|
|
||||||
) -> None:
|
|
||||||
"""Persist (proposed, accepted) pairs when the user edits LLM output in the review UI.
|
|
||||||
|
|
||||||
Only saves corrections where accepted=True AND the user actually modified the
|
|
||||||
proposed text (proposed != accepted). Rejections carry no training signal.
|
|
||||||
"""
|
|
||||||
from scripts.db import save_resume_correction as _save_correction
|
|
||||||
|
|
||||||
sections = {s["section"]: s for s in (draft.get("sections") or [])}
|
|
||||||
|
|
||||||
# ── Summary correction ────────────────────────────────────────────────────
|
|
||||||
summary_dec = decisions.get("summary", {})
|
|
||||||
if summary_dec.get("accepted", True):
|
|
||||||
edited_text = summary_dec.get("edited_text")
|
|
||||||
proposed_summary = sections.get("summary", {}).get("proposed", "")
|
|
||||||
if edited_text is not None and edited_text.strip() != proposed_summary.strip():
|
|
||||||
_save_correction(db_path, job_id, "summary", proposed_summary, edited_text.strip())
|
|
||||||
|
|
||||||
# ── Experience bullet corrections ─────────────────────────────────────────
|
|
||||||
exp_sec = sections.get("experience", {})
|
|
||||||
entry_diffs = {
|
|
||||||
f"{e['title']}|{e['company']}": e
|
|
||||||
for e in (exp_sec.get("entries") or [])
|
|
||||||
}
|
|
||||||
for entry_dec in (decisions.get("experience", {}).get("accepted_entries") or []):
|
|
||||||
if not entry_dec.get("accepted", True):
|
|
||||||
continue
|
|
||||||
edited_bullets = entry_dec.get("edited_bullets")
|
|
||||||
if edited_bullets is None:
|
|
||||||
continue
|
|
||||||
key = f"{entry_dec.get('title', '')}|{entry_dec.get('company', '')}"
|
|
||||||
diff = entry_diffs.get(key)
|
|
||||||
if diff is None:
|
|
||||||
continue
|
|
||||||
proposed_bullets = diff.get("proposed_bullets") or []
|
|
||||||
cleaned = [b for b in edited_bullets if b.strip()]
|
|
||||||
if cleaned != proposed_bullets:
|
|
||||||
_save_correction(db_path, job_id, f"experience:{key}", proposed_bullets, cleaned)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/jobs/{job_id}/resume_optimizer/review")
|
@app.get("/api/jobs/{job_id}/resume_optimizer/review")
|
||||||
def get_resume_review(job_id: int):
|
def get_resume_review(job_id: int):
|
||||||
"""Return the pending review draft for this job (populated when task is awaiting_review)."""
|
"""Return the pending review draft for this job (populated when task is awaiting_review)."""
|
||||||
|
|
@ -752,10 +692,6 @@ def preview_resume_review(job_id: int, body: ResumeReviewBody):
|
||||||
# Step 1: apply section-level decisions
|
# Step 1: apply section-level decisions
|
||||||
struct = apply_review_decisions(draft, body.decisions)
|
struct = apply_review_decisions(draft, body.decisions)
|
||||||
|
|
||||||
# Step 1b: capture (proposed, accepted) correction pairs for Avocet fine-tuning.
|
|
||||||
# Only fires when accepted=True and the user actually edited the LLM output.
|
|
||||||
_capture_review_corrections(db_path, job_id, draft, body.decisions)
|
|
||||||
|
|
||||||
# Step 2: inject gap framing for rejected skills (adjacent / learning)
|
# Step 2: inject gap framing for rejected skills (adjacent / learning)
|
||||||
framings = [f.model_dump() for f in body.gap_framings if f.mode in ("adjacent", "learning")]
|
framings = [f.model_dump() for f in body.gap_framings if f.mode in ("adjacent", "learning")]
|
||||||
if framings:
|
if framings:
|
||||||
|
|
@ -777,19 +713,6 @@ def preview_resume_review(job_id: int, body: ResumeReviewBody):
|
||||||
return {"preview_text": preview_text, "preview_struct": struct}
|
return {"preview_text": preview_text, "preview_struct": struct}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/resume_optimizer/corrections")
|
|
||||||
def list_resume_corrections(job_id: int | None = None, limit: int = 200):
|
|
||||||
"""Return resume review correction pairs for Avocet import.
|
|
||||||
|
|
||||||
Each record is a (proposed, accepted) pair from the review UI where the
|
|
||||||
user edited the LLM output before accepting. These are SFT (supervised
|
|
||||||
fine-tuning) candidates that flow through Avocet for human review.
|
|
||||||
"""
|
|
||||||
from scripts.db import get_resume_corrections as _get_corrections
|
|
||||||
db_path = Path(_request_db.get() or DB_PATH)
|
|
||||||
return {"corrections": _get_corrections(db_path, limit=limit, job_id=job_id)}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/jobs/{job_id}/resume_optimizer/approve")
|
@app.post("/api/jobs/{job_id}/resume_optimizer/approve")
|
||||||
def approve_resume(job_id: int, body: dict):
|
def approve_resume(job_id: int, body: dict):
|
||||||
"""Save the user-approved assembled resume struct and mark the task complete.
|
"""Save the user-approved assembled resume struct and mark the task complete.
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,9 @@
|
||||||
<Cog6ToothIcon class="sidebar__icon" aria-hidden="true" />
|
<Cog6ToothIcon class="sidebar__icon" aria-hidden="true" />
|
||||||
<span class="sidebar__label">Settings</span>
|
<span class="sidebar__label">Settings</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
<button class="sidebar__classic-btn" @click="switchToClassic" title="Switch to Classic (Streamlit) UI">
|
||||||
|
⚡ Classic
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|
@ -131,6 +134,23 @@ function exitHackerMode() {
|
||||||
restoreTheme()
|
restoreTheme()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const _apiBase = import.meta.env.BASE_URL.replace(/\/$/, '')
|
||||||
|
|
||||||
|
async function switchToClassic() {
|
||||||
|
// Persist preference via API so Streamlit reads streamlit from user.yaml
|
||||||
|
// and won't re-set the cookie back to vue (avoids the ?prgn_switch rerun cycle)
|
||||||
|
try {
|
||||||
|
await fetch(_apiBase + '/api/settings/ui-preference', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ preference: 'streamlit' }),
|
||||||
|
})
|
||||||
|
} catch { /* non-fatal — cookie below is enough for immediate redirect */ }
|
||||||
|
document.cookie = 'prgn_ui=streamlit; path=/; SameSite=Lax'
|
||||||
|
// Navigate to root (no query params) — Caddy routes to Streamlit based on cookie
|
||||||
|
window.location.href = window.location.origin + '/'
|
||||||
|
}
|
||||||
|
|
||||||
const navLinks = computed(() => [
|
const navLinks = computed(() => [
|
||||||
{ to: '/', icon: HomeIcon, label: 'Home' },
|
{ to: '/', icon: HomeIcon, label: 'Home' },
|
||||||
{ to: '/review', icon: ClipboardDocumentListIcon, label: 'Job Review' },
|
{ to: '/review', icon: ClipboardDocumentListIcon, label: 'Job Review' },
|
||||||
|
|
@ -301,6 +321,29 @@ const mobileLinks = [
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar__classic-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity 150ms, background 150ms;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__classic-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Theme picker ───────────────────────────────────── */
|
/* ── Theme picker ───────────────────────────────────── */
|
||||||
.sidebar__theme {
|
.sidebar__theme {
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ describe('usePrepStore', () => {
|
||||||
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
|
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
|
||||||
generated_at: '2026-03-20T12:00:00' }, error: null }) // research
|
generated_at: '2026-03-20T12:00:00' }, error: null }) // research
|
||||||
.mockResolvedValueOnce({ data: [], error: null }) // contacts
|
.mockResolvedValueOnce({ data: [], error: null }) // contacts
|
||||||
.mockResolvedValueOnce({ data: [], error: null }) // qa
|
|
||||||
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null }) // task
|
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null }) // task
|
||||||
.mockResolvedValueOnce({ data: { id: 1, title: 'Engineer', company: 'Acme', url: null,
|
.mockResolvedValueOnce({ data: { id: 1, title: 'Engineer', company: 'Acme', url: null,
|
||||||
description: 'Build things.', cover_letter: null, match_score: 80,
|
description: 'Build things.', cover_letter: null, match_score: 80,
|
||||||
|
|
@ -51,7 +50,6 @@ describe('usePrepStore', () => {
|
||||||
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
|
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
|
||||||
generated_at: null }, error: null })
|
generated_at: null }, error: null })
|
||||||
.mockResolvedValueOnce({ data: [], error: null })
|
.mockResolvedValueOnce({ data: [], error: null })
|
||||||
.mockResolvedValueOnce({ data: [], error: null }) // qa
|
|
||||||
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null })
|
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null })
|
||||||
.mockResolvedValueOnce({ data: { id: 1, title: 'Old Job', company: 'OldCo', url: null,
|
.mockResolvedValueOnce({ data: { id: 1, title: 'Old Job', company: 'OldCo', url: null,
|
||||||
description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
|
description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
|
||||||
|
|
@ -64,7 +62,6 @@ describe('usePrepStore', () => {
|
||||||
mockApiFetch
|
mockApiFetch
|
||||||
.mockResolvedValueOnce({ data: null, error: { kind: 'http', status: 404, detail: '' } }) // 404 → null
|
.mockResolvedValueOnce({ data: null, error: { kind: 'http', status: 404, detail: '' } }) // 404 → null
|
||||||
.mockResolvedValueOnce({ data: [], error: null })
|
.mockResolvedValueOnce({ data: [], error: null })
|
||||||
.mockResolvedValueOnce({ data: [], error: null }) // qa
|
|
||||||
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null })
|
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null })
|
||||||
.mockResolvedValueOnce({ data: { id: 2, title: 'New Job', company: 'NewCo', url: null,
|
.mockResolvedValueOnce({ data: { id: 2, title: 'New Job', company: 'NewCo', url: null,
|
||||||
description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
|
description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
|
||||||
|
|
@ -105,7 +102,6 @@ describe('usePrepStore', () => {
|
||||||
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
|
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
|
||||||
generated_at: null }, error: null })
|
generated_at: null }, error: null })
|
||||||
.mockResolvedValueOnce({ data: [], error: null })
|
.mockResolvedValueOnce({ data: [], error: null })
|
||||||
.mockResolvedValueOnce({ data: [], error: null }) // qa
|
|
||||||
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null })
|
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null })
|
||||||
.mockResolvedValueOnce({ data: { id: 1, title: 'Eng', company: 'Acme', url: null,
|
.mockResolvedValueOnce({ data: { id: 1, title: 'Eng', company: 'Acme', url: null,
|
||||||
description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
|
description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
|
||||||
|
|
@ -116,12 +112,11 @@ describe('usePrepStore', () => {
|
||||||
// Mock first poll → completed
|
// Mock first poll → completed
|
||||||
mockApiFetch
|
mockApiFetch
|
||||||
.mockResolvedValueOnce({ data: { status: 'completed', stage: null, message: null }, error: null })
|
.mockResolvedValueOnce({ data: { status: 'completed', stage: null, message: null }, error: null })
|
||||||
// re-fetch on completed: research, contacts, qa, task, fullJob
|
// re-fetch on completed: research, contacts, task, fullJob
|
||||||
.mockResolvedValueOnce({ data: { company_brief: 'Updated!', ceo_brief: null, talking_points: null,
|
.mockResolvedValueOnce({ data: { company_brief: 'Updated!', ceo_brief: null, talking_points: null,
|
||||||
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
|
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
|
||||||
generated_at: '2026-03-20T13:00:00' }, error: null })
|
generated_at: '2026-03-20T13:00:00' }, error: null })
|
||||||
.mockResolvedValueOnce({ data: [], error: null })
|
.mockResolvedValueOnce({ data: [], error: null })
|
||||||
.mockResolvedValueOnce({ data: [], error: null }) // qa
|
|
||||||
.mockResolvedValueOnce({ data: { status: 'completed', stage: null, message: null }, error: null })
|
.mockResolvedValueOnce({ data: { status: 'completed', stage: null, message: null }, error: null })
|
||||||
.mockResolvedValueOnce({ data: { id: 1, title: 'Eng', company: 'Acme', url: null,
|
.mockResolvedValueOnce({ data: { id: 1, title: 'Eng', company: 'Acme', url: null,
|
||||||
description: 'Now with content', cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
|
description: 'Now with content', cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
|
||||||
|
|
@ -139,7 +134,6 @@ describe('usePrepStore', () => {
|
||||||
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
|
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
|
||||||
generated_at: null }, error: null })
|
generated_at: null }, error: null })
|
||||||
.mockResolvedValueOnce({ data: [], error: null })
|
.mockResolvedValueOnce({ data: [], error: null })
|
||||||
.mockResolvedValueOnce({ data: [], error: null }) // qa
|
|
||||||
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null })
|
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null })
|
||||||
.mockResolvedValueOnce({ data: { id: 1, title: 'Eng', company: 'Acme', url: null,
|
.mockResolvedValueOnce({ data: { id: 1, title: 'Eng', company: 'Acme', url: null,
|
||||||
description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
|
description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
|
||||||
|
|
@ -168,7 +162,6 @@ describe('usePrepStore', () => {
|
||||||
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
|
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
|
||||||
generated_at: '2026-03-20T12:00:00' }, error: null }) // research OK
|
generated_at: '2026-03-20T12:00:00' }, error: null }) // research OK
|
||||||
.mockResolvedValueOnce({ data: null, error: { kind: 'http', status: 500, detail: 'DB error' } }) // contacts fail
|
.mockResolvedValueOnce({ data: null, error: { kind: 'http', status: 500, detail: 'DB error' } }) // contacts fail
|
||||||
.mockResolvedValueOnce({ data: [], error: null }) // qa
|
|
||||||
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null }) // task OK
|
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null }) // task OK
|
||||||
.mockResolvedValueOnce({ data: { id: 1, title: 'Engineer', company: 'Acme', url: null,
|
.mockResolvedValueOnce({ data: { id: 1, title: 'Engineer', company: 'Acme', url: null,
|
||||||
description: 'Build things.', cover_letter: null, match_score: 80,
|
description: 'Build things.', cover_letter: null, match_score: 80,
|
||||||
|
|
|
||||||
|
|
@ -54,20 +54,14 @@ describe('useSurveyStore', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('analyze stores result including mode and rawInput', async () => {
|
it('analyze stores result including mode and rawInput', async () => {
|
||||||
vi.useFakeTimers()
|
|
||||||
const mockApiFetch = vi.mocked(useApiFetch)
|
const mockApiFetch = vi.mocked(useApiFetch)
|
||||||
// POST → task accepted
|
|
||||||
mockApiFetch.mockResolvedValueOnce({ data: { task_id: 7, is_new: true }, error: null })
|
|
||||||
// Poll → completed with result
|
|
||||||
mockApiFetch.mockResolvedValueOnce({
|
mockApiFetch.mockResolvedValueOnce({
|
||||||
data: { status: 'completed', stage: null, message: null,
|
data: { output: '1. B — reason', source: 'text_paste' },
|
||||||
result: { output: '1. B — reason', source: 'text_paste' } },
|
|
||||||
error: null,
|
error: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const store = useSurveyStore()
|
const store = useSurveyStore()
|
||||||
await store.analyze(1, { text: 'Q1: test', mode: 'quick' })
|
await store.analyze(1, { text: 'Q1: test', mode: 'quick' })
|
||||||
await vi.advanceTimersByTimeAsync(3000)
|
|
||||||
|
|
||||||
expect(store.analysis).not.toBeNull()
|
expect(store.analysis).not.toBeNull()
|
||||||
expect(store.analysis!.output).toBe('1. B — reason')
|
expect(store.analysis!.output).toBe('1. B — reason')
|
||||||
|
|
@ -75,7 +69,6 @@ describe('useSurveyStore', () => {
|
||||||
expect(store.analysis!.mode).toBe('quick')
|
expect(store.analysis!.mode).toBe('quick')
|
||||||
expect(store.analysis!.rawInput).toBe('Q1: test')
|
expect(store.analysis!.rawInput).toBe('Q1: test')
|
||||||
expect(store.loading).toBe(false)
|
expect(store.loading).toBe(false)
|
||||||
vi.useRealTimers()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('analyze sets error on failure', async () => {
|
it('analyze sets error on failure', async () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue