Vision-verified UI fixes: flat company/title joins on kanban+detail, keyword coverage ratio object rendering, change log action/detail fields, Swedish diacritics in demo seed and UI labels (Skåne, Malmö, Göteborg, Ängelholm, Fråga, Arbetsförmedlingen)
Some checks failed
CI / api-tests (push) Failing after 34s
CI / package-tests (push) Failing after 31s
CI / web-tests (push) Failing after 34s

🤖 Generated with Hermes Agent
This commit is contained in:
hermes 2026-07-30 22:05:19 +00:00
parent aa858bd2c1
commit 0741a8b717
11 changed files with 62 additions and 58 deletions

View file

@ -175,10 +175,10 @@ def _mock_cv_tailor(prompt: str) -> dict[str, Any]:
change_log = [] change_log = []
for b in bullets[:2]: for b in bullets[:2]:
tailored.append(f"{b} (tailored for this posting)") tailored.append(f"{b} (tailored for this posting)")
change_log.append({"action": "rephrased", "detail": f"Emphasized relevance of: {b[:60]}..."}) change_log.append({"action": "rephrased", "detail": f"Emphasized relevance of {b[:60]}"})
for b in bullets[2:]: for b in bullets[2:]:
tailored.append(b) tailored.append(b)
change_log.append({"action": "kept", "detail": f"Retained as-is: {b[:60]}..."}) change_log.append({"action": "kept", "detail": f"Retained as-is {b[:60]}"})
return { return {
"tailored_cv": {"summary": bullets[0][:160], "bullets": tailored}, "tailored_cv": {"summary": bullets[0][:160], "bullets": tailored},
"change_log": change_log, "change_log": change_log,

View file

@ -1151,9 +1151,9 @@ def seed_demo() -> Any:
profile = repo_profile.update_profile({ profile = repo_profile.update_profile({
"full_name": "Demo Demosson", "full_name": "Demo Demosson",
"email": "demo@example.com", "email": "demo@example.com",
"location": "Malmo", "location": "Malmö",
"headline": "Software Developer", "headline": "Software Developer",
"summary": "Experienced developer looking for opportunities in Skane.", "summary": "Experienced developer looking for opportunities in Skåne.",
"languages": [{"code": "sv", "level": "native"}, {"code": "en", "level": "fluent"}], "languages": [{"code": "sv", "level": "native"}, {"code": "en", "level": "fluent"}],
}) })
if profile is None: if profile is None:
@ -1161,7 +1161,7 @@ def seed_demo() -> Any:
# Create demo CV sections # Create demo CV sections
demo_sections = [ demo_sections = [
{"kind": "experience", "title": "Backend Developer", "org": "TechSkane AB", "bullets": ["Built REST APIs", "Improved performance by 30%"], "tags": ["python", "fastapi"]}, {"kind": "experience", "title": "Backend Developer", "org": "TechSkåne AB", "bullets": ["Built REST APIs", "Improved performance by 30%"], "tags": ["python", "fastapi"]},
{"kind": "experience", "title": "Junior Developer", "org": "Lund Software", "bullets": ["Maintained web apps"], "tags": ["javascript"]}, {"kind": "experience", "title": "Junior Developer", "org": "Lund Software", "bullets": ["Maintained web apps"], "tags": ["javascript"]},
{"kind": "education", "title": "MSc Computer Science", "org": "Lunds Universitet", "bullets": ["Distributed systems specialization"], "tags": ["algorithms"]}, {"kind": "education", "title": "MSc Computer Science", "org": "Lunds Universitet", "bullets": ["Distributed systems specialization"], "tags": ["algorithms"]},
{"kind": "skills", "title": "Technical Skills", "bullets": ["Python", "PostgreSQL", "Docker", "FastAPI"], "tags": ["python", "docker"]}, {"kind": "skills", "title": "Technical Skills", "bullets": ["Python", "PostgreSQL", "Docker", "FastAPI"], "tags": ["python", "docker"]},
@ -1176,12 +1176,12 @@ def seed_demo() -> Any:
# -- Create 6 standalone demo postings with varied states -- # -- Create 6 standalone demo postings with varied states --
demo_postings = [ demo_postings = [
{"company": "Skane Tech AB", "title": "Senior Python Developer", "location": "Malmo", "url": "https://example.com/af/1", "state": "scored", "score": 85}, {"company": "Skåne Tech AB", "title": "Senior Python Developer", "location": "Malmö", "url": "https://example.com/af/1", "state": "scored", "score": 85},
{"company": "Lund Systems", "title": "Fullstack Engineer", "location": "Lund", "url": "https://example.com/af/2", "state": "discovered", "score": None}, {"company": "Lund Systems", "title": "Fullstack Engineer", "location": "Lund", "url": "https://example.com/af/2", "state": "discovered", "score": None},
{"company": "Copenhagen Digital", "title": "Backend Developer", "location": "Copenhagen", "url": "https://example.com/af/3", "state": "approved", "score": 70}, {"company": "Copenhagen Digital", "title": "Backend Developer", "location": "Copenhagen", "url": "https://example.com/af/3", "state": "approved", "score": 70},
{"company": "Helsingborg IT", "title": "DevOps Engineer", "location": "Helsingborg", "url": "https://example.com/af/4", "state": "sent", "score": 65}, {"company": "Helsingborg IT", "title": "DevOps Engineer", "location": "Helsingborg", "url": "https://example.com/af/4", "state": "sent", "score": 65},
{"company": "Malmo Startup", "title": "Software Engineer", "location": "Malmo", "url": "https://example.com/af/5", "state": "rejected", "score": 30}, {"company": "Malmö Startup", "title": "Software Engineer", "location": "Malmö", "url": "https://example.com/af/5", "state": "rejected", "score": 30},
{"company": "Angelholm Tech", "title": "Data Engineer", "location": "Angelholm", "url": "https://example.com/af/6", "state": "discovered", "score": None}, {"company": "Ängelholm Tech", "title": "Data Engineer", "location": "Ängelholm", "url": "https://example.com/af/6", "state": "discovered", "score": None},
] ]
for dp in demo_postings: for dp in demo_postings:
@ -1258,7 +1258,7 @@ def seed_demo() -> Any:
url=f"https://example.com/af/deadline/{j+1}", url=f"https://example.com/af/deadline/{j+1}",
company=f"Deadline Corp {j+1}", company=f"Deadline Corp {j+1}",
title=f"Urgent Developer Role {j+1}", title=f"Urgent Developer Role {j+1}",
location="Goteborg", location="Göteborg",
description="Urgent hire for a developer with deadline approaching.", description="Urgent hire for a developer with deadline approaching.",
raw={}, raw={},
) )
@ -1290,7 +1290,7 @@ def seed_demo() -> Any:
url="https://example.com/af/interview/1", url="https://example.com/af/interview/1",
company="Festina Digital AB", company="Festina Digital AB",
title="Full Stack Developer", title="Full Stack Developer",
location="Malmo", location="Malmö",
description="Full stack developer with React, Python, and cloud experience.", description="Full stack developer with React, Python, and cloud experience.",
raw={}, raw={},
) )
@ -1398,8 +1398,8 @@ def seed_demo() -> Any:
create_email_suggestion( create_email_suggestion(
application_id=interviewing_app_id, application_id=interviewing_app_id,
mailbox_from="hr@festina-demo.se", mailbox_from="hr@festina-demo.se",
subject="Fraga om din erfarenhet", subject="Fråga om din erfarenhet",
snippet="Vi har nagra fragor om din bakgrund inom Python...", snippet="Vi har några frågor om din bakgrund inom Python...",
classification="question", classification="question",
state_proposal=None, state_proposal=None,
received_at=datetime.now(timezone.utc) - timedelta(hours=1), received_at=datetime.now(timezone.utc) - timedelta(hours=1),

View file

@ -66,8 +66,10 @@ export interface Application {
notes: string notes: string
state_changed_at: string state_changed_at: string
created_at: string created_at: string
// joined posting info (from GET /applications) // joined posting info (flat fields from GET /applications)
posting?: JobPosting company?: string | null
title?: string | null
location?: string | null
} }
export type ArtifactKind = 'cv' | 'cover_letter' | 'email' | 'other' export type ArtifactKind = 'cv' | 'cover_letter' | 'email' | 'other'
@ -262,14 +264,21 @@ export interface Cluster {
postings: ClusterPosting[] postings: ClusterPosting[]
} }
export interface TailorKeywordCoverage {
ratio: number
matched: string[]
missing: string[]
}
export interface TailorChangeLogEntry { export interface TailorChangeLogEntry {
section: string action?: string
change: string section?: string
bullet_id: string detail?: string
change?: string
} }
export interface TailorCvResponse { export interface TailorCvResponse {
artifact_id: string artifact_id: string
change_log: TailorChangeLogEntry[] change_log: TailorChangeLogEntry[]
keyword_coverage: number keyword_coverage: TailorKeywordCoverage
} }

