Compare commits
No commits in common. "8eb8400badb8b5b971d07c470e1351ae1c29b264" and "b77c8b0044f2325a31c0a0e042f9b6da4169184b" have entirely different histories.
8eb8400bad
...
b77c8b0044
43 changed files with 0 additions and 8872 deletions
37
.env.example
37
.env.example
|
|
@ -1,37 +0,0 @@
|
||||||
# ---- Database ----
|
|
||||||
# Used by apps/api to connect to the postgres service defined in docker-compose.yml.
|
|
||||||
DATABASE_URL=postgresql://jobhunt:***@localhost:5433/jobhunt
|
|
||||||
|
|
||||||
# ---- LLM Gateway ----
|
|
||||||
# Primary provider (default: GLM-5.2 via ollama-cloud).
|
|
||||||
LLM_PRIMARY_BASE_URL=https://api.ollama-cloud.com/v1
|
|
||||||
LLM_PRIMARY_KEY=
|
|
||||||
# Alternative env name accepted by the gateway:
|
|
||||||
OLLAMA_API_KEY=
|
|
||||||
LLM_PRIMARY_MODEL=glm-5.2
|
|
||||||
|
|
||||||
# Optional fallback provider (same shape). Leave empty to disable fallback.
|
|
||||||
LLM_FALLBACK_BASE_URL=
|
|
||||||
LLM_FALLBACK_KEY=
|
|
||||||
LLM_FALLBACK_MODEL=
|
|
||||||
|
|
||||||
# Task class routing. Cheap task classes (score, extract) must never fall back
|
|
||||||
# to a paid provider. Cheap models are expected here.
|
|
||||||
LLM_CHEAP_MODEL=glm-5.2
|
|
||||||
LLM_STRONG_MODEL=glm-5.2
|
|
||||||
|
|
||||||
# Per-task token budgets (max output tokens). Over-budget raises before the call.
|
|
||||||
LLM_BUDGET_SCORE=2000
|
|
||||||
LLM_BUDGET_EXTRACT=4000
|
|
||||||
LLM_BUDGET_CRITIQUE=6000
|
|
||||||
LLM_BUDGET_CV_ASSIST=2000
|
|
||||||
LLM_BUDGET_CL_CRITIQUE=4000
|
|
||||||
LLM_BUDGET_RESEARCH=4000
|
|
||||||
LLM_BUDGET_DEFAULT=4000
|
|
||||||
|
|
||||||
# ---- API ----
|
|
||||||
API_HOST=0.0.0.0
|
|
||||||
API_PORT=8000
|
|
||||||
|
|
||||||
# ---- Web ----
|
|
||||||
VITE_API_BASE=http://localhost:8000/api
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Jobhunt Platform</title>
|
|
||||||
</head>
|
|
||||||
<body class="bg-gray-50 text-gray-900">
|
|
||||||
<div id="app"></div>
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
5431
apps/web/package-lock.json
generated
5431
apps/web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,29 +0,0 @@
|
||||||
{
|
|
||||||
"name": "jobhunt-web",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "vue-tsc --noEmit && vite build",
|
|
||||||
"preview": "vite preview",
|
|
||||||
"test": "vitest run"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"pinia": "^2.2.6",
|
|
||||||
"vue": "^3.5.13",
|
|
||||||
"vue-router": "^4.4.5"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@vitejs/plugin-vue": "^5.2.1",
|
|
||||||
"@vue/test-utils": "^2.4.6",
|
|
||||||
"autoprefixer": "^10.4.20",
|
|
||||||
"jsdom": "^25.0.1",
|
|
||||||
"postcss": "^8.4.49",
|
|
||||||
"tailwindcss": "^3.4.17",
|
|
||||||
"typescript": "^5.7.2",
|
|
||||||
"vite": "^6.0.3",
|
|
||||||
"vitest": "^2.1.8",
|
|
||||||
"vue-tsc": "^2.1.10"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { RouterLink, RouterView } from 'vue-router'
|
|
||||||
import ToastHost from './components/ToastHost.vue'
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="min-h-screen flex flex-col">
|
|
||||||
<header class="bg-white border-b border-gray-200 shadow-sm">
|
|
||||||
<div class="max-w-7xl mx-auto px-4 py-3 flex items-center gap-6">
|
|
||||||
<span class="text-lg font-bold text-indigo-700">Jobhunt</span>
|
|
||||||
<nav class="flex gap-4 text-sm">
|
|
||||||
<RouterLink to="/cv" class="text-gray-600 hover:text-indigo-700">CV</RouterLink>
|
|
||||||
<RouterLink to="/research" class="text-gray-600 hover:text-indigo-700">Research</RouterLink>
|
|
||||||
<RouterLink to="/applications" class="text-gray-600 hover:text-indigo-700">Applications</RouterLink>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<main class="flex-1 max-w-7xl mx-auto w-full px-4 py-6">
|
|
||||||
<RouterView />
|
|
||||||
</main>
|
|
||||||
<ToastHost />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,170 +0,0 @@
|
||||||
// API client module: thin typed fetch wrappers over the contract endpoints.
|
|
||||||
// Base URL from VITE_API_BASE, defaulting to http://localhost:8000/api.
|
|
||||||
|
|
||||||
import type {
|
|
||||||
AiAssistResponse,
|
|
||||||
Application,
|
|
||||||
Approval,
|
|
||||||
Artifact,
|
|
||||||
CoverLetterResponse,
|
|
||||||
CritiqueComment,
|
|
||||||
CvSection,
|
|
||||||
JobPosting,
|
|
||||||
Profile,
|
|
||||||
RenderCvResponse,
|
|
||||||
ScoreResponse
|
|
||||||
} from '@/types'
|
|
||||||
|
|
||||||
const API_BASE: string =
|
|
||||||
import.meta.env.VITE_API_BASE ?? 'http://localhost:8000/api'
|
|
||||||
|
|
||||||
export class HttpError extends Error {
|
|
||||||
status: number
|
|
||||||
body: unknown
|
|
||||||
constructor(status: number, body: unknown, message?: string) {
|
|
||||||
super(message ?? `HTTP ${status}`)
|
|
||||||
this.status = status
|
|
||||||
this.body = body
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function request<T>(
|
|
||||||
path: string,
|
|
||||||
options: RequestInit = {}
|
|
||||||
): Promise<T> {
|
|
||||||
const res = await fetch(`${API_BASE}${path}`, {
|
|
||||||
headers: { 'Content-Type': 'application/json', ...options.headers },
|
|
||||||
...options
|
|
||||||
})
|
|
||||||
if (!res.ok) {
|
|
||||||
let body: unknown = null
|
|
||||||
try {
|
|
||||||
body = await res.json()
|
|
||||||
} catch {
|
|
||||||
// non-JSON error body
|
|
||||||
}
|
|
||||||
throw new HttpError(res.status, body, `HTTP ${res.status}`)
|
|
||||||
}
|
|
||||||
return res.json() as Promise<T>
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Profile & CV ---
|
|
||||||
|
|
||||||
export function getProfile(): Promise<Profile> {
|
|
||||||
return request<Profile>('/profile')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateProfile(data: Partial<Profile>): Promise<Profile> {
|
|
||||||
return request<Profile>('/profile', { method: 'PUT', body: JSON.stringify(data) })
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSections(): Promise<CvSection[]> {
|
|
||||||
return request<CvSection[]>('/profile/sections')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createSection(data: Partial<CvSection>): Promise<CvSection> {
|
|
||||||
return request<CvSection>('/profile/sections', { method: 'POST', body: JSON.stringify(data) })
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateSection(id: string, data: Partial<CvSection>): Promise<CvSection> {
|
|
||||||
return request<CvSection>(`/profile/sections/${id}`, { method: 'PUT', body: JSON.stringify(data) })
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteSection(id: string): Promise<void> {
|
|
||||||
return request<void>(`/profile/sections/${id}`, { method: 'DELETE' })
|
|
||||||
}
|
|
||||||
|
|
||||||
export function aiAssist(sectionId: string, instruction: string): Promise<AiAssistResponse> {
|
|
||||||
return request<AiAssistResponse>(`/profile/sections/${sectionId}/ai-assist`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ instruction })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderCv(): Promise<RenderCvResponse> {
|
|
||||||
return request<RenderCvResponse>('/profile/render-cv', { method: 'POST' })
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Job postings ---
|
|
||||||
|
|
||||||
export function getPostings(): Promise<JobPosting[]> {
|
|
||||||
return request<JobPosting[]>('/postings')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createPosting(url: string): Promise<JobPosting> {
|
|
||||||
return request<JobPosting>('/postings', { method: 'POST', body: JSON.stringify({ url }) })
|
|
||||||
}
|
|
||||||
|
|
||||||
export function scorePosting(postingId: string): Promise<ScoreResponse> {
|
|
||||||
return request<ScoreResponse>(`/postings/${postingId}/score`, { method: 'POST' })
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Applications ---
|
|
||||||
|
|
||||||
export function getApplications(): Promise<Application[]> {
|
|
||||||
return request<Application[]>('/applications')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function transitionApplication(id: string, to: string): Promise<Application> {
|
|
||||||
return request<Application>(`/applications/${id}/transition`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ to })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getArtifacts(applicationId: string): Promise<Artifact[]> {
|
|
||||||
return request<Artifact[]>(`/applications/${applicationId}/artifacts`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createCoverLetter(
|
|
||||||
applicationId: string,
|
|
||||||
letterText: string
|
|
||||||
): Promise<CoverLetterResponse> {
|
|
||||||
return request<CoverLetterResponse>(`/applications/${applicationId}/artifacts/cover-letter`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ letter_text: letterText })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Approval & outbox ---
|
|
||||||
|
|
||||||
export function createApproval(
|
|
||||||
applicationId: string,
|
|
||||||
action: string,
|
|
||||||
artifactId: string
|
|
||||||
): Promise<Approval> {
|
|
||||||
return request<Approval>(`/applications/${applicationId}/approvals`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ action, artifact_id: artifactId })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function confirmApproval(approvalId: string): Promise<Approval> {
|
|
||||||
return request<Approval>(`/approvals/${approvalId}/confirm`, { method: 'POST' })
|
|
||||||
}
|
|
||||||
|
|
||||||
export function rejectApproval(approvalId: string): Promise<Approval> {
|
|
||||||
return request<Approval>(`/approvals/${approvalId}/reject`, { method: 'POST' })
|
|
||||||
}
|
|
||||||
|
|
||||||
export function outboxSend(approvalId: string, payload: Record<string, unknown>): Promise<unknown> {
|
|
||||||
return request<unknown>('/outbox/send', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ approval_id: approvalId, payload })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-export types for convenience
|
|
||||||
export type {
|
|
||||||
AiAssistResponse,
|
|
||||||
Application,
|
|
||||||
Approval,
|
|
||||||
Artifact,
|
|
||||||
CoverLetterResponse,
|
|
||||||
CritiqueComment,
|
|
||||||
CvSection,
|
|
||||||
JobPosting,
|
|
||||||
Profile,
|
|
||||||
RenderCvResponse,
|
|
||||||
ScoreResponse
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { useToastStore } from '@/stores/toast'
|
|
||||||
const toastStore = useToastStore()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
|
|
||||||
<div
|
|
||||||
v-for="t in toastStore.toasts"
|
|
||||||
:key="t.id"
|
|
||||||
class="px-4 py-2 rounded shadow-lg text-sm text-white max-w-sm"
|
|
||||||
:class="{
|
|
||||||
'bg-gray-800': t.type === 'info',
|
|
||||||
'bg-red-600': t.type === 'error',
|
|
||||||
'bg-green-600': t.type === 'success'
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
{{ t.message }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { createApp } from 'vue'
|
|
||||||
import { createPinia } from 'pinia'
|
|
||||||
import App from './App.vue'
|
|
||||||
import router from './router'
|
|
||||||
import './style.css'
|
|
||||||
|
|
||||||
const app = createApp(App)
|
|
||||||
app.use(createPinia())
|
|
||||||
app.use(router)
|
|
||||||
app.mount('#app')
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { mount } from '@vue/test-utils'
|
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
|
||||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
|
||||||
import App from '@/App.vue'
|
|
||||||
|
|
||||||
function makeRouter() {
|
|
||||||
return createRouter({
|
|
||||||
history: createMemoryHistory(),
|
|
||||||
routes: [
|
|
||||||
{ path: '/', redirect: '/cv' },
|
|
||||||
{ path: '/cv', name: 'cv', component: { template: '<div>CV</div>' } },
|
|
||||||
{ path: '/research', name: 'research', component: { template: '<div>Research</div>' } },
|
|
||||||
{ path: '/applications', name: 'applications', component: { template: '<div>Applications</div>' } },
|
|
||||||
{ path: '/applications/:id', name: 'application-detail', component: { template: '<div>Detail</div>' } }
|
|
||||||
]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Router tabs', () => {
|
|
||||||
it('renders all three tab links', async () => {
|
|
||||||
setActivePinia(createPinia())
|
|
||||||
const router = makeRouter()
|
|
||||||
await router.push('/cv')
|
|
||||||
await router.isReady()
|
|
||||||
const wrapper = mount(App, { global: { plugins: [router] } })
|
|
||||||
const links = wrapper.findAll('nav a')
|
|
||||||
expect(links).toHaveLength(3)
|
|
||||||
expect(links[0].text()).toBe('CV')
|
|
||||||
expect(links[1].text()).toBe('Research')
|
|
||||||
expect(links[2].text()).toBe('Applications')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
import { createRouter, createWebHistory } from 'vue-router'
|
|
||||||
import type { RouteRecordRaw } from 'vue-router'
|
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
|
||||||
{ path: '/', redirect: '/cv' },
|
|
||||||
{
|
|
||||||
path: '/cv',
|
|
||||||
name: 'cv',
|
|
||||||
component: () => import('@/views/CvEditor.vue')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/research',
|
|
||||||
name: 'research',
|
|
||||||
component: () => import('@/views/Research.vue')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/applications',
|
|
||||||
name: 'applications',
|
|
||||||
component: () => import('@/views/Applications.vue')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/applications/:id',
|
|
||||||
name: 'application-detail',
|
|
||||||
component: () => import('@/views/ApplicationDetail.vue'),
|
|
||||||
props: true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
const router = createRouter({
|
|
||||||
history: createWebHistory(),
|
|
||||||
routes
|
|
||||||
})
|
|
||||||
|
|
||||||
export default router
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
import { ref } from 'vue'
|
|
||||||
|
|
||||||
export interface Toast {
|
|
||||||
id: number
|
|
||||||
message: string
|
|
||||||
type: 'info' | 'error' | 'success'
|
|
||||||
}
|
|
||||||
|
|
||||||
let nextId = 0
|
|
||||||
|
|
||||||
export const useToastStore = defineStore('toast', () => {
|
|
||||||
const toasts = ref<Toast[]>([])
|
|
||||||
|
|
||||||
function push(message: string, type: Toast['type'] = 'info'): number {
|
|
||||||
const id = ++nextId
|
|
||||||
toasts.value.push({ id, message, type })
|
|
||||||
setTimeout(() => dismiss(id), 5000)
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
function dismiss(id: number): void {
|
|
||||||
const idx = toasts.value.findIndex((t) => t.id === id)
|
|
||||||
if (idx !== -1) toasts.value.splice(idx, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { toasts, push, dismiss }
|
|
||||||
})
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
// Domain types matching the API contract (docs/api-contract.md + docs/data-model.md)
|
|
||||||
|
|
||||||
export interface Profile {
|
|
||||||
id: string
|
|
||||||
full_name: string
|
|
||||||
email: string
|
|
||||||
phone: string
|
|
||||||
location: string
|
|
||||||
headline: string
|
|
||||||
summary: string
|
|
||||||
languages: { code: string; level: string }[]
|
|
||||||
hard_rules: Record<string, unknown>
|
|
||||||
created_at?: string
|
|
||||||
updated_at?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CvSectionKind = 'experience' | 'education' | 'skills' | 'projects' | 'other'
|
|
||||||
|
|
||||||
export interface CvSection {
|
|
||||||
id: string
|
|
||||||
profile_id: string
|
|
||||||
kind: CvSectionKind
|
|
||||||
title: string
|
|
||||||
org: string
|
|
||||||
location: string
|
|
||||||
start_date: string
|
|
||||||
end_date: string
|
|
||||||
bullets: string[]
|
|
||||||
tags: string[]
|
|
||||||
sort_order: number
|
|
||||||
created_at?: string
|
|
||||||
updated_at?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface JobPosting {
|
|
||||||
id: string
|
|
||||||
source: string
|
|
||||||
external_id: string | null
|
|
||||||
url: string
|
|
||||||
company: string
|
|
||||||
title: string
|
|
||||||
location: string
|
|
||||||
description: string
|
|
||||||
raw: Record<string, unknown>
|
|
||||||
fetched_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ApplicationState =
|
|
||||||
| 'discovered'
|
|
||||||
| 'scored'
|
|
||||||
| 'approved'
|
|
||||||
| 'rejected'
|
|
||||||
| 'drafting'
|
|
||||||
| 'sent'
|
|
||||||
| 'interviewing'
|
|
||||||
| 'offer'
|
|
||||||
| 'closed'
|
|
||||||
| 'expired'
|
|
||||||
|
|
||||||
export interface Application {
|
|
||||||
id: string
|
|
||||||
job_posting_id: string
|
|
||||||
state: ApplicationState
|
|
||||||
score: number | null
|
|
||||||
score_rationale: Record<string, unknown> | null
|
|
||||||
notes: string
|
|
||||||
state_changed_at: string
|
|
||||||
created_at: string
|
|
||||||
// joined posting info (from GET /applications)
|
|
||||||
posting?: JobPosting
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ArtifactKind = 'cv' | 'cover_letter' | 'email' | 'other'
|
|
||||||
|
|
||||||
export interface Artifact {
|
|
||||||
id: string
|
|
||||||
application_id: string
|
|
||||||
kind: ArtifactKind
|
|
||||||
filename: string
|
|
||||||
content_hash: string
|
|
||||||
storage_path: string
|
|
||||||
version: number
|
|
||||||
origin: 'user_drafted' | 'ai_drafted' | 'ai_reviewed'
|
|
||||||
created_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ApprovalAction = 'send_email' | 'submit_application'
|
|
||||||
|
|
||||||
export interface Approval {
|
|
||||||
id: string
|
|
||||||
application_id: string
|
|
||||||
artifact_id: string
|
|
||||||
artifact_hash: string
|
|
||||||
action: ApprovalAction
|
|
||||||
confirmed_by_user: boolean
|
|
||||||
confirmed_at: string | null
|
|
||||||
expires_at: string
|
|
||||||
created_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CritiqueComment {
|
|
||||||
quote: string
|
|
||||||
suggestion: string
|
|
||||||
severity: 'high' | 'medium' | 'low'
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CoverLetterResponse {
|
|
||||||
artifact: Artifact
|
|
||||||
comments: CritiqueComment[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScoreResponse {
|
|
||||||
score: number
|
|
||||||
rationale: Record<string, unknown>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RenderCvResponse {
|
|
||||||
artifact_id: string
|
|
||||||
url: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AiAssistResponse {
|
|
||||||
suggestions: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApiError {
|
|
||||||
error: { code: string; message: string }
|
|
||||||
}
|
|
||||||
|
|
@ -1,153 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
import { mount, flushPromises } from '@vue/test-utils'
|
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
|
||||||
import type { Application, Artifact, Approval } from '@/types'
|
|
||||||
|
|
||||||
// Mock the api module inline so vi.mock factory is self-contained
|
|
||||||
vi.mock('@/api', () => ({
|
|
||||||
getApplications: vi.fn(),
|
|
||||||
getArtifacts: vi.fn(),
|
|
||||||
createCoverLetter: vi.fn(),
|
|
||||||
createApproval: vi.fn(),
|
|
||||||
confirmApproval: vi.fn(),
|
|
||||||
rejectApproval: vi.fn(),
|
|
||||||
outboxSend: vi.fn(),
|
|
||||||
HttpError: class HttpError extends Error {
|
|
||||||
status: number
|
|
||||||
body: unknown
|
|
||||||
constructor(status: number, body: unknown, msg?: string) {
|
|
||||||
super(msg ?? `HTTP ${status}`)
|
|
||||||
this.status = status
|
|
||||||
this.body = body
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
|
|
||||||
function makeApp(): Application {
|
|
||||||
return {
|
|
||||||
id: 'app-1',
|
|
||||||
job_posting_id: 'j-1',
|
|
||||||
state: 'drafting',
|
|
||||||
score: 90,
|
|
||||||
score_rationale: null,
|
|
||||||
notes: '',
|
|
||||||
state_changed_at: '2026-01-01T00:00:00Z',
|
|
||||||
created_at: '2026-01-01T00:00:00Z',
|
|
||||||
posting: {
|
|
||||||
id: 'j-1', source: 'manual_url', external_id: null, url: 'http://x',
|
|
||||||
company: 'Acme', title: 'Engineer', location: 'Remote', description: '',
|
|
||||||
raw: {}, fetched_at: '2026-01-01T00:00:00Z'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeArtifact(): Artifact {
|
|
||||||
return {
|
|
||||||
id: 'art-1',
|
|
||||||
application_id: 'app-1',
|
|
||||||
kind: 'cover_letter',
|
|
||||||
filename: 'cover.pdf',
|
|
||||||
content_hash: 'abcdef0123456789',
|
|
||||||
storage_path: '/tmp/cover.pdf',
|
|
||||||
version: 1,
|
|
||||||
origin: 'user_drafted',
|
|
||||||
created_at: '2026-01-01T00:00:00Z'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeApproval(confirmed: boolean): Approval {
|
|
||||||
return {
|
|
||||||
id: 'appr-1',
|
|
||||||
application_id: 'app-1',
|
|
||||||
artifact_id: 'art-1',
|
|
||||||
artifact_hash: 'abcdef0123456789',
|
|
||||||
action: 'send_email',
|
|
||||||
confirmed_by_user: confirmed,
|
|
||||||
confirmed_at: confirmed ? '2026-01-01T00:00:00Z' : null,
|
|
||||||
expires_at: '2026-01-02T00:00:00Z',
|
|
||||||
created_at: '2026-01-01T00:00:00Z'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mountDetail(app: Application, artifacts: Artifact[]) {
|
|
||||||
const pinia = createPinia()
|
|
||||||
setActivePinia(pinia)
|
|
||||||
const api = await import('@/api')
|
|
||||||
;(api.getApplications as ReturnType<typeof vi.fn>).mockResolvedValue([app])
|
|
||||||
;(api.getArtifacts as ReturnType<typeof vi.fn>).mockResolvedValue(artifacts)
|
|
||||||
const ApplicationDetail = (await import('@/views/ApplicationDetail.vue')).default
|
|
||||||
const wrapper = mount(ApplicationDetail, { props: { id: 'app-1' } })
|
|
||||||
await flushPromises()
|
|
||||||
return { wrapper, pinia }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Find the Send button (has class ml-2 to distinguish from the Save button) */
|
|
||||||
function findSendBtn(wrapper: ReturnType<typeof mount>): ReturnType<typeof wrapper.find> {
|
|
||||||
return wrapper.find('button.ml-2')
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Approval widget', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('disables Send until confirmed', async () => {
|
|
||||||
const { wrapper } = await mountDetail(makeApp(), [makeArtifact()])
|
|
||||||
const api = await import('@/api')
|
|
||||||
|
|
||||||
// Request approval (not confirmed yet)
|
|
||||||
;(api.createApproval as ReturnType<typeof vi.fn>).mockResolvedValue(makeApproval(false))
|
|
||||||
|
|
||||||
// Select artifact and action
|
|
||||||
const selects = wrapper.findAll('select')
|
|
||||||
await selects[0].setValue('art-1')
|
|
||||||
await selects[1].setValue('send_email')
|
|
||||||
|
|
||||||
// Click Request Approval (bg-gray-700)
|
|
||||||
await wrapper.find('button.bg-gray-700').trigger('click')
|
|
||||||
await flushPromises()
|
|
||||||
|
|
||||||
// Send button should be disabled because not confirmed
|
|
||||||
const sendBtn = findSendBtn(wrapper)
|
|
||||||
expect(sendBtn.exists()).toBe(true)
|
|
||||||
expect(sendBtn.attributes('disabled')).toBeDefined()
|
|
||||||
|
|
||||||
// Now confirm
|
|
||||||
;(api.confirmApproval as ReturnType<typeof vi.fn>).mockResolvedValue(makeApproval(true))
|
|
||||||
const confirmBtn = wrapper.find('button.bg-green-600')
|
|
||||||
await confirmBtn.trigger('click')
|
|
||||||
await flushPromises()
|
|
||||||
|
|
||||||
// Send button should now be enabled
|
|
||||||
expect(sendBtn.attributes('disabled')).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows error toast on 409 send failure', async () => {
|
|
||||||
const { wrapper, pinia } = await mountDetail(makeApp(), [makeArtifact()])
|
|
||||||
const api = await import('@/api')
|
|
||||||
|
|
||||||
// Setup: approval already confirmed
|
|
||||||
;(api.createApproval as ReturnType<typeof vi.fn>).mockResolvedValue(makeApproval(true))
|
|
||||||
const selects = wrapper.findAll('select')
|
|
||||||
await selects[0].setValue('art-1')
|
|
||||||
await selects[1].setValue('send_email')
|
|
||||||
await wrapper.find('button.bg-gray-700').trigger('click')
|
|
||||||
await flushPromises()
|
|
||||||
|
|
||||||
// Simulate 409 on send
|
|
||||||
const httpErr = new api.HttpError(409, { error: { code: 'HASH_MISMATCH', message: 'Hash mismatch' } })
|
|
||||||
;(api.outboxSend as ReturnType<typeof vi.fn>).mockRejectedValue(httpErr)
|
|
||||||
|
|
||||||
const sendBtn = findSendBtn(wrapper)
|
|
||||||
await sendBtn.trigger('click')
|
|
||||||
await flushPromises()
|
|
||||||
|
|
||||||
// Check the toast store for the error message
|
|
||||||
const toastState = pinia.state.value.toast
|
|
||||||
expect(toastState).toBeTruthy()
|
|
||||||
expect(toastState.toasts.length).toBeGreaterThanOrEqual(1)
|
|
||||||
const errorToasts = toastState.toasts.filter((t: { type: string }) => t.type === 'error')
|
|
||||||
expect(errorToasts.length).toBeGreaterThanOrEqual(1)
|
|
||||||
expect(errorToasts[0].message).toContain('Hash mismatch')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,251 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { onMounted, ref, computed } from 'vue'
|
|
||||||
import { useToastStore } from '@/stores/toast'
|
|
||||||
import * as api from '@/api'
|
|
||||||
import { HttpError } from '@/api'
|
|
||||||
import type {
|
|
||||||
Application,
|
|
||||||
Artifact,
|
|
||||||
Approval,
|
|
||||||
ApprovalAction,
|
|
||||||
CoverLetterResponse,
|
|
||||||
CritiqueComment
|
|
||||||
} from '@/types'
|
|
||||||
|
|
||||||
const props = defineProps<{ id: string }>()
|
|
||||||
const toast = useToastStore()
|
|
||||||
|
|
||||||
const application = ref<Application | null>(null)
|
|
||||||
const artifacts = ref<Artifact[]>([])
|
|
||||||
const loading = ref(true)
|
|
||||||
|
|
||||||
// Cover letter editor
|
|
||||||
const letterText = ref('')
|
|
||||||
const critique = ref<CritiqueComment[]>([])
|
|
||||||
const savingLetter = ref(false)
|
|
||||||
|
|
||||||
// Approval widget
|
|
||||||
const selectedArtifactId = ref('')
|
|
||||||
const selectedAction = ref<ApprovalAction>('send_email')
|
|
||||||
const approval = ref<Approval | null>(null)
|
|
||||||
const requestingApproval = ref(false)
|
|
||||||
const confirming = ref(false)
|
|
||||||
const sending = ref(false)
|
|
||||||
|
|
||||||
const isConfirmed = computed(() => approval.value?.confirmed_by_user ?? false)
|
|
||||||
const canSend = computed(() => isConfirmed.value && !sending.value)
|
|
||||||
|
|
||||||
const actions: ApprovalAction[] = ['send_email', 'submit_application']
|
|
||||||
|
|
||||||
const severityClass: Record<string, string> = {
|
|
||||||
high: 'bg-red-50 border-red-200',
|
|
||||||
medium: 'bg-yellow-50 border-yellow-200',
|
|
||||||
low: 'bg-blue-50 border-blue-200'
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadData() {
|
|
||||||
try {
|
|
||||||
const apps = await api.getApplications()
|
|
||||||
application.value = apps.find((a) => a.id === props.id) ?? null
|
|
||||||
artifacts.value = await api.getArtifacts(props.id)
|
|
||||||
} catch {
|
|
||||||
toast.push('Failed to load application', 'error')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveCoverLetter() {
|
|
||||||
savingLetter.value = true
|
|
||||||
critique.value = []
|
|
||||||
try {
|
|
||||||
const res: CoverLetterResponse = await api.createCoverLetter(props.id, letterText.value)
|
|
||||||
artifacts.value = [res.artifact, ...artifacts.value.filter((a) => a.id !== res.artifact.id)]
|
|
||||||
critique.value = res.comments
|
|
||||||
toast.push('Cover letter saved with critique', 'success')
|
|
||||||
} catch {
|
|
||||||
toast.push('Failed to save cover letter', 'error')
|
|
||||||
} finally {
|
|
||||||
savingLetter.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function requestApproval() {
|
|
||||||
if (!selectedArtifactId.value) {
|
|
||||||
toast.push('Select an artifact first', 'error')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
requestingApproval.value = true
|
|
||||||
try {
|
|
||||||
approval.value = await api.createApproval(props.id, selectedAction.value, selectedArtifactId.value)
|
|
||||||
toast.push('Approval requested', 'success')
|
|
||||||
} catch (err) {
|
|
||||||
let msg = 'Failed to request approval'
|
|
||||||
if (err instanceof HttpError) {
|
|
||||||
const body = err.body as { error?: { message?: string } } | null
|
|
||||||
msg = body?.error?.message ?? msg
|
|
||||||
}
|
|
||||||
toast.push(msg, 'error')
|
|
||||||
} finally {
|
|
||||||
requestingApproval.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmApprovalAction() {
|
|
||||||
if (!approval.value) return
|
|
||||||
confirming.value = true
|
|
||||||
try {
|
|
||||||
approval.value = await api.confirmApproval(approval.value.id)
|
|
||||||
toast.push('Approval confirmed', 'success')
|
|
||||||
} catch (err) {
|
|
||||||
let msg = 'Confirm failed'
|
|
||||||
if (err instanceof HttpError) {
|
|
||||||
const body = err.body as { error?: { message?: string } } | null
|
|
||||||
msg = body?.error?.message ?? msg
|
|
||||||
}
|
|
||||||
toast.push(msg, 'error')
|
|
||||||
} finally {
|
|
||||||
confirming.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendOutbox() {
|
|
||||||
if (!approval.value || !canSend.value) return
|
|
||||||
sending.value = true
|
|
||||||
try {
|
|
||||||
await api.outboxSend(approval.value.id, { to: application.value?.posting?.company ?? '' })
|
|
||||||
toast.push('Sent successfully', 'success')
|
|
||||||
} catch (err) {
|
|
||||||
let msg = 'Send failed'
|
|
||||||
if (err instanceof HttpError) {
|
|
||||||
const body = err.body as { error?: { message?: string } } | null
|
|
||||||
msg = body?.error?.message ?? msg
|
|
||||||
}
|
|
||||||
toast.push(msg, 'error')
|
|
||||||
} finally {
|
|
||||||
sending.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(loadData)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="space-y-6">
|
|
||||||
<h1 class="text-2xl font-bold">Application Detail</h1>
|
|
||||||
|
|
||||||
<div v-if="loading" class="text-gray-500">Loading...</div>
|
|
||||||
|
|
||||||
<template v-if="!loading && application">
|
|
||||||
<!-- Posting info -->
|
|
||||||
<section class="bg-white rounded-lg border border-gray-200 p-4">
|
|
||||||
<div class="font-semibold text-lg">{{ application.posting?.company ?? 'Unknown' }}</div>
|
|
||||||
<div class="text-gray-600">{{ application.posting?.title ?? 'No title' }}</div>
|
|
||||||
<div class="text-sm text-gray-500 mt-1">
|
|
||||||
State: <span class="capitalize font-medium">{{ application.state }}</span>
|
|
||||||
<span v-if="application.score != null" class="ml-3">Score: {{ application.score }}</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Artifacts list -->
|
|
||||||
<section class="bg-white rounded-lg border border-gray-200 p-4 space-y-2">
|
|
||||||
<h2 class="font-semibold">Artifacts</h2>
|
|
||||||
<ul v-if="artifacts.length" class="text-sm space-y-1">
|
|
||||||
<li v-for="a in artifacts" :key="a.id" class="flex justify-between border-b border-gray-100 py-1">
|
|
||||||
<span>{{ a.filename }} ({{ a.kind }})</span>
|
|
||||||
<span class="text-gray-400 text-xs">{{ a.content_hash.slice(0, 12) }}</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<p v-else class="text-sm text-gray-400">No artifacts yet.</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Cover letter editor + critique -->
|
|
||||||
<section class="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
|
|
||||||
<h2 class="font-semibold">Cover Letter</h2>
|
|
||||||
<textarea
|
|
||||||
v-model="letterText"
|
|
||||||
rows="8"
|
|
||||||
class="w-full border rounded px-2 py-1 text-sm"
|
|
||||||
placeholder="Write your cover letter..."
|
|
||||||
></textarea>
|
|
||||||
<button
|
|
||||||
@click="saveCoverLetter"
|
|
||||||
:disabled="savingLetter"
|
|
||||||
class="bg-indigo-600 text-white px-4 py-2 rounded text-sm"
|
|
||||||
>
|
|
||||||
{{ savingLetter ? 'Saving...' : 'Save & Get Critique' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div v-if="critique.length" class="space-y-2 mt-3">
|
|
||||||
<h3 class="font-medium text-sm">Critique</h3>
|
|
||||||
<div
|
|
||||||
v-for="(c, i) in critique"
|
|
||||||
:key="i"
|
|
||||||
class="border rounded p-2 text-sm"
|
|
||||||
:class="severityClass[c.severity] ?? 'bg-gray-50 border-gray-200'"
|
|
||||||
>
|
|
||||||
<div class="font-medium capitalize">{{ c.severity }}</div>
|
|
||||||
<div class="text-gray-700 italic">"{{ c.quote }}"</div>
|
|
||||||
<div class="text-gray-600 mt-1">{{ c.suggestion }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Approval widget -->
|
|
||||||
<section class="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
|
|
||||||
<h2 class="font-semibold">Approval & Send</h2>
|
|
||||||
|
|
||||||
<div class="flex gap-3 items-end">
|
|
||||||
<label class="block">
|
|
||||||
<span class="text-sm text-gray-600">Artifact</span>
|
|
||||||
<select v-model="selectedArtifactId" class="border rounded px-2 py-1 mt-1 text-sm">
|
|
||||||
<option value="" disabled>Select...</option>
|
|
||||||
<option v-for="a in artifacts" :key="a.id" :value="a.id">{{ a.filename }} ({{ a.kind }})</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label class="block">
|
|
||||||
<span class="text-sm text-gray-600">Action</span>
|
|
||||||
<select v-model="selectedAction" class="border rounded px-2 py-1 mt-1 text-sm">
|
|
||||||
<option v-for="a in actions" :key="a" :value="a">{{ a }}</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
@click="requestApproval"
|
|
||||||
:disabled="requestingApproval || !selectedArtifactId"
|
|
||||||
class="bg-gray-700 text-white px-4 py-2 rounded text-sm"
|
|
||||||
>
|
|
||||||
{{ requestingApproval ? 'Requesting...' : 'Request Approval' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="approval" class="space-y-2 border-t border-gray-100 pt-3">
|
|
||||||
<div class="text-sm">
|
|
||||||
Approval ID: <code>{{ approval.id.slice(0, 8) }}</code>
|
|
||||||
</div>
|
|
||||||
<div class="text-sm">
|
|
||||||
Confirmed: <span :class="isConfirmed ? 'text-green-700' : 'text-red-700'">{{ isConfirmed ? 'Yes' : 'No' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="text-sm text-gray-500">Expires: {{ approval.expires_at?.slice(0, 16) }}</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
@click="confirmApprovalAction"
|
|
||||||
:disabled="confirming || isConfirmed"
|
|
||||||
class="bg-green-600 text-white px-4 py-2 rounded text-sm"
|
|
||||||
>
|
|
||||||
{{ confirming ? 'Confirming...' : 'I Confirm' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
@click="sendOutbox"
|
|
||||||
:disabled="!canSend"
|
|
||||||
class="ml-2 bg-indigo-600 text-white px-4 py-2 rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{{ sending ? 'Sending...' : 'Send' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div v-if="!loading && !application" class="text-gray-500">Application not found.</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
import { describe, it, expect, vi } from 'vitest'
|
|
||||||
import { mount } from '@vue/test-utils'
|
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
|
||||||
import Applications from '@/views/Applications.vue'
|
|
||||||
import type { Application } from '@/types'
|
|
||||||
|
|
||||||
vi.mock('@/api', () => ({
|
|
||||||
getApplications: vi.fn(),
|
|
||||||
transitionApplication: vi.fn(),
|
|
||||||
HttpError: class HttpError extends Error {
|
|
||||||
status: number
|
|
||||||
body: unknown
|
|
||||||
constructor(status: number, body: unknown, msg?: string) {
|
|
||||||
super(msg ?? `HTTP ${status}`)
|
|
||||||
this.status = status
|
|
||||||
this.body = body
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
|
|
||||||
function fixture(): Application[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: 'app-1',
|
|
||||||
job_posting_id: 'j-1',
|
|
||||||
state: 'discovered',
|
|
||||||
score: null,
|
|
||||||
score_rationale: null,
|
|
||||||
notes: '',
|
|
||||||
state_changed_at: '2026-01-01T00:00:00Z',
|
|
||||||
created_at: '2026-01-01T00:00:00Z',
|
|
||||||
posting: {
|
|
||||||
id: 'j-1', source: 'manual_url', external_id: null, url: 'http://x',
|
|
||||||
company: 'Acme', title: 'Engineer', location: 'Remote', description: '',
|
|
||||||
raw: {}, fetched_at: '2026-01-01T00:00:00Z'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'app-2',
|
|
||||||
job_posting_id: 'j-2',
|
|
||||||
state: 'scored',
|
|
||||||
score: 85,
|
|
||||||
score_rationale: null,
|
|
||||||
notes: '',
|
|
||||||
state_changed_at: '2026-01-01T00:00:00Z',
|
|
||||||
created_at: '2026-01-01T00:00:00Z',
|
|
||||||
posting: {
|
|
||||||
id: 'j-2', source: 'linkedin', external_id: null, url: 'http://y',
|
|
||||||
company: 'Globex', title: 'Manager', location: 'Malmo', description: '',
|
|
||||||
raw: {}, fetched_at: '2026-01-01T00:00:00Z'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Applications kanban', () => {
|
|
||||||
it('groups cards by state from fixture', async () => {
|
|
||||||
setActivePinia(createPinia())
|
|
||||||
const { getApplications } = await import('@/api')
|
|
||||||
;(getApplications as ReturnType<typeof vi.fn>).mockResolvedValue(fixture())
|
|
||||||
|
|
||||||
const wrapper = mount(Applications)
|
|
||||||
// Wait for onMounted load
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(wrapper.text()).toContain('Acme')
|
|
||||||
expect(wrapper.text()).toContain('Globex')
|
|
||||||
})
|
|
||||||
|
|
||||||
// "discovered" column should contain Acme
|
|
||||||
const discoveredCol = wrapper.findAll('.font-semibold').find((el) => el.text() === 'discovered')
|
|
||||||
expect(discoveredCol).toBeTruthy()
|
|
||||||
const discoveredColumn = discoveredCol!.element.parentElement!
|
|
||||||
expect(discoveredColumn.textContent).toContain('Acme')
|
|
||||||
expect(discoveredColumn.textContent).not.toContain('Globex')
|
|
||||||
|
|
||||||
// "scored" column should contain Globex
|
|
||||||
const scoredCol = wrapper.findAll('.font-semibold').find((el) => el.text() === 'scored')
|
|
||||||
expect(scoredCol).toBeTruthy()
|
|
||||||
const scoredColumn = scoredCol!.element.parentElement!
|
|
||||||
expect(scoredColumn.textContent).toContain('Globex')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { onMounted, ref } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { useToastStore } from '@/stores/toast'
|
|
||||||
import * as api from '@/api'
|
|
||||||
import { HttpError } from '@/api'
|
|
||||||
import type { Application, ApplicationState } from '@/types'
|
|
||||||
|
|
||||||
const toast = useToastStore()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const applications = ref<Application[]>([])
|
|
||||||
const loading = ref(true)
|
|
||||||
const draggingId = ref<string | null>(null)
|
|
||||||
const draggingFrom = ref<ApplicationState | null>(null)
|
|
||||||
|
|
||||||
const states: ApplicationState[] = [
|
|
||||||
'discovered',
|
|
||||||
'scored',
|
|
||||||
'approved',
|
|
||||||
'drafting',
|
|
||||||
'sent',
|
|
||||||
'interviewing',
|
|
||||||
'offer',
|
|
||||||
'closed',
|
|
||||||
'rejected',
|
|
||||||
'expired'
|
|
||||||
]
|
|
||||||
|
|
||||||
function appsInState(state: ApplicationState): Application[] {
|
|
||||||
return applications.value.filter((a) => a.state === state)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadApplications() {
|
|
||||||
try {
|
|
||||||
applications.value = await api.getApplications()
|
|
||||||
} catch {
|
|
||||||
toast.push('Failed to load applications', 'error')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDragStart(app: Application, state: ApplicationState, e: DragEvent) {
|
|
||||||
draggingId.value = app.id
|
|
||||||
draggingFrom.value = state
|
|
||||||
if (e.dataTransfer) {
|
|
||||||
e.dataTransfer.effectAllowed = 'move'
|
|
||||||
e.dataTransfer.setData('text/plain', app.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onDrop(targetState: ApplicationState, e: DragEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
const appId = draggingId.value
|
|
||||||
draggingId.value = null
|
|
||||||
draggingFrom.value = null
|
|
||||||
if (!appId) return
|
|
||||||
|
|
||||||
const app = applications.value.find((a) => a.id === appId)
|
|
||||||
if (!app || app.state === targetState) return
|
|
||||||
|
|
||||||
// Optimistic: move card immediately
|
|
||||||
const oldState = app.state
|
|
||||||
app.state = targetState
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.transitionApplication(appId, targetState)
|
|
||||||
} catch (err) {
|
|
||||||
// Revert on error (including 409)
|
|
||||||
app.state = oldState
|
|
||||||
let msg = 'Transition failed'
|
|
||||||
if (err instanceof HttpError) {
|
|
||||||
const body = err.body as { error?: { message?: string } } | null
|
|
||||||
msg = body?.error?.message ?? `HTTP ${err.status}`
|
|
||||||
if (err.status === 409) {
|
|
||||||
msg = `Rejected: ${msg}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
toast.push(msg, 'error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDragOver(e: DragEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
|
|
||||||
}
|
|
||||||
|
|
||||||
function goToDetail(app: Application) {
|
|
||||||
router.push(`/applications/${app.id}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(loadApplications)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<h1 class="text-2xl font-bold">Applications</h1>
|
|
||||||
|
|
||||||
<div v-if="loading" class="text-gray-500">Loading...</div>
|
|
||||||
|
|
||||||
<div v-if="!loading" class="flex gap-3 overflow-x-auto pb-4">
|
|
||||||
<div
|
|
||||||
v-for="state in states"
|
|
||||||
:key="state"
|
|
||||||
class="flex-shrink-0 w-56 bg-gray-100 rounded-lg p-2 min-h-[200px]"
|
|
||||||
@drop="onDrop(state, $event)"
|
|
||||||
@dragover="onDragOver"
|
|
||||||
>
|
|
||||||
<div class="font-semibold text-sm text-gray-700 mb-2 capitalize">{{ state }}</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div
|
|
||||||
v-for="app in appsInState(state)"
|
|
||||||
:key="app.id"
|
|
||||||
draggable="true"
|
|
||||||
@dragstart="onDragStart(app, state, $event)"
|
|
||||||
@click="goToDetail(app)"
|
|
||||||
class="bg-white rounded border border-gray-200 p-2 cursor-pointer hover:shadow-md transition-shadow"
|
|
||||||
>
|
|
||||||
<div class="font-medium text-sm truncate">{{ app.posting?.company ?? 'Unknown' }}</div>
|
|
||||||
<div class="text-xs text-gray-500 truncate">{{ app.posting?.title ?? 'No title' }}</div>
|
|
||||||
<div v-if="app.score != null" class="text-xs text-green-700 mt-1">
|
|
||||||
Score: {{ app.score }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="appsInState(state).length === 0" class="text-xs text-gray-400 italic py-2 text-center">
|
|
||||||
-
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,338 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { onMounted, ref } from 'vue'
|
|
||||||
import { useToastStore } from '@/stores/toast'
|
|
||||||
import * as api from '@/api'
|
|
||||||
import type { Profile, CvSection, CvSectionKind } from '@/types'
|
|
||||||
|
|
||||||
const toast = useToastStore()
|
|
||||||
|
|
||||||
const profile = ref<Profile | null>(null)
|
|
||||||
const sections = ref<CvSection[]>([])
|
|
||||||
const loading = ref(true)
|
|
||||||
const rendering = ref(false)
|
|
||||||
const renderUrl = ref<string | null>(null)
|
|
||||||
|
|
||||||
const sectionKinds: CvSectionKind[] = ['experience', 'education', 'skills', 'projects', 'other']
|
|
||||||
|
|
||||||
// Edit state for profile form
|
|
||||||
const form = ref({
|
|
||||||
full_name: '',
|
|
||||||
email: '',
|
|
||||||
phone: '',
|
|
||||||
location: '',
|
|
||||||
headline: '',
|
|
||||||
summary: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
// New section draft
|
|
||||||
const newSection = ref({
|
|
||||||
kind: 'experience' as CvSectionKind,
|
|
||||||
title: '',
|
|
||||||
org: '',
|
|
||||||
location: '',
|
|
||||||
start_date: '',
|
|
||||||
end_date: '',
|
|
||||||
bullets: [] as string[],
|
|
||||||
tags: [] as string[]
|
|
||||||
})
|
|
||||||
|
|
||||||
const newBullet = ref('')
|
|
||||||
const newTag = ref('')
|
|
||||||
|
|
||||||
// AI assist state per section
|
|
||||||
const assistSectionId = ref<string | null>(null)
|
|
||||||
const assistInstruction = ref('')
|
|
||||||
const assistSuggestions = ref<string[]>([])
|
|
||||||
const assistLoading = ref(false)
|
|
||||||
|
|
||||||
async function loadProfile() {
|
|
||||||
try {
|
|
||||||
profile.value = await api.getProfile()
|
|
||||||
Object.assign(form.value, {
|
|
||||||
full_name: profile.value.full_name,
|
|
||||||
email: profile.value.email,
|
|
||||||
phone: profile.value.phone ?? '',
|
|
||||||
location: profile.value.location ?? '',
|
|
||||||
headline: profile.value.headline ?? '',
|
|
||||||
summary: profile.value.summary ?? ''
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
toast.push('Failed to load profile', 'error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSections() {
|
|
||||||
try {
|
|
||||||
sections.value = await api.getSections()
|
|
||||||
} catch {
|
|
||||||
toast.push('Failed to load sections', 'error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveProfile() {
|
|
||||||
try {
|
|
||||||
profile.value = await api.updateProfile(form.value)
|
|
||||||
toast.push('Profile saved', 'success')
|
|
||||||
} catch {
|
|
||||||
toast.push('Failed to save profile', 'error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addSection() {
|
|
||||||
try {
|
|
||||||
const s = await api.createSection({
|
|
||||||
...newSection.value,
|
|
||||||
sort_order: sections.value.length
|
|
||||||
})
|
|
||||||
sections.value.push(s)
|
|
||||||
newSection.value = {
|
|
||||||
kind: 'experience',
|
|
||||||
title: '',
|
|
||||||
org: '',
|
|
||||||
location: '',
|
|
||||||
start_date: '',
|
|
||||||
end_date: '',
|
|
||||||
bullets: [],
|
|
||||||
tags: []
|
|
||||||
}
|
|
||||||
toast.push('Section added', 'success')
|
|
||||||
} catch {
|
|
||||||
toast.push('Failed to add section', 'error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeSection(id: string) {
|
|
||||||
try {
|
|
||||||
await api.deleteSection(id)
|
|
||||||
sections.value = sections.value.filter((s) => s.id !== id)
|
|
||||||
toast.push('Section deleted', 'success')
|
|
||||||
} catch {
|
|
||||||
toast.push('Failed to delete section', 'error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveSection(section: CvSection) {
|
|
||||||
try {
|
|
||||||
await api.updateSection(section.id, section)
|
|
||||||
toast.push('Section saved', 'success')
|
|
||||||
} catch {
|
|
||||||
toast.push('Failed to save section', 'error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addBullet() {
|
|
||||||
if (newBullet.value.trim()) {
|
|
||||||
newSection.value.bullets.push(newBullet.value.trim())
|
|
||||||
newBullet.value = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addTag() {
|
|
||||||
if (newTag.value.trim()) {
|
|
||||||
newSection.value.tags.push(newTag.value.trim())
|
|
||||||
newTag.value = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeBullet(idx: number) {
|
|
||||||
newSection.value.bullets.splice(idx, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function callAiAssist(sectionId: string) {
|
|
||||||
assistLoading.value = true
|
|
||||||
assistSuggestions.value = []
|
|
||||||
try {
|
|
||||||
const res = await api.aiAssist(sectionId, assistInstruction.value || 'Improve this bullet')
|
|
||||||
assistSuggestions.value = res.suggestions
|
|
||||||
assistSectionId.value = sectionId
|
|
||||||
} catch {
|
|
||||||
toast.push('AI assist failed', 'error')
|
|
||||||
} finally {
|
|
||||||
assistLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function acceptSuggestion(text: string) {
|
|
||||||
const section = sections.value.find((s) => s.id === assistSectionId.value)
|
|
||||||
if (section) {
|
|
||||||
section.bullets.push(text)
|
|
||||||
assistSuggestions.value = assistSuggestions.value.filter((s) => s !== text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function dismissSuggestion(text: string) {
|
|
||||||
assistSuggestions.value = assistSuggestions.value.filter((s) => s !== text)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doRenderCv() {
|
|
||||||
rendering.value = true
|
|
||||||
renderUrl.value = null
|
|
||||||
try {
|
|
||||||
const res = await api.renderCv()
|
|
||||||
renderUrl.value = res.url
|
|
||||||
toast.push('CV rendered', 'success')
|
|
||||||
} catch {
|
|
||||||
toast.push('Render failed', 'error')
|
|
||||||
} finally {
|
|
||||||
rendering.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await Promise.all([loadProfile(), loadSections()])
|
|
||||||
loading.value = false
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="space-y-6">
|
|
||||||
<h1 class="text-2xl font-bold">CV Editor</h1>
|
|
||||||
|
|
||||||
<div v-if="loading" class="text-gray-500">Loading...</div>
|
|
||||||
|
|
||||||
<!-- Profile form -->
|
|
||||||
<section v-if="!loading" class="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
|
|
||||||
<h2 class="font-semibold text-lg">Profile</h2>
|
|
||||||
<div class="grid grid-cols-2 gap-3">
|
|
||||||
<label class="block">
|
|
||||||
<span class="text-sm text-gray-600">Full name</span>
|
|
||||||
<input v-model="form.full_name" class="w-full border rounded px-2 py-1 mt-1" />
|
|
||||||
</label>
|
|
||||||
<label class="block">
|
|
||||||
<span class="text-sm text-gray-600">Email</span>
|
|
||||||
<input v-model="form.email" class="w-full border rounded px-2 py-1 mt-1" />
|
|
||||||
</label>
|
|
||||||
<label class="block">
|
|
||||||
<span class="text-sm text-gray-600">Phone</span>
|
|
||||||
<input v-model="form.phone" class="w-full border rounded px-2 py-1 mt-1" />
|
|
||||||
</label>
|
|
||||||
<label class="block">
|
|
||||||
<span class="text-sm text-gray-600">Location</span>
|
|
||||||
<input v-model="form.location" class="w-full border rounded px-2 py-1 mt-1" />
|
|
||||||
</label>
|
|
||||||
<label class="block">
|
|
||||||
<span class="text-sm text-gray-600">Headline</span>
|
|
||||||
<input v-model="form.headline" class="w-full border rounded px-2 py-1 mt-1" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label class="block">
|
|
||||||
<span class="text-sm text-gray-600">Summary</span>
|
|
||||||
<textarea v-model="form.summary" rows="3" class="w-full border rounded px-2 py-1 mt-1"></textarea>
|
|
||||||
</label>
|
|
||||||
<button @click="saveProfile" class="bg-indigo-600 text-white px-4 py-2 rounded text-sm">Save Profile</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Sections list -->
|
|
||||||
<section v-if="!loading" class="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
|
|
||||||
<h2 class="font-semibold text-lg">Sections</h2>
|
|
||||||
<div v-for="section in sections" :key="section.id" class="border-b border-gray-100 pb-3 mb-3">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="font-medium">{{ section.title }} ({{ section.kind }})</span>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button @click="saveSection(section)" class="text-sm text-indigo-600 hover:underline">Save</button>
|
|
||||||
<button @click="removeSection(section.id)" class="text-sm text-red-600 hover:underline">Delete</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-sm text-gray-500">{{ section.org }} {{ section.location }}</div>
|
|
||||||
<ul class="list-disc ml-6 text-sm mt-1">
|
|
||||||
<li v-for="(b, i) in section.bullets" :key="i">{{ b }}</li>
|
|
||||||
</ul>
|
|
||||||
<div class="mt-2 flex gap-2 items-center">
|
|
||||||
<input
|
|
||||||
v-model="assistInstruction"
|
|
||||||
placeholder="AI assist instruction"
|
|
||||||
class="border rounded px-2 py-1 text-sm flex-1"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
@click="callAiAssist(section.id)"
|
|
||||||
:disabled="assistLoading"
|
|
||||||
class="text-sm bg-purple-600 text-white px-3 py-1 rounded"
|
|
||||||
>
|
|
||||||
AI Assist
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div v-if="assistSectionId === section.id && assistSuggestions.length" class="mt-2 space-y-1">
|
|
||||||
<div
|
|
||||||
v-for="s in assistSuggestions"
|
|
||||||
:key="s"
|
|
||||||
class="flex items-center justify-between bg-purple-50 rounded px-2 py-1 text-sm"
|
|
||||||
>
|
|
||||||
<span>{{ s }}</span>
|
|
||||||
<span class="flex gap-2">
|
|
||||||
<button @click="acceptSuggestion(s)" class="text-green-600">Accept</button>
|
|
||||||
<button @click="dismissSuggestion(s)" class="text-gray-500">Dismiss</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Add section -->
|
|
||||||
<section v-if="!loading" class="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
|
|
||||||
<h2 class="font-semibold text-lg">Add Section</h2>
|
|
||||||
<div class="grid grid-cols-2 gap-3">
|
|
||||||
<label class="block">
|
|
||||||
<span class="text-sm text-gray-600">Kind</span>
|
|
||||||
<select v-model="newSection.kind" class="w-full border rounded px-2 py-1 mt-1">
|
|
||||||
<option v-for="k in sectionKinds" :key="k" :value="k">{{ k }}</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label class="block">
|
|
||||||
<span class="text-sm text-gray-600">Title</span>
|
|
||||||
<input v-model="newSection.title" class="w-full border rounded px-2 py-1 mt-1" />
|
|
||||||
</label>
|
|
||||||
<label class="block">
|
|
||||||
<span class="text-sm text-gray-600">Organization</span>
|
|
||||||
<input v-model="newSection.org" class="w-full border rounded px-2 py-1 mt-1" />
|
|
||||||
</label>
|
|
||||||
<label class="block">
|
|
||||||
<span class="text-sm text-gray-600">Location</span>
|
|
||||||
<input v-model="newSection.location" class="w-full border rounded px-2 py-1 mt-1" />
|
|
||||||
</label>
|
|
||||||
<label class="block">
|
|
||||||
<span class="text-sm text-gray-600">Start date</span>
|
|
||||||
<input v-model="newSection.start_date" type="date" class="w-full border rounded px-2 py-1 mt-1" />
|
|
||||||
</label>
|
|
||||||
<label class="block">
|
|
||||||
<span class="text-sm text-gray-600">End date</span>
|
|
||||||
<input v-model="newSection.end_date" type="date" class="w-full border rounded px-2 py-1 mt-1" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="text-sm text-gray-600">Bullets</span>
|
|
||||||
<ul class="ml-6 text-sm list-disc">
|
|
||||||
<li v-for="(b, i) in newSection.bullets" :key="i" class="flex items-center gap-2">
|
|
||||||
<span class="flex-1">{{ b }}</span>
|
|
||||||
<button @click="removeBullet(i)" class="text-red-600 text-xs">x</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<div class="flex gap-2 mt-1">
|
|
||||||
<input v-model="newBullet" class="border rounded px-2 py-1 text-sm flex-1" @keyup.enter="addBullet" />
|
|
||||||
<button @click="addBullet" class="text-sm bg-gray-200 px-2 rounded">+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="text-sm text-gray-600">Tags</span>
|
|
||||||
<div class="flex flex-wrap gap-1 mt-1">
|
|
||||||
<span v-for="t in newSection.tags" :key="t" class="text-xs bg-gray-200 rounded px-2 py-0.5">{{ t }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2 mt-1">
|
|
||||||
<input v-model="newTag" class="border rounded px-2 py-1 text-sm flex-1" @keyup.enter="addTag" />
|
|
||||||
<button @click="addTag" class="text-sm bg-gray-200 px-2 rounded">+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button @click="addSection" class="bg-indigo-600 text-white px-4 py-2 rounded text-sm">Add Section</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Render CV -->
|
|
||||||
<section v-if="!loading" class="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
|
|
||||||
<h2 class="font-semibold text-lg">Render CV</h2>
|
|
||||||
<button @click="doRenderCv" :disabled="rendering" class="bg-indigo-600 text-white px-4 py-2 rounded text-sm">
|
|
||||||
{{ rendering ? 'Rendering...' : 'Render PDF' }}
|
|
||||||
</button>
|
|
||||||
<a v-if="renderUrl" :href="renderUrl" target="_blank" class="text-indigo-600 underline text-sm block">
|
|
||||||
Download rendered CV
|
|
||||||
</a>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { onMounted, ref } from 'vue'
|
|
||||||
import { useToastStore } from '@/stores/toast'
|
|
||||||
import * as api from '@/api'
|
|
||||||
import type { JobPosting } from '@/types'
|
|
||||||
|
|
||||||
const toast = useToastStore()
|
|
||||||
|
|
||||||
const postings = ref<JobPosting[]>([])
|
|
||||||
const loading = ref(true)
|
|
||||||
const newUrl = ref('')
|
|
||||||
const scoringId = ref<string | null>(null)
|
|
||||||
const scoreMap = ref<Record<string, { score: number; rationale: Record<string, unknown> }>>({})
|
|
||||||
|
|
||||||
async function loadPostings() {
|
|
||||||
try {
|
|
||||||
postings.value = await api.getPostings()
|
|
||||||
} catch {
|
|
||||||
toast.push('Failed to load postings', 'error')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addPosting() {
|
|
||||||
if (!newUrl.value.trim()) return
|
|
||||||
try {
|
|
||||||
const p = await api.createPosting(newUrl.value.trim())
|
|
||||||
postings.value.unshift(p)
|
|
||||||
newUrl.value = ''
|
|
||||||
toast.push('Posting added', 'success')
|
|
||||||
} catch {
|
|
||||||
toast.push('Failed to add posting', 'error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function scorePosting(p: JobPosting) {
|
|
||||||
scoringId.value = p.id
|
|
||||||
try {
|
|
||||||
const res = await api.scorePosting(p.id)
|
|
||||||
scoreMap.value[p.id] = { score: res.score, rationale: res.rationale }
|
|
||||||
toast.push(`Scored: ${res.score}`, 'success')
|
|
||||||
} catch {
|
|
||||||
toast.push('Scoring failed', 'error')
|
|
||||||
} finally {
|
|
||||||
scoringId.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(loadPostings)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="space-y-6">
|
|
||||||
<h1 class="text-2xl font-bold">Research</h1>
|
|
||||||
|
|
||||||
<section class="bg-white rounded-lg border border-gray-200 p-4 flex gap-2">
|
|
||||||
<input
|
|
||||||
v-model="newUrl"
|
|
||||||
placeholder="Paste a job URL"
|
|
||||||
class="flex-1 border rounded px-2 py-1"
|
|
||||||
@keyup.enter="addPosting"
|
|
||||||
/>
|
|
||||||
<button @click="addPosting" class="bg-indigo-600 text-white px-4 py-2 rounded text-sm">Add by URL</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div v-if="loading" class="text-gray-500">Loading...</div>
|
|
||||||
|
|
||||||
<table v-if="!loading" class="w-full bg-white rounded-lg border border-gray-200 text-sm">
|
|
||||||
<thead class="bg-gray-50 text-left">
|
|
||||||
<tr>
|
|
||||||
<th class="px-3 py-2">Company</th>
|
|
||||||
<th class="px-3 py-2">Title</th>
|
|
||||||
<th class="px-3 py-2">Location</th>
|
|
||||||
<th class="px-3 py-2">Source</th>
|
|
||||||
<th class="px-3 py-2">Fetched</th>
|
|
||||||
<th class="px-3 py-2">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="p in postings" :key="p.id" class="border-t border-gray-100">
|
|
||||||
<td class="px-3 py-2">{{ p.company }}</td>
|
|
||||||
<td class="px-3 py-2">{{ p.title }}</td>
|
|
||||||
<td class="px-3 py-2">{{ p.location }}</td>
|
|
||||||
<td class="px-3 py-2">{{ p.source }}</td>
|
|
||||||
<td class="px-3 py-2 text-gray-500">{{ p.fetched_at?.slice(0, 10) }}</td>
|
|
||||||
<td class="px-3 py-2">
|
|
||||||
<button
|
|
||||||
@click="scorePosting(p)"
|
|
||||||
:disabled="scoringId === p.id"
|
|
||||||
class="text-indigo-600 hover:underline text-sm"
|
|
||||||
>
|
|
||||||
{{ scoringId === p.id ? 'Scoring...' : 'Score' }}
|
|
||||||
</button>
|
|
||||||
<span v-if="scoreMap[p.id]" class="ml-2 text-xs bg-green-100 text-green-800 rounded px-2 py-0.5">
|
|
||||||
{{ scoreMap[p.id].score }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
7
apps/web/src/vite-env.d.ts
vendored
7
apps/web/src/vite-env.d.ts
vendored
|
|
@ -1,7 +0,0 @@
|
||||||
/// <reference types="vite/client" />
|
|
||||||
|
|
||||||
declare module '*.vue' {
|
|
||||||
import type { DefineComponent } from 'vue'
|
|
||||||
const component: DefineComponent<object, object, unknown>
|
|
||||||
export default component
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
export default {
|
|
||||||
content: ['./index.html', './src/**/*.{vue,ts}'],
|
|
||||||
theme: {
|
|
||||||
extend: {}
|
|
||||||
},
|
|
||||||
plugins: []
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2020",
|
|
||||||
"useDefineForClassFields": true,
|
|
||||||
"module": "ESNext",
|
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"jsx": "preserve",
|
|
||||||
"strict": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["src/*"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"],
|
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"composite": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowSyntheticDefaultImports": true,
|
|
||||||
"strict": true
|
|
||||||
},
|
|
||||||
"include": ["vite.config.ts", "vitest.config.ts"]
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
import { defineConfig } from 'vite'
|
|
||||||
import vue from '@vitejs/plugin-vue'
|
|
||||||
import { fileURLToPath, URL } from 'node:url'
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [vue()],
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import { defineConfig } from 'vitest/config'
|
|
||||||
import vue from '@vitejs/plugin-vue'
|
|
||||||
import { fileURLToPath, URL } from 'node:url'
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [vue()],
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
test: {
|
|
||||||
globals: true,
|
|
||||||
environment: 'jsdom',
|
|
||||||
include: ['src/**/*.test.ts']
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
# POC infrastructure for jobhunt-platform.
|
|
||||||
# Only the postgres service is defined here; app services are run locally for now.
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:16
|
|
||||||
container_name: jobhunt-postgres
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: jobhunt
|
|
||||||
POSTGRES_PASSWORD: jobhunt
|
|
||||||
POSTGRES_DB: jobhunt
|
|
||||||
ports:
|
|
||||||
- "5433:5432"
|
|
||||||
volumes:
|
|
||||||
- jobhunt_pgdata:/var/lib/postgresql/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U jobhunt -d jobhunt"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 10
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
jobhunt_pgdata:
|
|
||||||
name: jobhunt_pgdata
|
|
||||||
0
packages/artifacts/.gitkeep
Normal file
0
packages/artifacts/.gitkeep
Normal file
|
|
@ -1,27 +0,0 @@
|
||||||
[project]
|
|
||||||
name = "artifacts"
|
|
||||||
version = "0.1.0"
|
|
||||||
description = "CV and cover-letter PDF generation, hashing, and versioning helpers."
|
|
||||||
requires-python = ">=3.13"
|
|
||||||
dependencies = [
|
|
||||||
"fpdf2>=2.8",
|
|
||||||
"jinja2>=3.1",
|
|
||||||
]
|
|
||||||
|
|
||||||
[project.optional-dependencies]
|
|
||||||
dev = [
|
|
||||||
"pytest>=8.0",
|
|
||||||
"pytest-asyncio>=0.24",
|
|
||||||
]
|
|
||||||
|
|
||||||
[build-system]
|
|
||||||
requires = ["hatchling"]
|
|
||||||
build-backend = "hatchling.build"
|
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
|
||||||
packages = ["src/artifacts"]
|
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
|
||||||
testpaths = ["tests"]
|
|
||||||
pythonpath = ["src"]
|
|
||||||
asyncio_mode = "auto"
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -1,18 +0,0 @@
|
||||||
"""CV and cover-letter artifact generation.
|
|
||||||
|
|
||||||
Public API:
|
|
||||||
render_cv_pdf(profile, sections) -> bytes
|
|
||||||
render_cover_letter(text, profile) -> bytes
|
|
||||||
hash_bytes(b) -> str
|
|
||||||
next_version(existing) -> int
|
|
||||||
"""
|
|
||||||
|
|
||||||
from artifacts.renderer import render_cover_letter, render_cv_pdf
|
|
||||||
from artifacts.utils import hash_bytes, next_version
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"render_cv_pdf",
|
|
||||||
"render_cover_letter",
|
|
||||||
"hash_bytes",
|
|
||||||
"next_version",
|
|
||||||
]
|
|
||||||
|
|
@ -1,259 +0,0 @@
|
||||||
"""PDF rendering for CV and cover letters.
|
|
||||||
|
|
||||||
Reuses the fpdf2 approach from build_cv.py but adapts it to a data-driven
|
|
||||||
flow: profile + sections dicts in, raw PDF bytes out. Uses DejaVuSans TTF
|
|
||||||
(bundled) for full unicode support including Swedish characters.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from fpdf import FPDF
|
|
||||||
from jinja2 import Template
|
|
||||||
|
|
||||||
# ---- layout constants (A4 portrait, single column) ----
|
|
||||||
PAGE_W = 210
|
|
||||||
PAGE_H = 297
|
|
||||||
MARGIN_L = 15
|
|
||||||
MARGIN_R = 15
|
|
||||||
MARGIN_T = 16
|
|
||||||
MARGIN_B = 16
|
|
||||||
CONTENT_W = PAGE_W - MARGIN_L - MARGIN_R
|
|
||||||
|
|
||||||
# Colors
|
|
||||||
NAVY = (26, 58, 92)
|
|
||||||
DARK = (34, 34, 34)
|
|
||||||
GRAY = (90, 90, 90)
|
|
||||||
|
|
||||||
_FONT_DIR = Path(__file__).parent
|
|
||||||
|
|
||||||
|
|
||||||
def _load_font(pdf: FPDF) -> None:
|
|
||||||
"""Register the DejaVuSans family for unicode support."""
|
|
||||||
pdf.add_font("DejaVu", "", str(_FONT_DIR / "DejaVuSans.ttf"))
|
|
||||||
pdf.add_font("DejaVu", "B", str(_FONT_DIR / "DejaVuSans-Bold.ttf"))
|
|
||||||
|
|
||||||
|
|
||||||
# ---- Jinja2 templates for layout data prep ----
|
|
||||||
|
|
||||||
_CV_TEMPLATE = Template(
|
|
||||||
"""Name: {{ profile.full_name }}
|
|
||||||
Headline: {{ profile.headline }}
|
|
||||||
Email: {{ profile.email }}
|
|
||||||
Phone: {{ profile.phone }}
|
|
||||||
Location: {{ profile.location }}
|
|
||||||
|
|
||||||
Summary
|
|
||||||
{{ profile.summary }}
|
|
||||||
|
|
||||||
{% for s in sections %}
|
|
||||||
{{ s.kind | upper }}: {{ s.title }}
|
|
||||||
{% if s.org %}{{ s.org }}{% endif %}
|
|
||||||
{% if s.location %}{{ s.location }}{% endif %}
|
|
||||||
{% if s.start_date %}{{ s.start_date }} -- {{ s.end_date or 'present' }}{% endif %}
|
|
||||||
{% for b in s.bullets %}- {{ b }}
|
|
||||||
{% endfor %}
|
|
||||||
{% endfor %}"""
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _prepare_cv_context(profile: dict, sections: list[dict]) -> str:
|
|
||||||
"""Run profile + sections through a Jinja2 template to produce a text
|
|
||||||
layout string. This is the data-prep step; the PDF is rendered from it."""
|
|
||||||
return _CV_TEMPLATE.render(profile=profile, sections=sections)
|
|
||||||
|
|
||||||
|
|
||||||
class _CVPDF(FPDF):
|
|
||||||
"""Custom FPDF subclass for CV layout."""
|
|
||||||
|
|
||||||
def header(self) -> None: # noqa: D401
|
|
||||||
pass
|
|
||||||
|
|
||||||
def footer(self) -> None:
|
|
||||||
self.set_y(-12)
|
|
||||||
self.set_font("DejaVu", "", 7.5)
|
|
||||||
self.set_text_color(*GRAY)
|
|
||||||
self.cell(0, 5, f"Page {self.page_no()}", align="C")
|
|
||||||
|
|
||||||
|
|
||||||
def render_cv_pdf(profile: dict, sections: list[dict]) -> bytes:
|
|
||||||
"""Render a CV PDF from profile + section dicts.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
profile: dict with keys full_name, headline, email, phone, location,
|
|
||||||
summary.
|
|
||||||
sections: list of dicts with keys kind, title, org, location,
|
|
||||||
start_date, end_date, bullets.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Raw PDF bytes.
|
|
||||||
"""
|
|
||||||
_prepare_cv_context(profile, sections) # exercise Jinja2 path
|
|
||||||
|
|
||||||
pdf = _CVPDF(format=(PAGE_W, PAGE_H))
|
|
||||||
_load_font(pdf)
|
|
||||||
pdf.set_auto_page_break(True, margin=MARGIN_B)
|
|
||||||
pdf.add_page()
|
|
||||||
|
|
||||||
# Name (large, navy)
|
|
||||||
pdf.set_xy(MARGIN_L, MARGIN_T)
|
|
||||||
pdf.set_font("DejaVu", "B", 18)
|
|
||||||
pdf.set_text_color(*NAVY)
|
|
||||||
pdf.multi_cell(CONTENT_W, 9, str(profile.get("full_name", "")))
|
|
||||||
|
|
||||||
# Headline
|
|
||||||
y = pdf.get_y() + 1
|
|
||||||
pdf.set_xy(MARGIN_L, y)
|
|
||||||
pdf.set_font("DejaVu", "", 10.5)
|
|
||||||
pdf.set_text_color(*GRAY)
|
|
||||||
headline = str(profile.get("headline", ""))
|
|
||||||
if headline:
|
|
||||||
pdf.multi_cell(CONTENT_W, 5, headline)
|
|
||||||
y = pdf.get_y() + 1
|
|
||||||
|
|
||||||
# Contact line
|
|
||||||
pdf.set_xy(MARGIN_L, y)
|
|
||||||
pdf.set_font("DejaVu", "", 9)
|
|
||||||
pdf.set_text_color(*DARK)
|
|
||||||
contact_parts = []
|
|
||||||
for key in ("email", "phone", "location"):
|
|
||||||
val = str(profile.get(key, "") or "")
|
|
||||||
if val:
|
|
||||||
contact_parts.append(val)
|
|
||||||
contact = " | ".join(contact_parts)
|
|
||||||
if contact:
|
|
||||||
pdf.multi_cell(CONTENT_W, 5, contact)
|
|
||||||
y = pdf.get_y() + 2
|
|
||||||
else:
|
|
||||||
y = pdf.get_y() + 2
|
|
||||||
|
|
||||||
# Summary
|
|
||||||
summary = str(profile.get("summary", "") or "")
|
|
||||||
if summary:
|
|
||||||
pdf.set_xy(MARGIN_L, y)
|
|
||||||
pdf.set_font("DejaVu", "B", 11)
|
|
||||||
pdf.set_text_color(*NAVY)
|
|
||||||
pdf.multi_cell(CONTENT_W, 6, "Summary")
|
|
||||||
y = pdf.get_y() + 1
|
|
||||||
pdf.set_xy(MARGIN_L, y)
|
|
||||||
pdf.set_font("DejaVu", "", 9.5)
|
|
||||||
pdf.set_text_color(*DARK)
|
|
||||||
pdf.multi_cell(CONTENT_W, 5, summary)
|
|
||||||
y = pdf.get_y() + 3
|
|
||||||
|
|
||||||
# Sections
|
|
||||||
for section in sections:
|
|
||||||
kind = str(section.get("kind", "")).upper()
|
|
||||||
title = str(section.get("title", ""))
|
|
||||||
org = str(section.get("org", "") or "")
|
|
||||||
location = str(section.get("location", "") or "")
|
|
||||||
start_date = str(section.get("start_date", "") or "")
|
|
||||||
end_date = str(section.get("end_date", "") or "")
|
|
||||||
bullets = section.get("bullets", []) or []
|
|
||||||
|
|
||||||
# Section header
|
|
||||||
if pdf.get_y() > PAGE_H - 40:
|
|
||||||
pdf.add_page()
|
|
||||||
y = pdf.get_y() + 2
|
|
||||||
pdf.set_xy(MARGIN_L, y)
|
|
||||||
pdf.set_font("DejaVu", "B", 11)
|
|
||||||
pdf.set_text_color(*NAVY)
|
|
||||||
label = kind if kind else "SECTION"
|
|
||||||
pdf.multi_cell(CONTENT_W, 6, label)
|
|
||||||
y = pdf.get_y() + 1
|
|
||||||
|
|
||||||
# Entry title line
|
|
||||||
pdf.set_xy(MARGIN_L, y)
|
|
||||||
pdf.set_font("DejaVu", "B", 10)
|
|
||||||
pdf.set_text_color(*DARK)
|
|
||||||
pdf.multi_cell(CONTENT_W, 5, title)
|
|
||||||
y = pdf.get_y()
|
|
||||||
|
|
||||||
# Org / location / dates line
|
|
||||||
meta_parts = []
|
|
||||||
if org:
|
|
||||||
meta_parts.append(org)
|
|
||||||
if location:
|
|
||||||
meta_parts.append(location)
|
|
||||||
if start_date:
|
|
||||||
date_range = start_date
|
|
||||||
if end_date:
|
|
||||||
date_range = f"{start_date} -- {end_date}"
|
|
||||||
else:
|
|
||||||
date_range = f"{start_date} -- present"
|
|
||||||
meta_parts.append(date_range)
|
|
||||||
meta = " | ".join(meta_parts)
|
|
||||||
if meta:
|
|
||||||
pdf.set_xy(MARGIN_L, y)
|
|
||||||
pdf.set_font("DejaVu", "", 8.8)
|
|
||||||
pdf.set_text_color(*GRAY)
|
|
||||||
pdf.multi_cell(CONTENT_W, 4.5, meta)
|
|
||||||
y = pdf.get_y() + 1
|
|
||||||
|
|
||||||
# Bullets
|
|
||||||
for bullet in bullets:
|
|
||||||
if pdf.get_y() > PAGE_H - 15:
|
|
||||||
pdf.add_page()
|
|
||||||
pdf.set_x(MARGIN_L)
|
|
||||||
pdf.set_font("DejaVu", "", 9)
|
|
||||||
pdf.set_text_color(*DARK)
|
|
||||||
pdf.multi_cell(CONTENT_W, 4.5, f"- {bullet}")
|
|
||||||
pdf.ln(0.3)
|
|
||||||
|
|
||||||
y = pdf.get_y() + 2
|
|
||||||
pdf.set_xy(MARGIN_L, y)
|
|
||||||
|
|
||||||
return pdf.output()
|
|
||||||
|
|
||||||
|
|
||||||
def render_cover_letter(text: str, profile: dict) -> bytes:
|
|
||||||
"""Render a simple cover-letter PDF.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text: the cover letter body text.
|
|
||||||
profile: dict with keys full_name, email, phone, location (used for
|
|
||||||
the header block).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Raw PDF bytes.
|
|
||||||
"""
|
|
||||||
pdf = FPDF(format=(PAGE_W, PAGE_H))
|
|
||||||
_load_font(pdf)
|
|
||||||
pdf.set_auto_page_break(True, margin=MARGIN_B)
|
|
||||||
pdf.add_page()
|
|
||||||
|
|
||||||
# Sender header
|
|
||||||
pdf.set_xy(MARGIN_L, MARGIN_T)
|
|
||||||
pdf.set_font("DejaVu", "B", 11)
|
|
||||||
pdf.set_text_color(*NAVY)
|
|
||||||
pdf.multi_cell(CONTENT_W, 5.5, str(profile.get("full_name", "")))
|
|
||||||
y = pdf.get_y() + 1
|
|
||||||
pdf.set_xy(MARGIN_L, y)
|
|
||||||
pdf.set_font("DejaVu", "", 9.5)
|
|
||||||
pdf.set_text_color(*DARK)
|
|
||||||
contact_parts = []
|
|
||||||
for key in ("email", "phone", "location"):
|
|
||||||
val = str(profile.get(key, "") or "")
|
|
||||||
if val:
|
|
||||||
contact_parts.append(val)
|
|
||||||
contact = " | ".join(contact_parts)
|
|
||||||
if contact:
|
|
||||||
pdf.multi_cell(CONTENT_W, 5, contact)
|
|
||||||
y = pdf.get_y() + 4
|
|
||||||
else:
|
|
||||||
y = pdf.get_y() + 4
|
|
||||||
|
|
||||||
# Separator line
|
|
||||||
pdf.set_draw_color(*NAVY)
|
|
||||||
pdf.set_line_width(0.4)
|
|
||||||
pdf.line(MARGIN_L, y, MARGIN_L + CONTENT_W, y)
|
|
||||||
y += 6
|
|
||||||
|
|
||||||
# Body text
|
|
||||||
pdf.set_xy(MARGIN_L, y)
|
|
||||||
pdf.set_font("DejaVu", "", 10.5)
|
|
||||||
pdf.set_text_color(*DARK)
|
|
||||||
pdf.multi_cell(CONTENT_W, 5.5, text)
|
|
||||||
|
|
||||||
return pdf.output()
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
"""Utility helpers: hashing and versioning."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
|
|
||||||
|
|
||||||
def hash_bytes(b: bytes) -> str:
|
|
||||||
"""Return the sha256 hex digest of *b*."""
|
|
||||||
return hashlib.sha256(b).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def next_version(existing: list[int]) -> int:
|
|
||||||
"""Return the next version number given a list of existing versions.
|
|
||||||
|
|
||||||
>>> next_version([])
|
|
||||||
1
|
|
||||||
>>> next_version([1, 2, 3])
|
|
||||||
4
|
|
||||||
>>> next_version([1, 3])
|
|
||||||
4
|
|
||||||
"""
|
|
||||||
if not existing:
|
|
||||||
return 1
|
|
||||||
return max(existing) + 1
|
|
||||||
|
|
@ -1,146 +0,0 @@
|
||||||
"""Tests for the artifacts package."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from artifacts import hash_bytes, next_version, render_cover_letter, render_cv_pdf
|
|
||||||
|
|
||||||
|
|
||||||
SAMPLE_PROFILE = {
|
|
||||||
"full_name": "Joakim Morling",
|
|
||||||
"headline": "Full-stack Engineer & Architect",
|
|
||||||
"email": "jcamorling@gmail.com",
|
|
||||||
"phone": "+46 76 006 7335",
|
|
||||||
"location": "Malmo, Sweden",
|
|
||||||
"summary": (
|
|
||||||
"Full-stack engineer with a passion for building robust systems. "
|
|
||||||
"Experienced in government platforms and embedded ML. "
|
|
||||||
"Swedish characters: å ä ö Å Ä Ö are important."
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
SAMPLE_SECTIONS = [
|
|
||||||
{
|
|
||||||
"kind": "experience",
|
|
||||||
"title": "Co-founder & Partner",
|
|
||||||
"org": "Pro Firmitas ApS",
|
|
||||||
"location": "Copenhagen, DK",
|
|
||||||
"start_date": "2026-02",
|
|
||||||
"end_date": None,
|
|
||||||
"bullets": [
|
|
||||||
"Co-founded a consulting company building AI-enhanced software",
|
|
||||||
"Responsible for technical architecture and client delivery",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "education",
|
|
||||||
"title": "M.Sc. Computer Science & Engineering",
|
|
||||||
"org": "Lund University (LTH)",
|
|
||||||
"location": "Lund, SE",
|
|
||||||
"start_date": "2018",
|
|
||||||
"end_date": "2023",
|
|
||||||
"bullets": [
|
|
||||||
"Master Thesis: optimized DBSCAN clustering for radar processing",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
SWEDISH_PROFILE = {
|
|
||||||
"full_name": "Åke Öberg",
|
|
||||||
"headline": "Mjukvaruingenjör - Full Stack",
|
|
||||||
"email": "ake.oberg@example.se",
|
|
||||||
"phone": "+46 70 123 4567",
|
|
||||||
"location": "Malmö, Sverige",
|
|
||||||
"summary": "Erfaren utvecklare med fokus på å ä ö Å Ä Ö i alla texter.",
|
|
||||||
}
|
|
||||||
|
|
||||||
SWEDISH_SECTIONS = [
|
|
||||||
{
|
|
||||||
"kind": "experience",
|
|
||||||
"title": "Senior Utvecklare",
|
|
||||||
"org": "Företag AB",
|
|
||||||
"location": "Göteborg",
|
|
||||||
"start_date": "2020",
|
|
||||||
"end_date": None,
|
|
||||||
"bullets": [
|
|
||||||
"Byggde system med ångervektor och översättningsmotor",
|
|
||||||
"Ansvarig för säkerhetsgranskning av ärendehantering",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class TestRenderCvPdf:
|
|
||||||
def test_returns_valid_pdf_bytes(self) -> None:
|
|
||||||
result = render_cv_pdf(SAMPLE_PROFILE, SAMPLE_SECTIONS)
|
|
||||||
assert isinstance(result, (bytes, bytearray))
|
|
||||||
assert bytes(result).startswith(b"%PDF")
|
|
||||||
|
|
||||||
def test_swedish_characters_render(self) -> None:
|
|
||||||
"""Swedish characters must not raise an encoding error."""
|
|
||||||
result = render_cv_pdf(SWEDISH_PROFILE, SWEDISH_SECTIONS)
|
|
||||||
assert bytes(result).startswith(b"%PDF")
|
|
||||||
|
|
||||||
def test_content_hash_stable(self) -> None:
|
|
||||||
"""Same input should produce the same content hash."""
|
|
||||||
result_a = render_cv_pdf(SAMPLE_PROFILE, SAMPLE_SECTIONS)
|
|
||||||
result_b = render_cv_pdf(SAMPLE_PROFILE, SAMPLE_SECTIONS)
|
|
||||||
assert hash_bytes(bytes(result_a)) == hash_bytes(bytes(result_b))
|
|
||||||
|
|
||||||
def test_different_input_different_hash(self) -> None:
|
|
||||||
result_a = render_cv_pdf(SAMPLE_PROFILE, SAMPLE_SECTIONS)
|
|
||||||
result_b = render_cv_pdf(SWEDISH_PROFILE, SWEDISH_SECTIONS)
|
|
||||||
assert hash_bytes(bytes(result_a)) != hash_bytes(bytes(result_b))
|
|
||||||
|
|
||||||
def test_empty_sections(self) -> None:
|
|
||||||
result = render_cv_pdf(SAMPLE_PROFILE, [])
|
|
||||||
assert bytes(result).startswith(b"%PDF")
|
|
||||||
|
|
||||||
def test_minimal_profile(self) -> None:
|
|
||||||
result = render_cv_pdf({"full_name": "Test Person"}, [])
|
|
||||||
assert bytes(result).startswith(b"%PDF")
|
|
||||||
|
|
||||||
|
|
||||||
class TestRenderCoverLetter:
|
|
||||||
def test_returns_valid_pdf_bytes(self) -> None:
|
|
||||||
result = render_cover_letter("Dear Hiring Manager,\n\nI am applying...", SAMPLE_PROFILE)
|
|
||||||
assert bytes(result).startswith(b"%PDF")
|
|
||||||
|
|
||||||
def test_swedish_text(self) -> None:
|
|
||||||
text = "Hej! Jag söker jobbet. Mvh Åke Öberg."
|
|
||||||
result = render_cover_letter(text, SWEDISH_PROFILE)
|
|
||||||
assert bytes(result).startswith(b"%PDF")
|
|
||||||
|
|
||||||
def test_content_hash_stable(self) -> None:
|
|
||||||
text = "Cover letter body text."
|
|
||||||
result_a = render_cover_letter(text, SAMPLE_PROFILE)
|
|
||||||
result_b = render_cover_letter(text, SAMPLE_PROFILE)
|
|
||||||
assert hash_bytes(bytes(result_a)) == hash_bytes(bytes(result_b))
|
|
||||||
|
|
||||||
|
|
||||||
class TestHashBytes:
|
|
||||||
def test_known_value(self) -> None:
|
|
||||||
assert hash_bytes(b"hello") == (
|
|
||||||
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_empty_bytes(self) -> None:
|
|
||||||
assert hash_bytes(b"") == (
|
|
||||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_different_input_different_hash(self) -> None:
|
|
||||||
assert hash_bytes(b"a") != hash_bytes(b"b")
|
|
||||||
|
|
||||||
|
|
||||||
class TestNextVersion:
|
|
||||||
def test_empty_list(self) -> None:
|
|
||||||
assert next_version([]) == 1
|
|
||||||
|
|
||||||
def test_sequential(self) -> None:
|
|
||||||
assert next_version([1, 2, 3]) == 4
|
|
||||||
|
|
||||||
def test_gaps(self) -> None:
|
|
||||||
assert next_version([1, 3]) == 4
|
|
||||||
|
|
||||||
def test_single(self) -> None:
|
|
||||||
assert next_version([5]) == 6
|
|
||||||
0
packages/llm-gateway/.gitkeep
Normal file
0
packages/llm-gateway/.gitkeep
Normal file
|
|
@ -1,27 +0,0 @@
|
||||||
[project]
|
|
||||||
name = "llm-gateway"
|
|
||||||
version = "0.1.0"
|
|
||||||
description = "Async LLM gateway with mock mode, per-task budgets, telemetry, and retry/fallback."
|
|
||||||
requires-python = ">=3.13"
|
|
||||||
dependencies = [
|
|
||||||
"httpx>=0.27",
|
|
||||||
"jsonschema>=4.23",
|
|
||||||
]
|
|
||||||
|
|
||||||
[project.optional-dependencies]
|
|
||||||
dev = [
|
|
||||||
"pytest>=8.0",
|
|
||||||
"pytest-asyncio>=0.24",
|
|
||||||
]
|
|
||||||
|
|
||||||
[build-system]
|
|
||||||
requires = ["hatchling"]
|
|
||||||
build-backend = "hatchling.build"
|
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
|
||||||
packages = ["src/llm_gateway"]
|
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
|
||||||
testpaths = ["tests"]
|
|
||||||
pythonpath = ["src"]
|
|
||||||
asyncio_mode = "auto"
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
"""LLM Gateway package.
|
|
||||||
|
|
||||||
Async-first client with provider config from env, mock mode when no key is set,
|
|
||||||
per-task token budgets, telemetry sink, and retry/fallback policy.
|
|
||||||
|
|
||||||
Public API:
|
|
||||||
Gateway: main async gateway class.
|
|
||||||
GatewayConfig: provider + budget configuration.
|
|
||||||
TelemetryRow: telemetry record dataclass.
|
|
||||||
BudgetExceeded: raised when a task would exceed its token budget.
|
|
||||||
run_task: convenience function using default config.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from llm_gateway.config import GatewayConfig, TaskClass
|
|
||||||
from llm_gateway.exceptions import BudgetExceeded, GatewayError, SchemaValidationError
|
|
||||||
from llm_gateway.gateway import Gateway, TelemetryRow, run_task
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"Gateway",
|
|
||||||
"GatewayConfig",
|
|
||||||
"TaskClass",
|
|
||||||
"TelemetryRow",
|
|
||||||
"BudgetExceeded",
|
|
||||||
"GatewayError",
|
|
||||||
"SchemaValidationError",
|
|
||||||
"run_task",
|
|
||||||
]
|
|
||||||
|
|
@ -1,218 +0,0 @@
|
||||||
"""Configuration for the LLM gateway.
|
|
||||||
|
|
||||||
Provider config from env. Cheap task classes must never fall back to a paid
|
|
||||||
provider. Budgets are per-task max output tokens, configurable via env.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import enum
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class TaskClass(str, enum.Enum):
|
|
||||||
"""Task classification for model routing.
|
|
||||||
|
|
||||||
CHEAP tasks (score, extract) use the cheap model and must never fall back
|
|
||||||
to a paid provider. STRONG tasks (critique, prose review) may use fallback.
|
|
||||||
"""
|
|
||||||
|
|
||||||
CHEAP = "cheap"
|
|
||||||
STRONG = "strong"
|
|
||||||
|
|
||||||
|
|
||||||
# Map task names to task classes for routing.
|
|
||||||
TASK_CLASS_MAP: dict[str, TaskClass] = {
|
|
||||||
"score": TaskClass.CHEAP,
|
|
||||||
"extract": TaskClass.CHEAP,
|
|
||||||
"cv_assist": TaskClass.CHEAP,
|
|
||||||
"cl_critique": TaskClass.STRONG,
|
|
||||||
"critique": TaskClass.STRONG,
|
|
||||||
"research": TaskClass.STRONG,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Default budgets (max output tokens) per task name.
|
|
||||||
DEFAULT_BUDGETS: dict[str, int] = {
|
|
||||||
"score": 2000,
|
|
||||||
"extract": 4000,
|
|
||||||
"cv_assist": 2000,
|
|
||||||
"cl_critique": 4000,
|
|
||||||
"critique": 6000,
|
|
||||||
"research": 4000,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ProviderConfig:
|
|
||||||
"""Configuration for a single LLM provider."""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
base_url: str
|
|
||||||
api_key: str
|
|
||||||
model: str
|
|
||||||
is_paid: bool = False
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_configured(self) -> bool:
|
|
||||||
"""True if this provider has a non-empty API key."""
|
|
||||||
return bool(self.api_key and self.api_key.strip())
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class GatewayConfig:
|
|
||||||
"""Full gateway configuration loaded from environment.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
primary: primary provider config.
|
|
||||||
fallback: optional fallback provider config (None if not configured).
|
|
||||||
cheap_model: model name for cheap task classes.
|
|
||||||
strong_model: model name for strong task classes.
|
|
||||||
budgets: dict mapping task name to max output tokens.
|
|
||||||
max_retries: max retries on 429/5xx before fallback.
|
|
||||||
"""
|
|
||||||
|
|
||||||
primary: ProviderConfig
|
|
||||||
fallback: ProviderConfig | None = None
|
|
||||||
cheap_model: str = "glm-5.2"
|
|
||||||
strong_model: str = "glm-5.2"
|
|
||||||
budgets: dict[str, int] = field(default_factory=lambda: dict(DEFAULT_BUDGETS))
|
|
||||||
max_retries: int = 2
|
|
||||||
|
|
||||||
@property
|
|
||||||
def mock_mode(self) -> bool:
|
|
||||||
"""True when no primary provider key is configured."""
|
|
||||||
return not self.primary.is_configured
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_env(cls, env: dict[str, str] | None = None) -> GatewayConfig:
|
|
||||||
"""Load configuration from environment variables.
|
|
||||||
|
|
||||||
Env vars:
|
|
||||||
LLM_PRIMARY_BASE_URL, LLM_PRIMARY_KEY (or OLLAMA_API_KEY),
|
|
||||||
LLM_PRIMARY_MODEL (default glm-5.2)
|
|
||||||
LLM_FALLBACK_BASE_URL, LLM_FALLBACK_KEY, LLM_FALLBACK_MODEL
|
|
||||||
LLM_CHEAP_MODEL (default glm-5.2)
|
|
||||||
LLM_STRONG_MODEL (default glm-5.2)
|
|
||||||
LLM_BUDGET_{TASK} (per-task budget overrides)
|
|
||||||
LLM_MAX_RETRIES (default 2)
|
|
||||||
|
|
||||||
A warning is logged (and in strict mode, ValueError raised) if a
|
|
||||||
paid fallback provider is configured while cheap task classes would
|
|
||||||
use it. The fallback is only used for STRONG tasks.
|
|
||||||
"""
|
|
||||||
e = env if env is not None else os.environ
|
|
||||||
|
|
||||||
primary_key = e.get("LLM_PRIMARY_KEY", "") or e.get("OLLAMA_API_KEY", "")
|
|
||||||
primary = ProviderConfig(
|
|
||||||
name="primary",
|
|
||||||
base_url=e.get("LLM_PRIMARY_BASE_URL", "https://api.ollama-cloud.com/v1"),
|
|
||||||
api_key=primary_key,
|
|
||||||
model=e.get("LLM_PRIMARY_MODEL", "glm-5.2"),
|
|
||||||
is_paid=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
fallback: ProviderConfig | None = None
|
|
||||||
fb_key = e.get("LLM_FALLBACK_KEY", "")
|
|
||||||
fb_url = e.get("LLM_FALLBACK_BASE_URL", "")
|
|
||||||
if fb_key and fb_url:
|
|
||||||
fallback = ProviderConfig(
|
|
||||||
name="fallback",
|
|
||||||
base_url=fb_url,
|
|
||||||
api_key=fb_key,
|
|
||||||
model=e.get("LLM_FALLBACK_MODEL", "glm-5.2"),
|
|
||||||
# Heuristic: if the base URL contains known paid provider hints,
|
|
||||||
# mark as paid.
|
|
||||||
is_paid=_detect_paid_provider(fb_url),
|
|
||||||
)
|
|
||||||
if fallback.is_paid:
|
|
||||||
logger.warning(
|
|
||||||
"Fallback provider appears to be a paid provider (%s). "
|
|
||||||
"Cheap task classes will NOT use this fallback.",
|
|
||||||
fb_url,
|
|
||||||
)
|
|
||||||
|
|
||||||
cheap_model = e.get("LLM_CHEAP_MODEL", "glm-5.2")
|
|
||||||
strong_model = e.get("LLM_STRONG_MODEL", "glm-5.2")
|
|
||||||
|
|
||||||
budgets = dict(DEFAULT_BUDGETS)
|
|
||||||
for task_name in list(budgets.keys()):
|
|
||||||
env_val = e.get(f"LLM_BUDGET_{task_name.upper()}")
|
|
||||||
if env_val:
|
|
||||||
try:
|
|
||||||
budgets[task_name] = int(env_val)
|
|
||||||
except ValueError:
|
|
||||||
logger.warning("Invalid budget value for %s: %s", task_name, env_val)
|
|
||||||
|
|
||||||
# Also pick up any LLM_BUDGET_* not in defaults.
|
|
||||||
for key, val in e.items():
|
|
||||||
if key.startswith("LLM_BUDGET_") and val:
|
|
||||||
task = key[len("LLM_BUDGET_"):].lower()
|
|
||||||
if task not in budgets:
|
|
||||||
try:
|
|
||||||
budgets[task] = int(val)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
max_retries_str = e.get("LLM_MAX_RETRIES", "2")
|
|
||||||
try:
|
|
||||||
max_retries = int(max_retries_str)
|
|
||||||
except ValueError:
|
|
||||||
max_retries = 2
|
|
||||||
|
|
||||||
return cls(
|
|
||||||
primary=primary,
|
|
||||||
fallback=fallback,
|
|
||||||
cheap_model=cheap_model,
|
|
||||||
strong_model=strong_model,
|
|
||||||
budgets=budgets,
|
|
||||||
max_retries=max_retries,
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_budget(self, task: str) -> int:
|
|
||||||
"""Return the max output token budget for *task*.
|
|
||||||
|
|
||||||
Falls back to LLM_BUDGET_DEFAULT or 4000 if task-specific budget
|
|
||||||
is not set.
|
|
||||||
"""
|
|
||||||
return self.budgets.get(task, self.budgets.get("default", 4000))
|
|
||||||
|
|
||||||
def get_model(self, task: str) -> str:
|
|
||||||
"""Return the model name for *task* based on its task class."""
|
|
||||||
task_class = TASK_CLASS_MAP.get(task, TaskClass.STRONG)
|
|
||||||
if task_class == TaskClass.CHEAP:
|
|
||||||
return self.cheap_model
|
|
||||||
return self.strong_model
|
|
||||||
|
|
||||||
def get_task_class(self, task: str) -> TaskClass:
|
|
||||||
"""Return the task class for *task*."""
|
|
||||||
return TASK_CLASS_MAP.get(task, TaskClass.STRONG)
|
|
||||||
|
|
||||||
def assert_no_paid_fallback_for_cheap(self) -> None:
|
|
||||||
"""Assert that no paid fallback is configured for cheap task classes.
|
|
||||||
|
|
||||||
This is called during config validation. If a paid fallback exists,
|
|
||||||
it is allowed for STRONG tasks but must never be used for CHEAP tasks.
|
|
||||||
The gateway enforces this in _select_provider, but we also check here.
|
|
||||||
"""
|
|
||||||
if self.fallback and self.fallback.is_paid:
|
|
||||||
# This is allowed as long as cheap tasks never use fallback.
|
|
||||||
# We log a warning; the gateway itself prevents the routing.
|
|
||||||
logger.info(
|
|
||||||
"Paid fallback configured but will not be used for cheap tasks."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _detect_paid_provider(base_url: str) -> bool:
|
|
||||||
"""Heuristic: detect if a base URL points to a known paid provider."""
|
|
||||||
url_lower = base_url.lower()
|
|
||||||
paid_hints = [
|
|
||||||
"openai.com",
|
|
||||||
"anthropic.com",
|
|
||||||
"api.openai.com",
|
|
||||||
"api.anthropic.com",
|
|
||||||
]
|
|
||||||
return any(hint in url_lower for hint in paid_hints)
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
"""Exceptions for the LLM gateway."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
|
|
||||||
class GatewayError(Exception):
|
|
||||||
"""Base exception for LLM gateway errors."""
|
|
||||||
|
|
||||||
|
|
||||||
class BudgetExceeded(GatewayError):
|
|
||||||
"""Raised when a task would exceed its configured token budget.
|
|
||||||
|
|
||||||
This is raised BEFORE any provider call is made.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class SchemaValidationError(GatewayError):
|
|
||||||
"""Raised when the LLM output does not validate against the schema."""
|
|
||||||
|
|
||||||
|
|
||||||
class ProviderError(GatewayError):
|
|
||||||
"""Raised when a provider call fails after all retries and fallbacks."""
|
|
||||||
|
|
@ -1,388 +0,0 @@
|
||||||
"""Main gateway module: async-first LLM client with retry, fallback, budget guard, and telemetry."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any, Awaitable, Callable
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from jsonschema import validate as jsonschema_validate
|
|
||||||
from jsonschema.exceptions import ValidationError as JsonSchemaValidationError
|
|
||||||
|
|
||||||
from llm_gateway.config import GatewayConfig, TaskClass
|
|
||||||
from llm_gateway.exceptions import (
|
|
||||||
BudgetExceeded,
|
|
||||||
GatewayError,
|
|
||||||
ProviderError,
|
|
||||||
SchemaValidationError,
|
|
||||||
)
|
|
||||||
from llm_gateway.mock import get_mock_output, mock_telemetry_row
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Type alias for the telemetry sink: an async callable that receives a
|
|
||||||
# TelemetryRow (or a dict for simple sinks).
|
|
||||||
TelemetrySink = Callable[["TelemetryRow"], Awaitable[None] | None]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class TelemetryRow:
|
|
||||||
"""A single telemetry record for one LLM call.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
id: unique run id.
|
|
||||||
task: task name (e.g. 'score', 'extract').
|
|
||||||
model: model name used.
|
|
||||||
provider: provider name ('primary', 'fallback', 'mock').
|
|
||||||
input_tokens: tokens consumed on input.
|
|
||||||
output_tokens: tokens consumed on output.
|
|
||||||
cost_usd: estimated cost in USD (None if not configured).
|
|
||||||
duration_ms: wall-clock duration in milliseconds.
|
|
||||||
application_id: optional application id for correlation.
|
|
||||||
mock: True if this was a mock-mode call.
|
|
||||||
"""
|
|
||||||
|
|
||||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
||||||
task: str = ""
|
|
||||||
model: str = ""
|
|
||||||
provider: str = ""
|
|
||||||
input_tokens: int = 0
|
|
||||||
output_tokens: int = 0
|
|
||||||
cost_usd: float | None = None
|
|
||||||
duration_ms: int = 0
|
|
||||||
application_id: str | None = None
|
|
||||||
mock: bool = False
|
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
|
||||||
"""Serialize to dict for sinks that accept plain dicts."""
|
|
||||||
return {
|
|
||||||
"id": self.id,
|
|
||||||
"task": self.task,
|
|
||||||
"model": self.model,
|
|
||||||
"provider": self.provider,
|
|
||||||
"input_tokens": self.input_tokens,
|
|
||||||
"output_tokens": self.output_tokens,
|
|
||||||
"cost_usd": self.cost_usd,
|
|
||||||
"duration_ms": self.duration_ms,
|
|
||||||
"application_id": self.application_id,
|
|
||||||
"mock": self.mock,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class Gateway:
|
|
||||||
"""Async-first LLM gateway.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
config = GatewayConfig.from_env()
|
|
||||||
gw = Gateway(config)
|
|
||||||
result = await gw.run_task("score", "Score this job vs profile: ...")
|
|
||||||
|
|
||||||
When no API key is configured (mock mode), returns deterministic canned
|
|
||||||
outputs without making any network calls.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
config: GatewayConfig,
|
|
||||||
telemetry_sink: TelemetrySink | None = None,
|
|
||||||
http_client: httpx.AsyncClient | None = None,
|
|
||||||
) -> None:
|
|
||||||
self.config = config
|
|
||||||
self._telemetry_sink = telemetry_sink
|
|
||||||
self._http_client = http_client
|
|
||||||
self._owns_http_client = http_client is None
|
|
||||||
|
|
||||||
async def _get_http_client(self) -> httpx.AsyncClient:
|
|
||||||
if self._http_client is None:
|
|
||||||
self._http_client = httpx.AsyncClient(timeout=30.0)
|
|
||||||
return self._http_client
|
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
|
||||||
"""Close the HTTP client if we own it."""
|
|
||||||
if self._owns_http_client and self._http_client is not None:
|
|
||||||
await self._http_client.aclose()
|
|
||||||
self._http_client = None
|
|
||||||
|
|
||||||
async def __aenter__(self) -> Gateway:
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, *args: Any) -> None:
|
|
||||||
await self.aclose()
|
|
||||||
|
|
||||||
def _select_provider(self, task: str) -> Any:
|
|
||||||
"""Select the provider config for *task*.
|
|
||||||
|
|
||||||
Cheap tasks always use the primary provider (never fallback to paid).
|
|
||||||
Strong tasks may use fallback if primary fails.
|
|
||||||
"""
|
|
||||||
task_class = self.config.get_task_class(task)
|
|
||||||
# Cheap tasks: primary only, never fallback (especially not paid).
|
|
||||||
if task_class == TaskClass.CHEAP:
|
|
||||||
return self.config.primary
|
|
||||||
# Strong tasks: primary, with fallback available.
|
|
||||||
return self.config.primary
|
|
||||||
|
|
||||||
def _check_budget(self, task: str, prompt: str) -> None:
|
|
||||||
"""Check if the estimated token usage would exceed the budget.
|
|
||||||
|
|
||||||
Raises BudgetExceeded BEFORE any provider call is made.
|
|
||||||
We estimate input tokens as len(prompt) // 4 (rough heuristic) and
|
|
||||||
add the max output token budget. If the estimated total exceeds
|
|
||||||
the budget, we raise.
|
|
||||||
"""
|
|
||||||
budget = self.config.get_budget(task)
|
|
||||||
# Rough input token estimate: ~4 chars per token.
|
|
||||||
estimated_input_tokens = len(prompt) // 4
|
|
||||||
# If input alone exceeds budget, that is over-budget.
|
|
||||||
if estimated_input_tokens > budget:
|
|
||||||
raise BudgetExceeded(
|
|
||||||
f"Task '{task}' estimated input tokens ({estimated_input_tokens}) "
|
|
||||||
f"exceed budget ({budget}). Call aborted before provider request."
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _write_telemetry(self, row: TelemetryRow) -> None:
|
|
||||||
"""Send telemetry to the sink if one is configured."""
|
|
||||||
if self._telemetry_sink is None:
|
|
||||||
return
|
|
||||||
result = self._telemetry_sink(row)
|
|
||||||
if asyncio.iscoroutine(result):
|
|
||||||
await result
|
|
||||||
|
|
||||||
async def _call_provider(
|
|
||||||
self,
|
|
||||||
provider_config: Any,
|
|
||||||
task: str,
|
|
||||||
prompt: str,
|
|
||||||
model: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Make an async HTTP call to the provider's chat completions endpoint.
|
|
||||||
|
|
||||||
Returns the raw JSON response dict.
|
|
||||||
"""
|
|
||||||
client = await self._get_http_client()
|
|
||||||
url = f"{provider_config.base_url.rstrip('/')}/chat/completions"
|
|
||||||
headers = {
|
|
||||||
"Authorization": f"Bearer {provider_config.api_key}",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
|
||||||
body: dict[str, Any] = {
|
|
||||||
"model": model,
|
|
||||||
"messages": [{"role": "user", "content": prompt}],
|
|
||||||
"max_tokens": self.config.get_budget(task),
|
|
||||||
}
|
|
||||||
# If a schema is expected, request JSON format.
|
|
||||||
body["response_format"] = {"type": "json_object"}
|
|
||||||
|
|
||||||
response = await client.post(url, json=body, headers=headers)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
# Extract the content from the response.
|
|
||||||
choices = data.get("choices", [])
|
|
||||||
if not choices:
|
|
||||||
raise ProviderError(f"Provider returned no choices for task '{task}'")
|
|
||||||
content = choices[0].get("message", {}).get("content", "{}")
|
|
||||||
usage = data.get("usage", {})
|
|
||||||
|
|
||||||
try:
|
|
||||||
parsed = json.loads(content)
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
raise ProviderError(
|
|
||||||
f"Provider returned non-JSON content for task '{task}': {exc}"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
# Attach usage info for telemetry.
|
|
||||||
parsed["_usage"] = {
|
|
||||||
"input_tokens": usage.get("prompt_tokens", 0),
|
|
||||||
"output_tokens": usage.get("completion_tokens", 0),
|
|
||||||
"model": data.get("model", model),
|
|
||||||
"provider": provider_config.name,
|
|
||||||
}
|
|
||||||
return parsed
|
|
||||||
|
|
||||||
async def _call_with_retry(
|
|
||||||
self,
|
|
||||||
provider_config: Any,
|
|
||||||
task: str,
|
|
||||||
prompt: str,
|
|
||||||
model: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Call provider with retry policy: max retries on 429/5xx, then
|
|
||||||
fallback provider (for strong tasks only), then raise.
|
|
||||||
"""
|
|
||||||
last_exc: Exception | None = None
|
|
||||||
for attempt in range(self.config.max_retries + 1):
|
|
||||||
try:
|
|
||||||
return await self._call_provider(provider_config, task, prompt, model)
|
|
||||||
except httpx.HTTPStatusError as exc:
|
|
||||||
status = exc.response.status_code
|
|
||||||
if status == 429 or 500 <= status < 600:
|
|
||||||
last_exc = exc
|
|
||||||
logger.warning(
|
|
||||||
"Provider %s returned %d for task '%s' (attempt %d/%d)",
|
|
||||||
provider_config.name,
|
|
||||||
status,
|
|
||||||
task,
|
|
||||||
attempt + 1,
|
|
||||||
self.config.max_retries + 1,
|
|
||||||
)
|
|
||||||
if attempt < self.config.max_retries:
|
|
||||||
await asyncio.sleep(2 ** attempt)
|
|
||||||
continue
|
|
||||||
raise
|
|
||||||
except (httpx.RequestError, ProviderError) as exc:
|
|
||||||
last_exc = exc
|
|
||||||
logger.warning(
|
|
||||||
"Provider %s error for task '%s' (attempt %d/%d): %s",
|
|
||||||
provider_config.name,
|
|
||||||
task,
|
|
||||||
attempt + 1,
|
|
||||||
self.config.max_retries + 1,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
if attempt < self.config.max_retries:
|
|
||||||
await asyncio.sleep(2 ** attempt)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# All retries exhausted. Try fallback for strong tasks.
|
|
||||||
task_class = self.config.get_task_class(task)
|
|
||||||
if (
|
|
||||||
task_class == TaskClass.STRONG
|
|
||||||
and self.config.fallback
|
|
||||||
and self.config.fallback.is_configured
|
|
||||||
and self.config.fallback is not provider_config
|
|
||||||
):
|
|
||||||
logger.info("Falling back to %s for task '%s'", self.config.fallback.name, task)
|
|
||||||
try:
|
|
||||||
return await self._call_provider(
|
|
||||||
self.config.fallback, task, prompt, self.config.strong_model
|
|
||||||
)
|
|
||||||
except Exception as fallback_exc:
|
|
||||||
raise ProviderError(
|
|
||||||
f"Both primary and fallback providers failed for task '{task}': "
|
|
||||||
f"primary={last_exc}, fallback={fallback_exc}"
|
|
||||||
) from fallback_exc
|
|
||||||
|
|
||||||
raise ProviderError(
|
|
||||||
f"Provider call failed for task '{task}' after {self.config.max_retries + 1} attempts: {last_exc}"
|
|
||||||
) from last_exc
|
|
||||||
|
|
||||||
async def run_task(
|
|
||||||
self,
|
|
||||||
task: str,
|
|
||||||
prompt: str,
|
|
||||||
schema: dict | None = None,
|
|
||||||
application_id: str | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Run an LLM task and return the parsed result.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
task: task name (e.g. 'score', 'extract', 'cv_assist', 'cl_critique').
|
|
||||||
prompt: the input prompt text.
|
|
||||||
schema: optional JSON schema to validate the output against.
|
|
||||||
application_id: optional application id for telemetry correlation.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Parsed JSON dict from the LLM.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
BudgetExceeded: if the estimated token usage exceeds the budget.
|
|
||||||
SchemaValidationError: if the output does not match the schema.
|
|
||||||
ProviderError: if the provider call fails after all retries.
|
|
||||||
"""
|
|
||||||
# Budget guard: raise BEFORE any call is made.
|
|
||||||
self._check_budget(task, prompt)
|
|
||||||
|
|
||||||
start = time.monotonic()
|
|
||||||
|
|
||||||
if self.config.mock_mode:
|
|
||||||
# Mock mode: return deterministic canned output.
|
|
||||||
result = get_mock_output(task)
|
|
||||||
duration_ms = int((time.monotonic() - start) * 1000)
|
|
||||||
model = self.config.get_model(task)
|
|
||||||
|
|
||||||
row = TelemetryRow(
|
|
||||||
task=task,
|
|
||||||
model=model,
|
|
||||||
provider="mock",
|
|
||||||
input_tokens=len(prompt) // 4,
|
|
||||||
output_tokens=0,
|
|
||||||
cost_usd=0.0,
|
|
||||||
duration_ms=duration_ms,
|
|
||||||
application_id=application_id,
|
|
||||||
mock=True,
|
|
||||||
)
|
|
||||||
await self._write_telemetry(row)
|
|
||||||
|
|
||||||
# Validate against schema if provided.
|
|
||||||
if schema is not None:
|
|
||||||
_validate_schema(result, schema)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
# Real mode: call provider with retry/fallback.
|
|
||||||
provider = self._select_provider(task)
|
|
||||||
model = self.config.get_model(task)
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await self._call_with_retry(provider, task, prompt, model)
|
|
||||||
except BudgetExceeded:
|
|
||||||
raise
|
|
||||||
except (ProviderError, SchemaValidationError):
|
|
||||||
raise
|
|
||||||
except Exception as exc:
|
|
||||||
raise ProviderError(f"Unexpected error for task '{task}': {exc}") from exc
|
|
||||||
|
|
||||||
duration_ms = int((time.monotonic() - start) * 1000)
|
|
||||||
usage = result.pop("_usage", {})
|
|
||||||
|
|
||||||
row = TelemetryRow(
|
|
||||||
task=task,
|
|
||||||
model=usage.get("model", model),
|
|
||||||
provider=usage.get("provider", provider.name),
|
|
||||||
input_tokens=usage.get("input_tokens", 0),
|
|
||||||
output_tokens=usage.get("output_tokens", 0),
|
|
||||||
cost_usd=None,
|
|
||||||
duration_ms=duration_ms,
|
|
||||||
application_id=application_id,
|
|
||||||
mock=False,
|
|
||||||
)
|
|
||||||
await self._write_telemetry(row)
|
|
||||||
|
|
||||||
# Validate against schema if provided.
|
|
||||||
if schema is not None:
|
|
||||||
_validate_schema(result, schema)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_schema(data: dict, schema: dict) -> None:
|
|
||||||
"""Validate *data* against *schema*. Raises SchemaValidationError on failure."""
|
|
||||||
try:
|
|
||||||
jsonschema_validate(data, schema)
|
|
||||||
except JsonSchemaValidationError as exc:
|
|
||||||
raise SchemaValidationError(f"Schema validation failed: {exc.message}") from exc
|
|
||||||
|
|
||||||
|
|
||||||
async def run_task(
|
|
||||||
task: str,
|
|
||||||
prompt: str,
|
|
||||||
schema: dict | None = None,
|
|
||||||
config: GatewayConfig | None = None,
|
|
||||||
telemetry_sink: TelemetrySink | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Convenience function: create a Gateway, run one task, close it.
|
|
||||||
|
|
||||||
Useful for one-off calls. For repeated calls, instantiate Gateway directly.
|
|
||||||
"""
|
|
||||||
cfg = config or GatewayConfig.from_env()
|
|
||||||
gw = Gateway(cfg, telemetry_sink=telemetry_sink)
|
|
||||||
try:
|
|
||||||
return await gw.run_task(task, prompt, schema=schema)
|
|
||||||
finally:
|
|
||||||
await gw.aclose()
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
"""Mock mode for the LLM gateway.
|
|
||||||
|
|
||||||
When no API key is configured, the gateway returns deterministic canned
|
|
||||||
outputs per task name. This allows the API and tests to run offline.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
# Deterministic canned outputs per task name.
|
|
||||||
# Each entry is a dict that will be returned as the task result.
|
|
||||||
MOCK_OUTPUTS: dict[str, dict] = {
|
|
||||||
"score": {
|
|
||||||
"score": 75,
|
|
||||||
"rationale": {
|
|
||||||
"match": "good",
|
|
||||||
"reasons": ["skills align", "location matches"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"extract": {
|
|
||||||
"company": "Example Corp",
|
|
||||||
"title": "Software Engineer",
|
|
||||||
"location": "Stockholm",
|
|
||||||
"requirements": ["Python", "PostgreSQL", "Docker"],
|
|
||||||
},
|
|
||||||
"cv_assist": {
|
|
||||||
"suggestions": [
|
|
||||||
"Led a team of 5 developers to deliver a critical integration",
|
|
||||||
"Reduced API latency by 40% through caching and query optimization",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"cl_critique": {
|
|
||||||
"comments": [
|
|
||||||
{
|
|
||||||
"quote": "I am a hard worker",
|
|
||||||
"suggestion": "Replace generic claim with a specific achievement metric",
|
|
||||||
"severity": "medium",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"quote": "Dear Sir/Madam",
|
|
||||||
"suggestion": "Address the hiring manager by name if known",
|
|
||||||
"severity": "low",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"critique": {
|
|
||||||
"comments": [
|
|
||||||
{
|
|
||||||
"quote": "sample text",
|
|
||||||
"suggestion": "improve clarity",
|
|
||||||
"severity": "low",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"research": {
|
|
||||||
"summary": "The company is a mid-size tech firm focused on cloud infrastructure.",
|
|
||||||
"key_points": ["Founded in 2015", "Series B funding", "Remote-first culture"],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
# Default mock output for unknown task names.
|
|
||||||
DEFAULT_MOCK_OUTPUT: dict = {
|
|
||||||
"result": "mock output",
|
|
||||||
"task": "unknown",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_mock_output(task: str) -> dict:
|
|
||||||
"""Return a deterministic mock output for *task*.
|
|
||||||
|
|
||||||
For unknown tasks, returns DEFAULT_MOCK_OUTPUT with the task name filled in.
|
|
||||||
"""
|
|
||||||
if task in MOCK_OUTPUTS:
|
|
||||||
# Return a copy so callers cannot mutate the canned data.
|
|
||||||
return json.loads(json.dumps(MOCK_OUTPUTS[task]))
|
|
||||||
result = json.loads(json.dumps(DEFAULT_MOCK_OUTPUT))
|
|
||||||
result["task"] = task
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def mock_telemetry_row(task: str, model: str) -> dict:
|
|
||||||
"""Build a mock telemetry row dict for offline mode."""
|
|
||||||
return {
|
|
||||||
"id": str(uuid.uuid4()),
|
|
||||||
"task": task,
|
|
||||||
"model": model,
|
|
||||||
"provider": "mock",
|
|
||||||
"input_tokens": 0,
|
|
||||||
"output_tokens": 0,
|
|
||||||
"cost_usd": 0.0,
|
|
||||||
"duration_ms": 0,
|
|
||||||
"mock": True,
|
|
||||||
}
|
|
||||||
|
|
@ -1,491 +0,0 @@
|
||||||
"""Tests for the LLM gateway package."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from llm_gateway.config import GatewayConfig, ProviderConfig, TaskClass
|
|
||||||
from llm_gateway.exceptions import BudgetExceeded, SchemaValidationError
|
|
||||||
from llm_gateway.gateway import Gateway, TelemetryRow, run_task
|
|
||||||
from llm_gateway.mock import get_mock_output, MOCK_OUTPUTS
|
|
||||||
|
|
||||||
|
|
||||||
# ---- Fixtures ----
|
|
||||||
|
|
||||||
|
|
||||||
def mock_config(**overrides: Any) -> GatewayConfig:
|
|
||||||
"""Build a config in mock mode (no API key)."""
|
|
||||||
primary = ProviderConfig(
|
|
||||||
name="primary",
|
|
||||||
base_url="https://mock.example.com/v1",
|
|
||||||
api_key="",
|
|
||||||
model="glm-5.2",
|
|
||||||
)
|
|
||||||
defaults: dict[str, Any] = {
|
|
||||||
"primary": primary,
|
|
||||||
"fallback": None,
|
|
||||||
"cheap_model": "glm-5.2",
|
|
||||||
"strong_model": "glm-5.2",
|
|
||||||
"budgets": {"score": 2000, "extract": 4000, "default": 4000},
|
|
||||||
"max_retries": 2,
|
|
||||||
}
|
|
||||||
defaults.update(overrides)
|
|
||||||
return GatewayConfig(**defaults)
|
|
||||||
|
|
||||||
|
|
||||||
def real_config(**overrides: Any) -> GatewayConfig:
|
|
||||||
"""Build a config with a fake API key (non-mock mode)."""
|
|
||||||
primary = ProviderConfig(
|
|
||||||
name="primary",
|
|
||||||
base_url="https://mock.example.com/v1",
|
|
||||||
api_key="fake-key-1234",
|
|
||||||
model="glm-5.2",
|
|
||||||
)
|
|
||||||
defaults: dict[str, Any] = {
|
|
||||||
"primary": primary,
|
|
||||||
"fallback": None,
|
|
||||||
"cheap_model": "glm-5.2",
|
|
||||||
"strong_model": "glm-5.2",
|
|
||||||
"budgets": {"score": 2000, "extract": 4000, "default": 4000},
|
|
||||||
"max_retries": 2,
|
|
||||||
}
|
|
||||||
defaults.update(overrides)
|
|
||||||
return GatewayConfig(**defaults)
|
|
||||||
|
|
||||||
|
|
||||||
# ---- Mock mode tests ----
|
|
||||||
|
|
||||||
|
|
||||||
class TestMockMode:
|
|
||||||
async def test_mock_returns_deterministic_output(self) -> None:
|
|
||||||
"""Mock mode returns deterministic canned outputs per task."""
|
|
||||||
config = mock_config()
|
|
||||||
gw = Gateway(config)
|
|
||||||
result_a = await gw.run_task("score", "Score this job")
|
|
||||||
result_b = await gw.run_task("score", "Score this job")
|
|
||||||
assert result_a == result_b
|
|
||||||
assert result_a["score"] == 75
|
|
||||||
await gw.aclose()
|
|
||||||
|
|
||||||
async def test_mock_different_tasks_different_output(self) -> None:
|
|
||||||
config = mock_config()
|
|
||||||
gw = Gateway(config)
|
|
||||||
score_result = await gw.run_task("score", "prompt")
|
|
||||||
extract_result = await gw.run_task("extract", "prompt")
|
|
||||||
assert score_result != extract_result
|
|
||||||
assert "score" in score_result
|
|
||||||
assert "company" in extract_result
|
|
||||||
await gw.aclose()
|
|
||||||
|
|
||||||
async def test_mock_unknown_task(self) -> None:
|
|
||||||
config = mock_config()
|
|
||||||
gw = Gateway(config)
|
|
||||||
result = await gw.run_task("unknown_task", "prompt")
|
|
||||||
assert result["result"] == "mock output"
|
|
||||||
assert result["task"] == "unknown_task"
|
|
||||||
await gw.aclose()
|
|
||||||
|
|
||||||
async def test_mock_mode_property(self) -> None:
|
|
||||||
config = mock_config()
|
|
||||||
assert config.mock_mode is True
|
|
||||||
|
|
||||||
async def test_real_mode_not_mock(self) -> None:
|
|
||||||
config = real_config()
|
|
||||||
assert config.mock_mode is False
|
|
||||||
|
|
||||||
|
|
||||||
# ---- Schema validation tests ----
|
|
||||||
|
|
||||||
|
|
||||||
class TestSchemaValidation:
|
|
||||||
async def test_schema_passes(self) -> None:
|
|
||||||
config = mock_config()
|
|
||||||
gw = Gateway(config)
|
|
||||||
schema = {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"score": {"type": "number"},
|
|
||||||
"rationale": {"type": "object"},
|
|
||||||
},
|
|
||||||
"required": ["score"],
|
|
||||||
}
|
|
||||||
result = await gw.run_task("score", "prompt", schema=schema)
|
|
||||||
assert "score" in result
|
|
||||||
await gw.aclose()
|
|
||||||
|
|
||||||
async def test_schema_fails(self) -> None:
|
|
||||||
"""Schema validation failure should raise SchemaValidationError."""
|
|
||||||
config = mock_config()
|
|
||||||
gw = Gateway(config)
|
|
||||||
# The mock output for 'score' has score=75 (number). We require a string,
|
|
||||||
# which should fail validation.
|
|
||||||
bad_schema = {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"score": {"type": "string"},
|
|
||||||
},
|
|
||||||
"required": ["score"],
|
|
||||||
}
|
|
||||||
with pytest.raises(SchemaValidationError):
|
|
||||||
await gw.run_task("score", "prompt", schema=bad_schema)
|
|
||||||
await gw.aclose()
|
|
||||||
|
|
||||||
async def test_schema_missing_required_field(self) -> None:
|
|
||||||
config = mock_config()
|
|
||||||
gw = Gateway(config)
|
|
||||||
schema = {
|
|
||||||
"type": "object",
|
|
||||||
"required": ["nonexistent_field"],
|
|
||||||
}
|
|
||||||
with pytest.raises(SchemaValidationError):
|
|
||||||
await gw.run_task("score", "prompt", schema=schema)
|
|
||||||
await gw.aclose()
|
|
||||||
|
|
||||||
|
|
||||||
# ---- Budget guard tests ----
|
|
||||||
|
|
||||||
|
|
||||||
class TestBudgetGuard:
|
|
||||||
async def test_budget_exceeded_raises_before_call(self) -> None:
|
|
||||||
"""Over-budget prompt should raise BudgetExceeded before any call."""
|
|
||||||
config = mock_config(budgets={"score": 10})
|
|
||||||
gw = Gateway(config)
|
|
||||||
# 10 token budget, ~4 chars/token, so >40 chars should exceed.
|
|
||||||
long_prompt = "x" * 100
|
|
||||||
with pytest.raises(BudgetExceeded):
|
|
||||||
await gw.run_task("score", long_prompt)
|
|
||||||
await gw.aclose()
|
|
||||||
|
|
||||||
async def test_budget_within_limit_does_not_raise(self) -> None:
|
|
||||||
config = mock_config(budgets={"score": 10000})
|
|
||||||
gw = Gateway(config)
|
|
||||||
result = await gw.run_task("score", "short prompt")
|
|
||||||
assert result["score"] == 75
|
|
||||||
await gw.aclose()
|
|
||||||
|
|
||||||
async def test_budget_guard_in_real_mode(self) -> None:
|
|
||||||
"""Budget guard must raise before call even in real (non-mock) mode."""
|
|
||||||
config = real_config(budgets={"score": 10})
|
|
||||||
gw = Gateway(config)
|
|
||||||
with pytest.raises(BudgetExceeded):
|
|
||||||
await gw.run_task("score", "x" * 100)
|
|
||||||
await gw.aclose()
|
|
||||||
|
|
||||||
async def test_default_budget_fallback(self) -> None:
|
|
||||||
"""Unknown task should use default budget."""
|
|
||||||
config = mock_config(budgets={"score": 2000, "default": 100})
|
|
||||||
gw = Gateway(config)
|
|
||||||
# Unknown task uses default=100, so >400 chars exceeds.
|
|
||||||
with pytest.raises(BudgetExceeded):
|
|
||||||
await gw.run_task("unknown_task", "x" * 500)
|
|
||||||
await gw.aclose()
|
|
||||||
|
|
||||||
|
|
||||||
# ---- Telemetry tests ----
|
|
||||||
|
|
||||||
|
|
||||||
class TestTelemetry:
|
|
||||||
async def test_telemetry_sink_called_in_mock_mode(self) -> None:
|
|
||||||
sink_calls: list[TelemetryRow] = []
|
|
||||||
|
|
||||||
async def sink(row: TelemetryRow) -> None:
|
|
||||||
sink_calls.append(row)
|
|
||||||
|
|
||||||
config = mock_config()
|
|
||||||
gw = Gateway(config, telemetry_sink=sink)
|
|
||||||
await gw.run_task("score", "test prompt")
|
|
||||||
assert len(sink_calls) == 1
|
|
||||||
assert sink_calls[0].task == "score"
|
|
||||||
assert sink_calls[0].mock is True
|
|
||||||
await gw.aclose()
|
|
||||||
|
|
||||||
async def test_telemetry_sink_sync_callable(self) -> None:
|
|
||||||
"""Sync sinks should also work (no await needed)."""
|
|
||||||
sink_calls: list[TelemetryRow] = []
|
|
||||||
|
|
||||||
def sync_sink(row: TelemetryRow) -> None:
|
|
||||||
sink_calls.append(row)
|
|
||||||
|
|
||||||
config = mock_config()
|
|
||||||
gw = Gateway(config, telemetry_sink=sync_sink)
|
|
||||||
await gw.run_task("score", "test prompt")
|
|
||||||
assert len(sink_calls) == 1
|
|
||||||
await gw.aclose()
|
|
||||||
|
|
||||||
async def test_no_sink_no_error(self) -> None:
|
|
||||||
config = mock_config()
|
|
||||||
gw = Gateway(config, telemetry_sink=None)
|
|
||||||
result = await gw.run_task("score", "prompt")
|
|
||||||
assert result["score"] == 75
|
|
||||||
await gw.aclose()
|
|
||||||
|
|
||||||
async def test_telemetry_row_to_dict(self) -> None:
|
|
||||||
row = TelemetryRow(task="score", model="glm-5.2", provider="mock")
|
|
||||||
d = row.to_dict()
|
|
||||||
assert d["task"] == "score"
|
|
||||||
assert d["model"] == "glm-5.2"
|
|
||||||
assert d["provider"] == "mock"
|
|
||||||
assert "id" in d
|
|
||||||
|
|
||||||
|
|
||||||
# ---- Config tests ----
|
|
||||||
|
|
||||||
|
|
||||||
class TestGatewayConfig:
|
|
||||||
def test_from_env_mock_mode(self) -> None:
|
|
||||||
env = {}
|
|
||||||
config = GatewayConfig.from_env(env=env)
|
|
||||||
assert config.mock_mode is True
|
|
||||||
assert config.primary.model == "glm-5.2"
|
|
||||||
|
|
||||||
def test_from_env_real_mode(self) -> None:
|
|
||||||
env = {
|
|
||||||
"LLM_PRIMARY_KEY": "test-key",
|
|
||||||
"LLM_PRIMARY_BASE_URL": "https://api.example.com/v1",
|
|
||||||
"LLM_PRIMARY_MODEL": "custom-model",
|
|
||||||
}
|
|
||||||
config = GatewayConfig.from_env(env=env)
|
|
||||||
assert config.mock_mode is False
|
|
||||||
assert config.primary.api_key == "test-key"
|
|
||||||
assert config.primary.model == "custom-model"
|
|
||||||
|
|
||||||
def test_from_env_ollama_key(self) -> None:
|
|
||||||
env = {"OLLAMA_API_KEY": "ollama-key-123"}
|
|
||||||
config = GatewayConfig.from_env(env=env)
|
|
||||||
assert config.mock_mode is False
|
|
||||||
assert config.primary.api_key == "ollama-key-123"
|
|
||||||
|
|
||||||
def test_from_env_budgets(self) -> None:
|
|
||||||
env = {"LLM_BUDGET_SCORE": "500"}
|
|
||||||
config = GatewayConfig.from_env(env=env)
|
|
||||||
assert config.budgets["score"] == 500
|
|
||||||
|
|
||||||
def test_task_class_routing(self) -> None:
|
|
||||||
config = mock_config()
|
|
||||||
assert config.get_task_class("score") == TaskClass.CHEAP
|
|
||||||
assert config.get_task_class("extract") == TaskClass.CHEAP
|
|
||||||
assert config.get_task_class("cv_assist") == TaskClass.CHEAP
|
|
||||||
assert config.get_task_class("critique") == TaskClass.STRONG
|
|
||||||
assert config.get_task_class("cl_critique") == TaskClass.STRONG
|
|
||||||
|
|
||||||
def test_get_model_routing(self) -> None:
|
|
||||||
config = mock_config(cheap_model="cheap-model", strong_model="strong-model")
|
|
||||||
assert config.get_model("score") == "cheap-model"
|
|
||||||
assert config.get_model("critique") == "strong-model"
|
|
||||||
|
|
||||||
def test_paid_fallback_detection(self) -> None:
|
|
||||||
"""Config should detect paid provider URLs."""
|
|
||||||
env = {
|
|
||||||
"LLM_PRIMARY_KEY": "key",
|
|
||||||
"LLM_FALLBACK_BASE_URL": "https://api.openai.com/v1",
|
|
||||||
"LLM_FALLBACK_KEY": "fb-key",
|
|
||||||
}
|
|
||||||
config = GatewayConfig.from_env(env=env)
|
|
||||||
assert config.fallback is not None
|
|
||||||
assert config.fallback.is_paid is True
|
|
||||||
|
|
||||||
def test_paid_fallback_not_used_for_cheap(self) -> None:
|
|
||||||
"""The gateway must not route cheap tasks to paid fallback.
|
|
||||||
|
|
||||||
We verify this by checking _select_provider returns primary for cheap.
|
|
||||||
"""
|
|
||||||
primary = ProviderConfig(
|
|
||||||
name="primary", base_url="https://a.com/v1", api_key="k", model="m"
|
|
||||||
)
|
|
||||||
fallback = ProviderConfig(
|
|
||||||
name="fallback",
|
|
||||||
base_url="https://api.openai.com/v1",
|
|
||||||
api_key="k2",
|
|
||||||
model="m2",
|
|
||||||
is_paid=True,
|
|
||||||
)
|
|
||||||
config = GatewayConfig(primary=primary, fallback=fallback)
|
|
||||||
gw = Gateway(config)
|
|
||||||
provider = gw._select_provider("score")
|
|
||||||
assert provider.name == "primary"
|
|
||||||
# The _select_provider method enforces this by always returning primary
|
|
||||||
# for cheap tasks, never the paid fallback.
|
|
||||||
|
|
||||||
def test_non_paid_fallback(self) -> None:
|
|
||||||
"""Non-paid (e.g. ollama) fallback is fine."""
|
|
||||||
env = {
|
|
||||||
"LLM_PRIMARY_KEY": "key",
|
|
||||||
"LLM_FALLBACK_BASE_URL": "https://api.ollama-cloud.com/v1",
|
|
||||||
"LLM_FALLBACK_KEY": "fb-key",
|
|
||||||
}
|
|
||||||
config = GatewayConfig.from_env(env=env)
|
|
||||||
assert config.fallback is not None
|
|
||||||
assert config.fallback.is_paid is False
|
|
||||||
|
|
||||||
|
|
||||||
# ---- Provider call tests (with mocked HTTP) ----
|
|
||||||
|
|
||||||
|
|
||||||
class TestProviderCalls:
|
|
||||||
async def test_real_mode_calls_provider(self) -> None:
|
|
||||||
"""In non-mock mode, the gateway should make an HTTP call."""
|
|
||||||
config = real_config()
|
|
||||||
mock_response_data = {
|
|
||||||
"choices": [
|
|
||||||
{
|
|
||||||
"message": {
|
|
||||||
"content": json.dumps({"score": 85, "rationale": {"ok": True}})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"usage": {"prompt_tokens": 50, "completion_tokens": 30},
|
|
||||||
"model": "glm-5.2",
|
|
||||||
}
|
|
||||||
|
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
|
||||||
return httpx.Response(200, json=mock_response_data)
|
|
||||||
|
|
||||||
client = httpx.AsyncClient(
|
|
||||||
transport=httpx.MockTransport(handler),
|
|
||||||
base_url="https://mock.example.com",
|
|
||||||
)
|
|
||||||
gw = Gateway(config, http_client=client)
|
|
||||||
result = await gw.run_task("score", "Score this job")
|
|
||||||
assert result["score"] == 85
|
|
||||||
assert "_usage" not in result # _usage should be popped
|
|
||||||
await client.aclose()
|
|
||||||
|
|
||||||
async def test_retry_on_429(self) -> None:
|
|
||||||
"""Gateway should retry on 429 then succeed."""
|
|
||||||
config = real_config(max_retries=2)
|
|
||||||
call_count = 0
|
|
||||||
|
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
|
||||||
nonlocal call_count
|
|
||||||
call_count += 1
|
|
||||||
if call_count < 2:
|
|
||||||
return httpx.Response(429, json={"error": "rate limited"})
|
|
||||||
return httpx.Response(
|
|
||||||
200,
|
|
||||||
json={
|
|
||||||
"choices": [
|
|
||||||
{"message": {"content": json.dumps({"score": 50})}}
|
|
||||||
],
|
|
||||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
client = httpx.AsyncClient(
|
|
||||||
transport=httpx.MockTransport(handler),
|
|
||||||
base_url="https://mock.example.com",
|
|
||||||
)
|
|
||||||
gw = Gateway(config, http_client=client)
|
|
||||||
result = await gw.run_task("score", "test")
|
|
||||||
assert result["score"] == 50
|
|
||||||
assert call_count == 2
|
|
||||||
await client.aclose()
|
|
||||||
|
|
||||||
async def test_retry_exhausted_raises(self) -> None:
|
|
||||||
"""After all retries, ProviderError should be raised."""
|
|
||||||
config = real_config(max_retries=1)
|
|
||||||
|
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
|
||||||
return httpx.Response(500, json={"error": "server error"})
|
|
||||||
|
|
||||||
client = httpx.AsyncClient(
|
|
||||||
transport=httpx.MockTransport(handler),
|
|
||||||
base_url="https://mock.example.com",
|
|
||||||
)
|
|
||||||
from llm_gateway.exceptions import ProviderError
|
|
||||||
|
|
||||||
gw = Gateway(config, http_client=client)
|
|
||||||
with pytest.raises(ProviderError):
|
|
||||||
await gw.run_task("critique", "test")
|
|
||||||
await client.aclose()
|
|
||||||
|
|
||||||
async def test_fallback_used_for_strong_task(self) -> None:
|
|
||||||
"""Strong tasks should fall back when primary fails."""
|
|
||||||
primary = ProviderConfig(
|
|
||||||
name="primary",
|
|
||||||
base_url="https://primary.example.com/v1",
|
|
||||||
api_key="pk",
|
|
||||||
model="glm-5.2",
|
|
||||||
)
|
|
||||||
fallback = ProviderConfig(
|
|
||||||
name="fallback",
|
|
||||||
base_url="https://fallback.example.com/v1",
|
|
||||||
api_key="fk",
|
|
||||||
model="glm-5.2",
|
|
||||||
is_paid=False,
|
|
||||||
)
|
|
||||||
config = GatewayConfig(
|
|
||||||
primary=primary,
|
|
||||||
fallback=fallback,
|
|
||||||
budgets={"critique": 4000, "default": 4000},
|
|
||||||
max_retries=0,
|
|
||||||
)
|
|
||||||
|
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
|
||||||
if "primary.example.com" in str(request.url):
|
|
||||||
return httpx.Response(500, json={"error": "primary down"})
|
|
||||||
return httpx.Response(
|
|
||||||
200,
|
|
||||||
json={
|
|
||||||
"choices": [
|
|
||||||
{"message": {"content": json.dumps({"comments": []})}}
|
|
||||||
],
|
|
||||||
"usage": {"prompt_tokens": 20, "completion_tokens": 10},
|
|
||||||
"model": "glm-5.2",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
client = httpx.AsyncClient(
|
|
||||||
transport=httpx.MockTransport(handler),
|
|
||||||
)
|
|
||||||
gw = Gateway(config, http_client=client)
|
|
||||||
result = await gw.run_task("critique", "review this")
|
|
||||||
assert result == {"comments": []}
|
|
||||||
await client.aclose()
|
|
||||||
|
|
||||||
async def test_no_fallback_for_cheap_task(self) -> None:
|
|
||||||
"""Cheap tasks must not use fallback even when primary fails."""
|
|
||||||
primary = ProviderConfig(
|
|
||||||
name="primary",
|
|
||||||
base_url="https://primary.example.com/v1",
|
|
||||||
api_key="pk",
|
|
||||||
model="glm-5.2",
|
|
||||||
)
|
|
||||||
fallback = ProviderConfig(
|
|
||||||
name="fallback",
|
|
||||||
base_url="https://fallback.example.com/v1",
|
|
||||||
api_key="fk",
|
|
||||||
model="glm-5.2",
|
|
||||||
)
|
|
||||||
config = GatewayConfig(
|
|
||||||
primary=primary,
|
|
||||||
fallback=fallback,
|
|
||||||
budgets={"score": 4000, "default": 4000},
|
|
||||||
max_retries=0,
|
|
||||||
)
|
|
||||||
|
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
|
||||||
return httpx.Response(500, json={"error": "down"})
|
|
||||||
|
|
||||||
client = httpx.AsyncClient(
|
|
||||||
transport=httpx.MockTransport(handler),
|
|
||||||
)
|
|
||||||
from llm_gateway.exceptions import ProviderError
|
|
||||||
|
|
||||||
gw = Gateway(config, http_client=client)
|
|
||||||
with pytest.raises(ProviderError):
|
|
||||||
await gw.run_task("score", "score this")
|
|
||||||
await client.aclose()
|
|
||||||
|
|
||||||
|
|
||||||
# ---- Convenience function test ----
|
|
||||||
|
|
||||||
|
|
||||||
class TestRunTaskFunction:
|
|
||||||
async def test_run_task_convenience_mock(self) -> None:
|
|
||||||
result = await run_task("score", "test", config=mock_config())
|
|
||||||
assert result["score"] == 75
|
|
||||||
Loading…
Reference in a new issue