Compare commits
3 commits
b77c8b0044
...
8eb8400bad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8eb8400bad | ||
|
|
bc81cb2398 | ||
|
|
8d8a863300 |
43 changed files with 8872 additions and 0 deletions
37
.env.example
Normal file
37
.env.example
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# ---- 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
|
||||
12
apps/web/index.html
Normal file
12
apps/web/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!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
Normal file
5431
apps/web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
29
apps/web/package.json
Normal file
29
apps/web/package.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
6
apps/web/postcss.config.js
Normal file
6
apps/web/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
}
|
||||
23
apps/web/src/App.vue
Normal file
23
apps/web/src/App.vue
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<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>
|
||||
170
apps/web/src/api/index.ts
Normal file
170
apps/web/src/api/index.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
// 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
|
||||
}
|
||||
21
apps/web/src/components/ToastHost.vue
Normal file
21
apps/web/src/components/ToastHost.vue
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<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>
|
||||
10
apps/web/src/main.ts
Normal file
10
apps/web/src/main.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
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')
|
||||
33
apps/web/src/router.test.ts
Normal file
33
apps/web/src/router.test.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
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')
|
||||
})
|
||||
})
|
||||
34
apps/web/src/router/index.ts
Normal file
34
apps/web/src/router/index.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
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
|
||||
28
apps/web/src/stores/toast.ts
Normal file
28
apps/web/src/stores/toast.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
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 }
|
||||
})
|
||||
3
apps/web/src/style.css
Normal file
3
apps/web/src/style.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
128
apps/web/src/types/index.ts
Normal file
128
apps/web/src/types/index.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
// 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 }
|
||||
}
|
||||
153
apps/web/src/views/ApplicationDetail.test.ts
Normal file
153
apps/web/src/views/ApplicationDetail.test.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
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')
|
||||
})
|
||||
})
|
||||
251
apps/web/src/views/ApplicationDetail.vue
Normal file
251
apps/web/src/views/ApplicationDetail.vue
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
<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>
|
||||
82
apps/web/src/views/Applications.test.ts
Normal file
82
apps/web/src/views/Applications.test.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
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')
|
||||
})
|
||||
})
|
||||
133
apps/web/src/views/Applications.vue
Normal file
133
apps/web/src/views/Applications.vue
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
<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>
|
||||
338
apps/web/src/views/CvEditor.vue
Normal file
338
apps/web/src/views/CvEditor.vue
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
<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>
|
||||
103
apps/web/src/views/Research.vue
Normal file
103
apps/web/src/views/Research.vue
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<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
Normal file
7
apps/web/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<object, object, unknown>
|
||||
export default component
|
||||
}
|
||||
8
apps/web/tailwind.config.js
Normal file
8
apps/web/tailwind.config.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{vue,ts}'],
|
||||
theme: {
|
||||
extend: {}
|
||||
},
|
||||
plugins: []
|
||||
}
|
||||
25
apps/web/tsconfig.json
Normal file
25
apps/web/tsconfig.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"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" }]
|
||||
}
|
||||
11
apps/web/tsconfig.node.json
Normal file
11
apps/web/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts", "vitest.config.ts"]
|
||||
}
|
||||
12
apps/web/vite.config.ts
Normal file
12
apps/web/vite.config.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
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))
|
||||
}
|
||||
}
|
||||
})
|
||||
17
apps/web/vitest.config.ts
Normal file
17
apps/web/vitest.config.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
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']
|
||||
}
|
||||
})
|
||||
23
docker-compose.yml
Normal file
23
docker-compose.yml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# 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
|
||||
27
packages/artifacts/pyproject.toml
Normal file
27
packages/artifacts/pyproject.toml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
[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"
|
||||
BIN
packages/artifacts/src/artifacts/DejaVuSans-Bold.ttf
Normal file
BIN
packages/artifacts/src/artifacts/DejaVuSans-Bold.ttf
Normal file
Binary file not shown.
BIN
packages/artifacts/src/artifacts/DejaVuSans.ttf
Normal file
BIN
packages/artifacts/src/artifacts/DejaVuSans.ttf
Normal file
Binary file not shown.
18
packages/artifacts/src/artifacts/__init__.py
Normal file
18
packages/artifacts/src/artifacts/__init__.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
"""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",
|
||||
]
|
||||
259
packages/artifacts/src/artifacts/renderer.py
Normal file
259
packages/artifacts/src/artifacts/renderer.py
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
"""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()
|
||||
25
packages/artifacts/src/artifacts/utils.py
Normal file
25
packages/artifacts/src/artifacts/utils.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"""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
|
||||
146
packages/artifacts/tests/test_artifacts.py
Normal file
146
packages/artifacts/tests/test_artifacts.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
"""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
|
||||
27
packages/llm-gateway/pyproject.toml
Normal file
27
packages/llm-gateway/pyproject.toml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
[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"
|
||||
27
packages/llm-gateway/src/llm_gateway/__init__.py
Normal file
27
packages/llm-gateway/src/llm_gateway/__init__.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"""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",
|
||||
]
|
||||
218
packages/llm-gateway/src/llm_gateway/config.py
Normal file
218
packages/llm-gateway/src/llm_gateway/config.py
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
"""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)
|
||||
22
packages/llm-gateway/src/llm_gateway/exceptions.py
Normal file
22
packages/llm-gateway/src/llm_gateway/exceptions.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
"""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."""
|
||||
388
packages/llm-gateway/src/llm_gateway/gateway.py
Normal file
388
packages/llm-gateway/src/llm_gateway/gateway.py
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
"""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()
|
||||
96
packages/llm-gateway/src/llm_gateway/mock.py
Normal file
96
packages/llm-gateway/src/llm_gateway/mock.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
"""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,
|
||||
}
|
||||
491
packages/llm-gateway/tests/test_gateway.py
Normal file
491
packages/llm-gateway/tests/test_gateway.py
Normal file
|
|
@ -0,0 +1,491 @@
|
|||
"""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