Skip to content

Commit 9378c7c

Browse files
committed
fix(suggestions): render structured suggestions correctly + docs sync
ScoreBreakdown rendered `<li>{suggestion}</li>` directly. when the LLM returned StructuredSuggestion objects (with summary/details/impact/ platforms) instead of plain strings, svelte's text interpolation fell back to String(obj) and shipped "[object Object]" to the page. fixed with type-narrowing helpers (same pattern report.ts and ScoreDashboard already use); structured suggestions now show their summary plus a nested list of their details. ScoreDashboard had the same class of bug in its non-deduplicated path at the !structured fallback (`<p>{suggestion}</p>`). hardened with a typeof guard so any malformed value renders empty rather than "[object Object]". JobDescriptionInput's debounced parser ran inside a fire-and-forget async IIFE with no .catch(); a transient parser failure would surface as an unhandled promise rejection. wrapped in try/catch with a console.warn so the scan flow stays unaffected. docs: - api/rate-limits: documents the new 429 body shape (retryAfter field + reason discriminator) and the Retry-After header - api/endpoints: documents the _cached response field, the in-memory cache behavior, and lists the new auxiliary endpoints (/healthz, /robots.txt, /sitemap.xml, /api/og, /share, /api/csp-report) - pnpm build:docs run; static/docs refreshed dry-tested via curl: /healthz, /robots.txt, /sitemap.xml, /api/og (magic bytes valid + correct cache-control), /share, rate limiter (11th request 429 with retry-after: 60), and a full LLM cache-hit round-trip (46s -> 81ms, 573x). 183 tests + lint + typecheck + build all green.
1 parent b3f01e9 commit 9378c7c

5 files changed

Lines changed: 121 additions & 30 deletions

File tree

