careermate 0.0.9 → 0.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,32 @@
1
+ # Local-first data ownership
2
+
3
+ CareerMate uses **local-first** to mean that the primary career workspace is owned by the user and stored on the user's machine.
4
+
5
+ ## Stored locally
6
+
7
+ - SQLite database
8
+ - profile, experience, project, and skill records
9
+ - résumés, career descriptions, and cover-letter versions
10
+ - saved job postings, application status, interview preparation, and timeline history
11
+ - backups and exports
12
+ - deterministic verification results
13
+
14
+ These records are not uploaded to a CareerMate account or CareerMate-operated database.
15
+
16
+ ## Optional network activity
17
+
18
+ Some features may use the network without changing the local-first model:
19
+
20
+ - checking the npm registry for a newer CareerMate version
21
+ - opening current installation documentation
22
+ - searching supported public job-board endpoints using only the user's search keyword
23
+ - loading the public product website or demo
24
+ - public-site analytics on `careermate.life`
25
+
26
+ CareerMate must not attach locally stored résumé, profile, document, or application data to these requests.
27
+
28
+ ## Product language
29
+
30
+ Do not describe CareerMate as fully offline. Prefer:
31
+
32
+ > Your career database and documents stay on your computer. Optional discovery and update features may access the internet without sending your stored career data.
@@ -0,0 +1,22 @@
1
+ # Full Review Implementation Plan
2
+
3
+ This document tracks the repository-wide improvements implemented after the 2026-08 full review.
4
+
5
+ ## Product definition
6
+
7
+ CareerMate is local-first: the primary database, career records, generated documents, and core workflows are owned and stored on the user's machine. Optional network-backed features such as install guidance, update checks, analytics, and job-board search do not change that definition.
8
+
9
+ ## Delivery groups
10
+
11
+ 1. Data safety and lifecycle
12
+ 2. Provenance and human verification
13
+ 3. Backup, restore, and portability
14
+ 4. Provider validation and health
15
+ 5. Onboarding and diagnostics
16
+ 6. Workflow history and explainability
17
+ 7. Fit analysis quality and contradiction detection
18
+ 8. Front-end maintainability and accessibility
19
+ 9. CI, release, and security hardening
20
+ 10. Documentation and SEO
21
+
22
+ Each group should be implemented as an independently reviewable commit or PR with tests and migration coverage where applicable.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "careermate",
3
- "version": "0.0.9",
3
+ "version": "0.0.10",
4
4
  "type": "module",
5
- "description": "CareerMate — an MCP-first, local-first career workspace. Connect it to a local AI agent (Claude Desktop, Claude Code, Codex); CareerMate stores your career data locally on your machine and exposes it over MCP.",
5
+ "description": "CareerMate — an MCP-first, local-first career workspace. Your career database and documents stay on your machine; optional discovery and update features may use the internet without sending stored career data.",
6
6
  "license": "MIT",
7
7
  "author": "CareerMate",
8
8
  "homepage": "https://careermate.life",
@@ -41,6 +41,8 @@
41
41
  "INSTALL.md",
42
42
  "THIRD_PARTY_NOTICES.md",
43
43
  "AGENTS.md",
44
+ "docs/LOCAL_FIRST.md",
45
+ "docs/REVIEW_IMPLEMENTATION_PLAN.md",
44
46
  "docs/career-os/README.md",
45
47
  "docs/career-os/eop",
46
48
  "docs/career-os/knowledge",
@@ -80,13 +82,13 @@
80
82
  "mammoth": "^1.12.0",
81
83
  "pdfjs-dist": "^6.0.0",
82
84
  "tsx": "^4.19.2",
83
- "zod": "^3.24.1"
85
+ "zod": "^4.4.3"
84
86
  },
85
87
  "devDependencies": {
86
88
  "@playwright/test": "^1.60.0",
87
- "@types/node": "^22.10.0",
89
+ "@types/node": "^26.1.2",
88
90
  "esbuild": "^0.28.1",
89
91
  "playwright": "^1.60.0",
90
- "typescript": "^5.7.2"
92
+ "typescript": "^7.0.2"
91
93
  }
92
94
  }