View file

@ -34,11 +34,9 @@ function makeApp(): Application {
notes: '', notes: '',
state_changed_at: '2026-01-01T00:00:00Z', state_changed_at: '2026-01-01T00:00:00Z',
created_at: '2026-01-01T00:00:00Z', created_at: '2026-01-01T00:00:00Z',
posting: { company: 'Acme',
id: 'j-1', source: 'manual_url', external_id: null, url: 'http://x', title: 'Engineer',
company: 'Acme', title: 'Engineer', location: 'Remote', description: '', location: 'Remote'
raw: {}, fetched_at: '2026-01-01T00:00:00Z'
}
} }
} }
@ -60,10 +58,10 @@ function makeTailorResult(): TailorCvResponse {
return { return {
artifact_id: 'art-tailor-1', artifact_id: 'art-tailor-1',
change_log: [ change_log: [
{ section: 'experience', change: 'Reordered to highlight Python backend work', bullet_id: 'b-1' }, { action: 'experience', detail: 'Reordered to highlight Python backend work' },
{ section: 'skills', change: 'Moved Docker and Kubernetes higher', bullet_id: 'b-5' } { action: 'skills', detail: 'Moved Docker and Kubernetes higher' }
], ],
keyword_coverage: 0.75 keyword_coverage: { ratio: 0.75, matched: ['python', 'docker'], missing: ['kubernetes'] }
} }
} }