docs/src/content/docs/api/endpoints.md

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -103,20 +103,35 @@ Extract structured requirements from a job description without scoring a resume.
103103
"suggestions": ["Add AWS and CI/CD keywords to match Workday's exact matching"]
104104
}
105105
],
106-
"_provider": "gemini",
107-
"_fallback": false
106+
"_provider": "gemma-3-27b",
107+
"_fallback": false,
108+
"_cached": false
108109
}
109110
```
110111

111112
### Response Fields
112113

113-
| Field | Type | Description |
114-
| ------------------------ | -------- | --------------------------------------- |
115-
| `results` | array | Array of 6 platform scoring objects |
116-
| `results[].system` | string | Platform name |
117-
| `results[].overallScore` | number | 0-100 weighted composite score |
118-
| `results[].passesFilter` | boolean | Whether resume passes initial screening |
119-
| `results[].breakdown` | object | Per-dimension scores and details |
120-
| `results[].suggestions` | string[] | Platform-specific improvement tips |
121-
| `_provider` | string | Which LLM provider handled the request |
122-
| `_fallback` | boolean | Whether a fallback provider was used |
114+
| Field | Type | Description |
115+
| ------------------------ | -------------------------- | ---------------------------------------------------------------------------------------------------------- |
116+
| `results` | array | Array of 6 platform scoring objects |
117+
| `results[].system` | string | Platform name |
118+
| `results[].overallScore` | number | 0-100 weighted composite score |
119+
| `results[].passesFilter` | boolean | Whether resume passes initial screening |
120+
| `results[].breakdown` | object | Per-dimension scores and details |
121+
| `results[].suggestions` | string \| StructuredItem[] | Platform-specific improvement tips. May be plain strings (rule-based) or structured objects (LLM-enhanced) |
122+
| `_provider` | string | Which LLM provider handled the request (e.g. `gemma-3-27b`, `groq-llama-3.3-70b`) |
123+
| `_fallback` | boolean | `true` when all providers failed and the client must fall back to local rule-based scoring |
124+
| `_cached` | boolean | `true` when the response was served from the in-memory result cache (sub-100ms, zero LLM cost) |
125+
126+
The server keeps a SHA-256 keyed in-memory LRU of recent prompts (200 entries, 24h TTL). Identical input hits the cache and returns instantly — the `_cached` flag tells you whether the response was a hit. The cache lives per Vercel instance; cold starts begin empty.
127+
128+
## Auxiliary Endpoints
129+
130+
| Path | Method | Purpose |
131+
| ------------------- | ------ | -------------------------------------------------------------------------------------- |
132+
| `/healthz` | GET | Liveness probe — JSON `{ status, timestamp }`. For uptime monitors |
133+
| `/robots.txt` | GET | Dynamic; the `Sitemap:` URL tracks the deployment origin |
134+
| `/sitemap.xml` | GET | Dynamic; lists public routes (`/`, `/scanner`, `/about`) with `lastmod` and `priority` |
135+
| `/api/og` | GET | Edge-cached PNG (`@vercel/og`) for share previews. Query: `score`, `pass`, `total`, optional `delta` |
136+
| `/share` | GET | Branded share landing page; reads the same query params and emits `og:image` pointing at `/api/og` |
137+
| `/api/csp-report` | POST | Receives Content-Security-Policy violation reports for the report-only header set by `hooks.server.ts` |

docs/src/content/docs/api/rate-limits.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,18 +32,28 @@ Cache-Control: no-store
3232

3333
## Handling Rate Limits
3434

35-
When you receive a `429` response:
35+
When you receive a `429` response, the body distinguishes which window was hit and the response includes a `Retry-After` header set to the seconds-until-reset for that window:
36+
37+
```http
38+
HTTP/1.1 429 Too Many Requests
39+
Retry-After: 60
40+
Content-Type: application/json
41+
```
3642

3743
```json
3844
{
39-
"error": "rate limit exceeded. try again in 60 seconds."
45+
"error": "rate limit exceeded: too many requests this minute. retry after 60s.",
46+
"retryAfter": 60
4047
}
4148
```
4249

50+
The error string ends with either `too many requests this minute` (per-minute window) or `daily limit reached` (per-day window). The `retryAfter` field (seconds) and the `Retry-After` header always match; clients can use either.
51+
4352
**Best practices:**
4453

45-
- Implement exponential backoff in your client
46-
- Cache results locally to avoid redundant requests
54+
- Honor the `Retry-After` header — it is the exact reset window for the limit you tripped
55+
- Cache results locally to avoid redundant requests (the server also caches identical inputs in-memory; see the `_cached` flag in [endpoints](./endpoints))
56+
- Implement exponential backoff for transient 5xx errors (rate-limit 429s should use Retry-After directly)
4757
- For high-volume use, self-host with your own API keys
4858

4959
## Self-Hosted Limits

src/lib/components/scoring/ScoreBreakdown.svelte

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,26 @@
11
<script lang="ts">
2-
import type { ScoreResult } from '$engine/scorer/types';
2+
import type { ScoreResult, Suggestion, StructuredSuggestion } from '$engine/scorer/types';
33
import { getScoreColor, getScoreLabel } from '$engine/scorer/classification';
44
55
let { result }: { result: ScoreResult } = $props();
66
77
// toggles the expanded state for this breakdown
88
let expanded = $state(false);
9+
10+
// suggestions can be either a plain string (legacy/rule-based path) or a
11+
// StructuredSuggestion object (LLM path). without narrowing, svelte's text
12+
// interpolation falls back to String(obj) and renders "[object Object]"
13+
function isStructured(s: Suggestion): s is StructuredSuggestion {
14+
return typeof s === 'object' && s !== null && 'summary' in s;
15+
}
16+
function suggestionText(s: Suggestion): string {
17+
if (typeof s === 'string') return s;
18+
if (isStructured(s)) return s.summary;
19+
return '';
20+
}
21+
function suggestionDetails(s: Suggestion): string[] {
22+
return isStructured(s) ? s.details : [];
23+
}
924
</script>
1025

1126
<div class="breakdown" class:expanded>
@@ -190,7 +205,20 @@
190205
<h4>Suggestions for {result.system}</h4>
191206
<ul class="suggestion-list">
192207
{#each result.suggestions as suggestion}
193-
<li>{suggestion}</li>
208+
{@const text = suggestionText(suggestion)}
209+
{@const details = suggestionDetails(suggestion)}
210+
{#if text}
211+
<li>
212+
<span class="suggestion-text">{text}</span>
213+
{#if details.length > 0}
214+
<ul class="suggestion-detail-list">
215+
{#each details as detail}
216+
<li>{detail}</li>
217+
{/each}
218+
</ul>
219+
{/if}
220+
</li>
221+
{/if}
194222
{/each}
195223
</ul>
196224
</div>
@@ -427,4 +455,31 @@
427455
color: var(--accent-cyan);
428456
font-weight: bold;
429457
}
458+
459+
.suggestion-text {
460+
display: block;
461+
}
462+
463+
.suggestion-detail-list {
464+
list-style: none;
465+
padding: 0;
466+
margin: 0.4rem 0 0.2rem;
467+
display: flex;
468+
flex-direction: column;
469+
gap: 0.2rem;
470+
}
471+
472+
.suggestion-detail-list li {
473+
font-size: 0.78rem;
474+
color: var(--text-tertiary);
475+
padding-left: 0.85rem;
476+
line-height: 1.5;
477+
}
478+
479+
.suggestion-detail-list li::before {
480+
content: '·';
481+
left: 0;
482+
color: var(--text-tertiary);
483+
opacity: 0.7;
484+
}
430485
</style>

src/lib/components/scoring/ScoreDashboard.svelte

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -640,7 +640,11 @@
640640
{/each}
641641
</ul>
642642
{:else if !structured}
643-
<p>{suggestion}</p>
643+
<!-- defensive: if suggestion is somehow a non-string non-structured
644+
value (e.g. malformed LLM output), interpolating directly would
645+
render "[object Object]" - same class as the bug fixed in
646+
ScoreBreakdown. typeof guard keeps the render type-safe -->
647+
<p>{typeof suggestion === 'string' ? suggestion : ''}</p>
644648
{/if}
645649
{#if example}
646650
<div class="suggestion-example">

src/lib/components/upload/JobDescriptionInput.svelte

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -37,17 +37,24 @@
3737
}
3838
let cancelled = false;
3939
(async () => {
40-
const { parseJobDescription } = await import('$engine/job-parser');
41-
if (cancelled) return;
42-
const result = parseJobDescription(v);
43-
if (cancelled) return;
44-
parsed = {
45-
extractedSkills: result.extractedSkills.slice(0, 12),
46-
requiredSkills: result.requiredSkills,
47-
experienceLevel: result.experienceLevel,
48-
roleType: result.roleType,
49-
industryContext: result.industryContext
50-
};
40+
try {
41+
const { parseJobDescription } = await import('$engine/job-parser');
42+
if (cancelled) return;
43+
const result = parseJobDescription(v);
44+
if (cancelled) return;
45+
parsed = {
46+
extractedSkills: result.extractedSkills.slice(0, 12),
47+
requiredSkills: result.requiredSkills,
48+
experienceLevel: result.experienceLevel,
49+
roleType: result.roleType,
50+
industryContext: result.industryContext
51+
};
52+
} catch (err) {
53+
// preview is best-effort - if the parser throws (corrupt input,
54+
// transient import failure), keep the previous parsed state and
55+
// log so it's grep-able in console without breaking the scan flow
56+
console.warn('[jd-preview] parse failed:', err);
57+
}
5158
})();
5259
return () => {
5360
cancelled = true;

0 commit comments

Comments
 (0)