@@ -0,0 +1,161 @@
1
+ import {
2
+ documentRepo,
3
+ experienceRepo,
4
+ projectRepo,
5
+ jobRepo,
6
+ fitRepo,
7
+ applicationRepo,
8
+ } from '@careermate/db';
9
+
10
+ export interface DataHealthIssue {
11
+ code: string;
12
+ severity: 'info' | 'warning' | 'error';
13
+ entity_type: string;
14
+ entity_id?: string;
15
+ message: string;
16
+ }
17
+
18
+ export interface DataHealthReport {
19
+ score: number;
20
+ checked_at: string;
21
+ counts: Record<string, number>;
22
+ issues: DataHealthIssue[];
23
+ }
24
+
25
+ function monthValue(value: string | null | undefined): number | null {
26
+ if (!value) return null;
27
+ const m = /^(\d{4})-(\d{2})/.exec(value);
28
+ if (!m) return null;
29
+ return Number(m[1]) * 12 + Number(m[2]);
30
+ }
31
+
32
+ function normalized(value: string | null | undefined): string {
33
+ return (value ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
34
+ }
35
+
36
+ export function getDataHealthReport(now = new Date()): DataHealthReport {
37
+ const experiences = experienceRepo.list();
38
+ const projects = projectRepo.list();
39
+ const documents = documentRepo.list();
40
+ const jobs = jobRepo.list();
41
+ const issues: DataHealthIssue[] = [];
42
+
43
+ for (const exp of experiences) {
44
+ const start = monthValue(exp.start_date);
45
+ const end = monthValue(exp.end_date);
46
+ if (start != null && end != null && end < start) {
47
+ issues.push({
48
+ code: 'experience_date_reversed',
49
+ severity: 'error',
50
+ entity_type: 'experience',
51
+ entity_id: exp.id,
52
+ message: `${exp.company} 경력의 종료일이 시작일보다 빠릅니다.`,
53
+ });
54
+ }
55
+ if (exp.is_current && exp.end_date) {
56
+ issues.push({
57
+ code: 'current_experience_has_end_date',
58
+ severity: 'warning',
59
+ entity_type: 'experience',
60
+ entity_id: exp.id,
61
+ message: `${exp.company} 경력이 재직 중이지만 종료일이 입력돼 있습니다.`,
62
+ });
63
+ }
64
+ if (!exp.role) {
65
+ issues.push({
66
+ code: 'experience_missing_role',
67
+ severity: 'warning',
68
+ entity_type: 'experience',
69
+ entity_id: exp.id,
70
+ message: `${exp.company} 경력에 직무/역할이 없습니다.`,
71
+ });
72
+ }
73
+ }
74
+
75
+ const seenProjects = new Map<string, string>();
76
+ for (const project of projects) {
77
+ const key = normalized(project.name);
78
+ const duplicate = seenProjects.get(key);
79
+ if (key && duplicate) {
80
+ issues.push({
81
+ code: 'duplicate_project_name',
82
+ severity: 'warning',
83
+ entity_type: 'project',
84
+ entity_id: project.id,
85
+ message: `같은 이름의 프로젝트가 중복돼 있습니다: ${project.name}`,
86
+ });
87
+ } else if (key) {
88
+ seenProjects.set(key, project.id);
89
+ }
90
+ const start = monthValue(project.start_date);
91
+ const end = monthValue(project.end_date);
92
+ if (start != null && end != null && end < start) {
93
+ issues.push({
94
+ code: 'project_date_reversed',
95
+ severity: 'error',
96
+ entity_type: 'project',
97
+ entity_id: project.id,
98
+ message: `${project.name} 프로젝트의 종료일이 시작일보다 빠릅니다.`,
99
+ });
100
+ }
101
+ }
102
+
103
+ const primaryDocs = documents.filter((doc) => doc.is_primary);
104
+ if (documents.length > 0 && primaryDocs.length === 0) {
105
+ issues.push({
106
+ code: 'missing_primary_document',
107
+ severity: 'warning',
108
+ entity_type: 'document',
109
+ message: '대표 이력서 또는 경력기술서가 지정되지 않았습니다.',
110
+ });
111
+ }
112
+
113
+ const nowDate = now.toISOString().slice(0, 10);
114
+ let staleDrafts = 0;
115
+ let jobsWithoutFit = 0;
116
+ for (const job of jobs) {
117
+ if (!fitRepo.getByJob(job.id)) jobsWithoutFit++;
118
+ const app = applicationRepo.getByJob(job.id);
119
+ if (app?.status === 'draft') {
120
+ const updated = String(app.updated_at ?? '').slice(0, 10);
121
+ if (updated && updated < nowDate) staleDrafts++;
122
+ }
123
+ }
124
+ if (jobsWithoutFit > 0) {
125
+ issues.push({
126
+ code: 'jobs_without_fit_analysis',
127
+ severity: 'info',
128
+ entity_type: 'job',
129
+ message: `적합도 분석이 없는 공고가 ${jobsWithoutFit}개 있습니다.`,
130
+ });
131
+ }
132
+ if (staleDrafts > 0) {
133
+ issues.push({
134
+ code: 'stale_draft_applications',
135
+ severity: 'info',
136
+ entity_type: 'application',
137
+ message: `후속 행동을 확인할 임시 지원 건이 ${staleDrafts}개 있습니다.`,
138
+ });
139
+ }
140
+
141
+ const penalty = issues.reduce((sum, issue) => {
142
+ if (issue.severity === 'error') return sum + 15;
143
+ if (issue.severity === 'warning') return sum + 7;
144
+ return sum + 2;
145
+ }, 0);
146
+
147
+ return {
148
+ score: Math.max(0, 100 - penalty),
149
+ checked_at: now.toISOString(),
150
+ counts: {
151
+ experiences: experiences.length,
152
+ projects: projects.length,
153
+ documents: documents.length,
154
+ jobs: jobs.length,
155
+ issues: issues.length,
156
+ errors: issues.filter((issue) => issue.severity === 'error').length,
157
+ warnings: issues.filter((issue) => issue.severity === 'warning').length,
158
+ },
159
+ issues,
160
+ };
161
+ }
@@ -0,0 +1,63 @@
1
+ import { fitRepo, jobRepo } from '@careermate/db';
2
+
3
+ export interface ExplainableFitAnalysis {
4
+ job_id: string;
5
+ overall_score: number | null;
6
+ requirement_coverage: number | null;
7
+ evidence_strength: number;
8
+ matched_requirements: string[];
9
+ missing_requirements: string[];
10
+ strengths: string[];
11
+ risks: string[];
12
+ recommended_actions: string[];
13
+ explanation: string;
14
+ }
15
+
16
+ function ratio(matched: number, missing: number): number | null {
17
+ const total = matched + missing;
18
+ return total === 0 ? null : Math.round((matched / total) * 100);
19
+ }
20
+
21
+ export function getExplainableFitAnalysis(jobId: string): ExplainableFitAnalysis | null {
22
+ const job = jobRepo.get(jobId);
23
+ const fit = fitRepo.getByJob(jobId);
24
+ if (!job || !fit) return null;
25
+
26
+ const matched = fit.matched_keywords ?? [];
27
+ const missing = fit.missing_keywords ?? [];
28
+ const coverage = ratio(matched.length, missing.length);
29
+ const evidenceItems = (fit.strengths ?? []).filter((item) => item.trim().length > 0);
30
+ const evidenceStrength = Math.min(100, evidenceItems.length * 20 + matched.length * 5);
31
+ const risks = [
32
+ ...(fit.gaps ?? []),
33
+ ...missing.map((item) => `근거가 부족한 요구사항: ${item}`),
34
+ ];
35
+ const actions = fit.recommendations?.length
36
+ ? fit.recommendations
37
+ : missing.map((item) => `${item} 요구에 연결할 실제 경험 또는 학습 근거를 보완하세요.`);
38
+
39
+ const explanationParts = [
40
+ coverage == null
41
+ ? '요구사항 충족률을 계산할 수 있는 매칭 정보가 아직 없습니다.'
42
+ : `저장된 키워드 기준 요구사항 충족률은 ${coverage}%입니다.`,
43
+ evidenceStrength >= 70
44
+ ? '강점과 근거가 비교적 충분합니다.'
45
+ : evidenceStrength >= 40
46
+ ? '일부 근거는 있지만 지원 문서에서 더 구체적으로 연결할 필요가 있습니다.'
47
+ : '현재 분석에는 구체적인 경험 근거가 부족합니다.',
48
+ risks.length > 0 ? `확인할 위험 요소가 ${risks.length}개 있습니다.` : '명시된 주요 위험 요소는 없습니다.',
49
+ ];
50
+
51
+ return {
52
+ job_id: jobId,
53
+ overall_score: fit.score,
54
+ requirement_coverage: coverage,
55
+ evidence_strength: evidenceStrength,
56
+ matched_requirements: matched,
57
+ missing_requirements: missing,
58
+ strengths: fit.strengths ?? [],
59
+ risks,
60
+ recommended_actions: actions,
61
+ explanation: explanationParts.join(' '),
62
+ };
63
+ }
@@ -8,4 +8,7 @@ export * from './services.ts';
8
8
  export * from './summary.ts';
9
9
  export * from './jobsearch.ts';
10
10
  export * from './offers.ts';
11
+ export * from './data-health.ts';
12
+ export * from './fit-explain.ts';
13
+ export * from './reuse-insights.ts';
11
14
  export * from './verify/index.ts';
@@ -2,49 +2,36 @@
2
2
  * Job search import — pull postings from key-free public job-board endpoints by
3
3
  * keyword, so the AI can offer real listings the user can save into CareerMate.
4
4
  *
5
- * Scope & boundaries (deliberate):
6
- * - INBOUND only. We fetch publicly visible postings; we send only the search
7
- * keyword, never the user's stored data. (Privacy = no user data leaves; this
8
- * does not violate it.)
9
- * - No WAF bypass, no auth keys. These providers' own frontends call these JSON
10
- * endpoints unauthenticated; a plain fetch suffices. We do NOT bundle a
11
- * scraper/anti-bot evasion (that's the legally dangerous part — see
12
- * .claude/work/research/research_0004). Sites requiring keys (Saramin oapi,
13
- * 고용24) or that forbid it in robots.txt (JobKorea search) are excluded here.
14
- * - Honest User-Agent. We identify ourselves as careermate (verified live:
15
- * both providers answer 200 to it), never as a spoofed browser — "no bot
16
- * evasion" must include not lying about who is asking.
17
- * - Read-only w.r.t. the DB. This returns candidates; the AI shows them and the
18
- * user picks which to save via save_job_posting (no auto-dumping into the DB).
19
- *
20
- * Providers are unofficial public endpoints — if a response shape changes, only
21
- * the affected provider's parser needs updating (others keep working).
5
+ * CareerMate is local-first: only the search keyword leaves the machine. Stored
6
+ * profile, résumé, application, and document data are never sent to providers.
7
+ * Provider endpoints are unofficial and therefore validated at runtime; a shape
8
+ * change degrades only that provider instead of silently returning corrupt rows.
22
9
  */