View file

@ -34,11 +34,9 @@ function makeApp(): Application {
notes: '', notes: '',
state_changed_at: '2026-01-01T00:00:00Z', state_changed_at: '2026-01-01T00:00:00Z',
created_at: '2026-01-01T00:00:00Z', created_at: '2026-01-01T00:00:00Z',
posting: { company: 'Acme',
id: 'j-1', source: 'manual_url', external_id: null, url: 'http://x', title: 'Engineer',
company: 'Acme', title: 'Engineer', location: 'Remote', description: '', location: 'Remote'
raw: {}, fetched_at: '2026-01-01T00:00:00Z'
}
} }
} }

View file

@ -52,17 +52,22 @@ const severityClass: Record<string, string> = {
low: 'bg-blue-50 border-blue-200' low: 'bg-blue-50 border-blue-200'
} }
const coverageRatio = computed(() => {
if (!tailorResult.value) return 0
const kc = tailorResult.value.keyword_coverage
return typeof kc === 'object' && kc !== null ? (kc.ratio ?? 0) : Number(kc) || 0
})
const coverageColor = computed(() => { const coverageColor = computed(() => {
if (!tailorResult.value) return 'bg-gray-300' if (!tailorResult.value) return 'bg-gray-300'
const c = tailorResult.value.keyword_coverage const c = coverageRatio.value
if (c >= 0.7) return 'bg-green-500' if (c >= 0.7) return 'bg-green-500'
if (c >= 0.4) return 'bg-yellow-500' if (c >= 0.4) return 'bg-yellow-500'
return 'bg-red-500' return 'bg-red-500'
}) })
const coveragePercent = computed(() => { const coveragePercent = computed(() => {
if (!tailorResult.value) return 0 return Math.round(coverageRatio.value * 100)
return Math.round(tailorResult.value.keyword_coverage * 100)
}) })
const downloadUrl = computed(() => { const downloadUrl = computed(() => {
@ -140,7 +145,7 @@ async function sendOutbox() {
if (!approval.value || !canSend.value) return if (!approval.value || !canSend.value) return
sending.value = true sending.value = true
try { try {
await api.outboxSend(approval.value.id, { to: application.value?.posting?.company ?? '' }) await api.outboxSend(approval.value.id, { to: application.value?.company ?? '' })
toast.push('Sent successfully', 'success') toast.push('Sent successfully', 'success')
} catch (err) { } catch (err) {
let msg = 'Send failed' let msg = 'Send failed'
@ -195,8 +200,8 @@ onMounted(loadData)
<template v-if="!loading && application"> <template v-if="!loading && application">
<!-- Posting info --> <!-- Posting info -->
<section class="bg-white rounded-lg border border-gray-200 p-4"> <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="font-semibold text-lg">{{ application.company ?? 'Unknown' }}</div>
<div class="text-gray-600">{{ application.posting?.title ?? 'No title' }}</div> <div class="text-gray-600">{{ application.title ?? 'No title' }}</div>
<div class="text-sm text-gray-500 mt-1"> <div class="text-sm text-gray-500 mt-1">
State: <span class="capitalize font-medium">{{ application.state }}</span> State: <span class="capitalize font-medium">{{ application.state }}</span>
<span v-if="application.score != null" class="ml-3">Score: {{ application.score }}</span> <span v-if="application.score != null" class="ml-3">Score: {{ application.score }}</span>
@ -245,8 +250,8 @@ onMounted(loadData)
:key="i" :key="i"
class="border-b border-gray-100 py-1" class="border-b border-gray-100 py-1"
> >
<span class="font-medium text-gray-700">{{ entry.section }}:</span> <span class="font-medium text-gray-700">{{ entry.action || entry.section || 'change' }}:</span>
<span class="text-gray-600 ml-1">{{ entry.change }}</span> <span class="text-gray-600 ml-1">{{ entry.detail || entry.change }}</span>
</li> </li>
</ul> </ul>
</div> </div>

View file

@ -30,11 +30,9 @@ function makeApp(id: string, state: string, company: string): Application {
notes: '', notes: '',
state_changed_at: '2026-01-01T00:00:00Z', state_changed_at: '2026-01-01T00:00:00Z',
created_at: '2026-01-01T00:00:00Z', created_at: '2026-01-01T00:00:00Z',
posting: { company,
id: 'j-' + id, source: 'manual_url', external_id: null, url: 'http://x', title: 'Engineer',
company, title: 'Engineer', location: 'Remote', description: '', location: 'Remote'
raw: {}, fetched_at: '2026-01-01T00:00:00Z'
}
} }
} }

View file

@ -31,11 +31,9 @@ function fixture(): Application[] {
notes: '', notes: '',
state_changed_at: '2026-01-01T00:00:00Z', state_changed_at: '2026-01-01T00:00:00Z',
created_at: '2026-01-01T00:00:00Z', created_at: '2026-01-01T00:00:00Z',
posting: { company: 'Acme',
id: 'j-1', source: 'manual_url', external_id: null, url: 'http://x', title: 'Engineer',
company: 'Acme', title: 'Engineer', location: 'Remote', description: '', location: 'Remote'
raw: {}, fetched_at: '2026-01-01T00:00:00Z'
}
}, },
{ {
id: 'app-2', id: 'app-2',
@ -46,11 +44,9 @@ function fixture(): Application[] {
notes: '', notes: '',
state_changed_at: '2026-01-01T00:00:00Z', state_changed_at: '2026-01-01T00:00:00Z',
created_at: '2026-01-01T00:00:00Z', created_at: '2026-01-01T00:00:00Z',
posting: { company: 'Globex',
id: 'j-2', source: 'linkedin', external_id: null, url: 'http://y', title: 'Manager',
company: 'Globex', title: 'Manager', location: 'Malmo', description: '', location: 'Malmo'
raw: {}, fetched_at: '2026-01-01T00:00:00Z'
}
} }
] ]
} }