23
10
 
11
+ import { z } from 'zod';
24
12
  import { APP_VERSION } from '@careermate/shared';
25
13
 
26
14
  export interface JobSearchResult {
27
- /** Provider id, e.g. 'wanted' | 'jumpit'. */
28
15
  source: string;
29
16
  company: string;
30
17
  position: string;
31
- /** Canonical posting URL the user can open. */
32
18
  url: string;
33
19
  location?: string;
34
- /** Required experience, human-readable (e.g. '신입', '5~20년'). */
35
20
  experience?: string;
36
- /** Tech stack / tags when the provider exposes them. */
37
21
  keywords?: string[];
38
- /** Application deadline as YYYY-MM-DD when known. */
39
22
  deadline?: string;
40
- /** Provider-native id (for dedup / detail fetch). */
41
23
  external_id: string;
42
24
  }
43
25
 
26
+ export type ProviderStatus = 'healthy' | 'degraded' | 'unavailable';
27
+
44
28
  export interface ProviderOutcome {
45
29
  source: string;
30
+ label: string;
46
31
  ok: boolean;
32
+ status: ProviderStatus;
47
33
  count: number;
34
+ checked_at: string;
48
35
  error?: string;
49
36
  }
50
37
 
@@ -55,24 +42,20 @@ export interface JobSearchResponse {
55
42
  }