View file

@ -171,9 +171,9 @@ onMounted(loadApplications)
class="inline-block w-2 h-2 rounded-full bg-orange-500 flex-shrink-0" class="inline-block w-2 h-2 rounded-full bg-orange-500 flex-shrink-0"
title="Follow-up nudge pending" title="Follow-up nudge pending"
></span> ></span>
<div class="font-medium text-sm truncate">{{ app.posting?.company ?? 'Unknown' }}</div> <div class="font-medium text-sm truncate">{{ app.company ?? 'Unknown' }}</div>
</div> </div>
<div class="text-xs text-gray-500 truncate">{{ app.posting?.title ?? 'No title' }}</div> <div class="text-xs text-gray-500 truncate">{{ app.title ?? 'No title' }}</div>
<div v-if="app.score != null" class="text-xs text-green-700 mt-1"> <div v-if="app.score != null" class="text-xs text-green-700 mt-1">
Score: {{ app.score }} Score: {{ app.score }}
</div> </div>

View file

@ -153,7 +153,7 @@ onMounted(loadPostings)
<!-- Fetch form (Arbetsformedlingen connector) --> <!-- Fetch form (Arbetsformedlingen connector) -->
<section class="bg-white rounded-lg border border-gray-200 p-4 space-y-3"> <section class="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
<h2 class="font-semibold">Fetch from Arbetsformedlingen</h2> <h2 class="font-semibold">Fetch from Arbetsförmedlingen</h2>
<div class="flex gap-2"> <div class="flex gap-2">
<input <input
v-model="fetchQuery" v-model="fetchQuery"

View file

@ -223,7 +223,7 @@ function prev() {
<div v-if="step === 2" class="bg-white rounded-lg border border-gray-200 p-6 space-y-4"> <div v-if="step === 2" class="bg-white rounded-lg border border-gray-200 p-6 space-y-4">
<h2 class="font-semibold text-lg">Fetch Job Postings</h2> <h2 class="font-semibold text-lg">Fetch Job Postings</h2>
<p class="text-sm text-gray-600"> <p class="text-sm text-gray-600">
Search for job postings from the Arbetsformedlingen connector. New postings will be added to your applications. Search for job postings from the Arbetsförmedlingen connector. New postings will be added to your applications.
</p> </p>
<div class="space-y-2"> <div class="space-y-2">
<label class="block"> <label class="block">