56
43
 
57
44
  export interface JobSearchOptions {
58
- /** Which providers to query. Defaults to all registered. */
59
45
  sources?: string[];
60
- /** Max results per provider (1–50). Default 10. */
61
46
  limit?: number;
62
- /** Per-request timeout in ms. Default 10000. */
63
47
  timeoutMs?: number;
64
48
  }
65
49
 
66
50
  const UA = `careermate/${APP_VERSION} (+https://careermate.life)`;
67
51
 
68
- /** Slice an ISO timestamp / date string to YYYY-MM-DD; undefined if not a date. */
69
52
  function toYmd(v: unknown): string | undefined {
70
53
  if (typeof v !== 'string' || v.length < 10) return undefined;
71
54
  const m = v.match(/^(\d{4}-\d{2}-\d{2})/);
72
55
  return m ? m[1] : undefined;
73
56
  }
74
57
 
75
- async function getJson(url: string, timeoutMs: number): Promise<any> {
58
+ async function getJson(url: string, timeoutMs: number): Promise<unknown> {
76
59
  const ctrl = new AbortController();
77
60
  const timer = setTimeout(() => ctrl.abort(), timeoutMs);
78
61
  try {
@@ -81,6 +64,8 @@ async function getJson(url: string, timeoutMs: number): Promise<any> {
81
64
  signal: ctrl.signal,
82
65
  });
83
66
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
67
+ const type = res.headers.get('content-type') ?? '';
68
+ if (!type.includes('json')) throw new Error(`unexpected content-type: ${type || 'unknown'}`);
84
69
  return await res.json();
85
70
  } finally {
86
71
  clearTimeout(timer);
@@ -93,7 +78,41 @@ interface Provider {
93
78
  search(query: string, limit: number, timeoutMs: number): Promise<JobSearchResult[]>;
94
79
  }
95
80
 
96
- /** Wanted (wanted.co.kr) — public v4 jobs JSON, no auth. IT/startup roles. */
81
+ const wantedSchema = z.object({
82
+ data: z.array(
83
+ z.object({
84
+ id: z.union([z.string(), z.number()]),
85
+ position: z.string().min(1),
86
+ company: z.object({ name: z.string().min(1) }),
87
+ annual_from: z.number().nullable().optional(),
88
+ annual_to: z.number().nullable().optional(),
89
+ due_time: z.string().nullable().optional(),
90
+ address: z
91
+ .object({ location: z.string().optional(), full_location: z.string().optional() })
92
+ .nullable()
93
+ .optional(),
94
+ }).passthrough(),
95
+ ),
96
+ }).passthrough();
97
+
98
+ const jumpitSchema = z.object({
99
+ result: z.object({
100
+ positions: z.array(
101
+ z.object({
102
+ id: z.union([z.string(), z.number()]),
103
+ companyName: z.string().min(1),
104
+ title: z.string().min(1),
105
+ locations: z.array(z.string()).optional(),
106
+ newcomer: z.boolean().optional(),
107
+ minCareer: z.number().nullable().optional(),
108
+ maxCareer: z.number().nullable().optional(),
109
+ techStacks: z.array(z.string()).optional(),
110
+ closedAt: z.string().nullable().optional(),
111
+ }).passthrough(),
112
+ ),
113
+ }).passthrough(),
114
+ }).passthrough();
115
+
97
116
  const wanted: Provider = {
98
117
  id: 'wanted',
99
118
  label: '원티드',
@@ -101,81 +120,103 @@ const wanted: Provider = {
101
120
  const url =
102
121
  `https://www.wanted.co.kr/api/v4/jobs?country=kr&locations=all&years=-1` +
103
122
  `&job_sort=job.latest_order&limit=${limit}&offset=0&query=${encodeURIComponent(query)}`;
104
- const j = await getJson(url, timeoutMs);
105
- const rows: any[] = Array.isArray(j?.data) ? j.data : [];
106
- return rows.map((r) => {
123
+ const j = wantedSchema.parse(await getJson(url, timeoutMs));
124
+ return j.data.map((r) => {
107
125
  const annual =
108
126
  r.annual_from || r.annual_to
109
127
  ? `${r.annual_from ?? 0}~${r.annual_to ?? '?'}년`
110
128
  : undefined;
111
129
  return {
112
130
  source: 'wanted',
113
- company: r.company?.name ?? '',
114
- position: r.position ?? '',
131
+ company: r.company.name,
132
+ position: r.position,
115
133
  url: `https://www.wanted.co.kr/wd/${r.id}`,
116
- location: r.address?.location ?? r.address?.full_location ?? undefined,
134
+ location: r.address?.location ?? r.address?.full_location,
117
135
  experience: annual,
118
136
  deadline: toYmd(r.due_time),
119
137
  external_id: String(r.id),
120
- } as JobSearchResult;
138
+ };
121
139
  });
122
140
  },
123
141
  };
124
142
 
125
- /** Jumpit (jumpit.co.kr) — public positions JSON, no auth. Dev roles, tech stacks. */
126
143
  const jumpit: Provider = {
127
144
  id: 'jumpit',
128
145
  label: '점핏',
129
146
  async search(query, limit, timeoutMs) {
130
147
  const url =
131
148
  `https://api.jumpit.co.kr/api/positions?sort=rsp_rate&page=1&keyword=${encodeURIComponent(query)}`;
132
- const j = await getJson(url, timeoutMs);
133
- const rows: any[] = Array.isArray(j?.result?.positions) ? j.result.positions : [];
134
- return rows.slice(0, limit).map((p) => ({
149
+ const j = jumpitSchema.parse(await getJson(url, timeoutMs));
150
+ return j.result.positions.slice(0, limit).map((p) => ({
135
151
  source: 'jumpit',
136
- company: p.companyName ?? '',
137
- position: p.title ?? '',
152
+ company: p.companyName,
153
+ position: p.title,
138
154
  url: `https://www.jumpit.co.kr/position/${p.id}`,
139
- location: Array.isArray(p.locations) ? p.locations[0] : undefined,
155
+ location: p.locations?.[0],
140
156
  experience: p.newcomer
141
157
  ? '신입'
142
158
  : p.minCareer != null
143
159
  ? `${p.minCareer}~${p.maxCareer ?? p.minCareer}년`
144
160
  : undefined,
145
- keywords: Array.isArray(p.techStacks) ? p.techStacks : undefined,
161
+ keywords: p.techStacks,
146
162
  deadline: toYmd(p.closedAt),
147
163
  external_id: String(p.id),
148
- } as JobSearchResult));
164
+ }));
149
165
  },
150
166
  };
151
167
 
152
168
  const PROVIDERS: Provider[] = [wanted, jumpit];
153
-
154
- /** Registered provider ids (key-free, no WAF bypass). */
155
169
  export const JOB_SEARCH_SOURCES = PROVIDERS.map((p) => p.id);
156
170
 
157
- /**
158
- * Search key-free public job boards by keyword. Each provider runs independently;
159
- * one failing (network/timeout/shape change) does not sink the others. Results
160
- * are returned for the AI to present — saving is a separate explicit step.
161
- */
171
+ function providerError(e: unknown): { status: ProviderStatus; error: string } {
172
+ if (e instanceof z.ZodError) {
173
+ const first = e.issues[0];
174
+ return {
175
+ status: 'degraded',
176
+ error: `provider response changed${first ? ` (${first.path.join('.') || 'root'})` : ''}`,
177
+ };
178
+ }
179
+ const message = e instanceof Error ? e.message : String(e);
180
+ return { status: 'unavailable', error: message };
181
+ }
182
+
162
183
  export async function searchJobs(query: string, opts: JobSearchOptions = {}): Promise<JobSearchResponse> {
163
184
  const q = (query ?? '').trim();
164
185
  if (!q) return { query: '', results: [], providers: [] };
165
186
  const limit = Math.min(50, Math.max(1, opts.limit ?? 10));
166
- const timeoutMs = opts.timeoutMs ?? 10000;
187
+ const timeoutMs = Math.min(30_000, Math.max(1_000, opts.timeoutMs ?? 10_000));
167
188
  const chosen = opts.sources?.length
168
189
  ? PROVIDERS.filter((p) => opts.sources!.includes(p.id))
169
190
  : PROVIDERS;
170
191
 
171
192
  const settled = await Promise.all(
172
193
  chosen.map(async (p): Promise<{ outcome: ProviderOutcome; results: JobSearchResult[] }> => {
194
+ const checked_at = new Date().toISOString();
173
195
  try {
174
196
  const results = await p.search(q, limit, timeoutMs);
175
- return { outcome: { source: p.id, ok: true, count: results.length }, results };
197
+ return {
198
+ outcome: {
199
+ source: p.id,
200
+ label: p.label,
201
+ ok: true,
202
+ status: 'healthy',
203
+ count: results.length,
204
+ checked_at,
205
+ },
206
+ results,
207
+ };
176
208
  } catch (e) {
209
+ const failure = providerError(e);
177
210
  return {
178
- outcome: { source: p.id, ok: false, count: 0, error: e instanceof Error ? e.message : String(e) },
211
+ outcome: {
212
+ source: p.id,
213
+ label: p.label,
214
+ ok: false,
215
+ status: failure.status,
216
+ count: 0,
217
+ checked_at,
218
+ error: failure.error,
219
+ },
179
220
  results: [],
180
221
  };
181
222
  }