careermate 0.0.7 → 0.0.9
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.
- package/AGENTS.md +2 -2
- package/LICENSE +1 -1
- package/README.md +108 -91
- package/THIRD_PARTY_NOTICES.md +2 -1
- package/bin/careermate.mjs +11 -2
- package/dist/career-os/eop/cover-letter.md +13 -6
- package/dist/career-os/eop/interview-prep.md +1 -0
- package/dist/career-os/knowledge/cover-letter.md +7 -3
- package/dist/career-os/knowledge/fit-matching.md +2 -0
- package/dist/career-os/knowledge/verifiers/ai-detection-risk.md +57 -0
- package/dist/career-os/knowledge/verifiers/human-voice.md +7 -2
- package/dist/career-os/knowledge/verifiers/truthfulness.md +1 -0
- package/dist/doctor.mjs +19760 -1018
- package/dist/mcp.mjs +19783 -1035
- package/dist/migrate.mjs +289 -20
- package/dist/public/i18n/en/applications.js +19 -0
- package/dist/public/i18n/en/common.js +10 -0
- package/dist/public/i18n/en/documents.js +0 -10
- package/dist/public/i18n/en/home.js +5 -0
- package/dist/public/i18n/en/jobs.js +18 -1
- package/dist/public/i18n/en/profile.js +0 -5
- package/dist/public/i18n/en/settings.js +5 -2
- package/dist/public/i18n/ko/applications.js +19 -0
- package/dist/public/i18n/ko/common.js +9 -0
- package/dist/public/i18n/ko/documents.js +0 -10
- package/dist/public/i18n/ko/home.js +5 -0
- package/dist/public/i18n/ko/jobs.js +18 -1
- package/dist/public/i18n/ko/profile.js +0 -5
- package/dist/public/i18n/ko/settings.js +5 -2
- package/dist/public/lib.js +101 -0
- package/dist/public/pages/applications.js +95 -15
- package/dist/public/pages/documents.js +20 -14
- package/dist/public/pages/home.js +38 -6
- package/dist/public/pages/interview.js +11 -4
- package/dist/public/pages/jobs.js +71 -1
- package/dist/public/pages/profile.js +2 -3
- package/dist/public/pages/settings.js +2 -2
- package/dist/public/styles.css +52 -4
- package/dist/web.mjs +19001 -758
- package/docs/FAQ.md +13 -3
- package/docs/MCP_TOOLS.md +32 -12
- package/docs/career-os/eop/cover-letter.md +13 -6
- package/docs/career-os/eop/interview-prep.md +1 -0
- package/docs/career-os/knowledge/cover-letter.md +7 -3
- package/docs/career-os/knowledge/fit-matching.md +2 -0
- package/docs/career-os/knowledge/verifiers/ai-detection-risk.md +57 -0
- package/docs/career-os/knowledge/verifiers/human-voice.md +7 -2
- package/docs/career-os/knowledge/verifiers/truthfulness.md +1 -0
- package/package.json +5 -2
- package/packages/core/src/index.ts +2 -0
- package/packages/core/src/jobsearch.ts +190 -0
- package/packages/core/src/offers.ts +41 -0
- package/packages/core/src/services.ts +29 -3
- package/packages/core/src/summary.ts +431 -9
- package/packages/core/src/verify/charcount.ts +87 -0
- package/packages/core/src/verify/index.ts +1 -0
- package/packages/core/src/verify/lint.ts +5 -0
- package/packages/db/src/connection.ts +70 -2
- package/packages/db/src/index.ts +1 -0
- package/packages/db/src/repositories.ts +179 -13
- package/packages/db/src/schema.ts +169 -8
- package/packages/exporters/src/docx.ts +165 -0
- package/packages/exporters/src/index.ts +142 -9
- package/packages/exporters/src/markdown.ts +16 -11
- package/packages/knowledge/src/index.ts +8 -3
- package/packages/mcp-tools/src/result.ts +9 -0
- package/packages/mcp-tools/src/tools.ts +303 -60
- package/packages/parsers/src/documents.ts +7 -1
- package/packages/prompts/src/humanize.ts +4 -0
- package/packages/shared/src/schemas.ts +198 -0
- package/scripts/init.ts +7 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Job search import — pull postings from key-free public job-board endpoints by
|
|
3
|
+
* keyword, so the AI can offer real listings the user can save into CareerMate.
|
|
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).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { APP_VERSION } from '@careermate/shared';
|
|
25
|
+
|
|
26
|
+
export interface JobSearchResult {
|
|
27
|
+
/** Provider id, e.g. 'wanted' | 'jumpit'. */
|
|
28
|
+
source: string;
|
|
29
|
+
company: string;
|
|
30
|
+
position: string;
|
|
31
|
+
/** Canonical posting URL the user can open. */
|
|
32
|
+
url: string;
|
|
33
|
+
location?: string;
|
|
34
|
+
/** Required experience, human-readable (e.g. '신입', '5~20년'). */
|
|
35
|
+
experience?: string;
|
|
36
|
+
/** Tech stack / tags when the provider exposes them. */
|
|
37
|
+
keywords?: string[];
|
|
38
|
+
/** Application deadline as YYYY-MM-DD when known. */
|
|
39
|
+
deadline?: string;
|
|
40
|
+
/** Provider-native id (for dedup / detail fetch). */
|
|
41
|
+
external_id: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ProviderOutcome {
|
|
45
|
+
source: string;
|
|
46
|
+
ok: boolean;
|
|
47
|
+
count: number;
|
|
48
|
+
error?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface JobSearchResponse {
|
|
52
|
+
query: string;
|
|
53
|
+
results: JobSearchResult[];
|
|
54
|
+
providers: ProviderOutcome[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface JobSearchOptions {
|
|
58
|
+
/** Which providers to query. Defaults to all registered. */
|
|
59
|
+
sources?: string[];
|
|
60
|
+
/** Max results per provider (1–50). Default 10. */
|
|
61
|
+
limit?: number;
|
|
62
|
+
/** Per-request timeout in ms. Default 10000. */
|
|
63
|
+
timeoutMs?: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const UA = `careermate/${APP_VERSION} (+https://careermate.life)`;
|
|
67
|
+
|
|
68
|
+
/** Slice an ISO timestamp / date string to YYYY-MM-DD; undefined if not a date. */
|
|
69
|
+
function toYmd(v: unknown): string | undefined {
|
|
70
|
+
if (typeof v !== 'string' || v.length < 10) return undefined;
|
|
71
|
+
const m = v.match(/^(\d{4}-\d{2}-\d{2})/);
|
|
72
|
+
return m ? m[1] : undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function getJson(url: string, timeoutMs: number): Promise<any> {
|
|
76
|
+
const ctrl = new AbortController();
|
|
77
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
78
|
+
try {
|
|
79
|
+
const res = await fetch(url, {
|
|
80
|
+
headers: { accept: 'application/json', 'user-agent': UA, 'accept-language': 'ko,en;q=0.8' },
|
|
81
|
+
signal: ctrl.signal,
|
|
82
|
+
});
|
|
83
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
84
|
+
return await res.json();
|
|
85
|
+
} finally {
|
|
86
|
+
clearTimeout(timer);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface Provider {
|
|
91
|
+
id: string;
|
|
92
|
+
label: string;
|
|
93
|
+
search(query: string, limit: number, timeoutMs: number): Promise<JobSearchResult[]>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Wanted (wanted.co.kr) — public v4 jobs JSON, no auth. IT/startup roles. */
|
|
97
|
+
const wanted: Provider = {
|
|
98
|
+
id: 'wanted',
|
|
99
|
+
label: '원티드',
|
|
100
|
+
async search(query, limit, timeoutMs) {
|
|
101
|
+
const url =
|
|
102
|
+
`https://www.wanted.co.kr/api/v4/jobs?country=kr&locations=all&years=-1` +
|
|
103
|
+
`&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) => {
|
|
107
|
+
const annual =
|
|
108
|
+
r.annual_from || r.annual_to
|
|
109
|
+
? `${r.annual_from ?? 0}~${r.annual_to ?? '?'}년`
|
|
110
|
+
: undefined;
|
|
111
|
+
return {
|
|
112
|
+
source: 'wanted',
|
|
113
|
+
company: r.company?.name ?? '',
|
|
114
|
+
position: r.position ?? '',
|
|
115
|
+
url: `https://www.wanted.co.kr/wd/${r.id}`,
|
|
116
|
+
location: r.address?.location ?? r.address?.full_location ?? undefined,
|
|
117
|
+
experience: annual,
|
|
118
|
+
deadline: toYmd(r.due_time),
|
|
119
|
+
external_id: String(r.id),
|
|
120
|
+
} as JobSearchResult;
|
|
121
|
+
});
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** Jumpit (jumpit.co.kr) — public positions JSON, no auth. Dev roles, tech stacks. */
|
|
126
|
+
const jumpit: Provider = {
|
|
127
|
+
id: 'jumpit',
|
|
128
|
+
label: '점핏',
|
|
129
|
+
async search(query, limit, timeoutMs) {
|
|
130
|
+
const url =
|
|
131
|
+
`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) => ({
|
|
135
|
+
source: 'jumpit',
|
|
136
|
+
company: p.companyName ?? '',
|
|
137
|
+
position: p.title ?? '',
|
|
138
|
+
url: `https://www.jumpit.co.kr/position/${p.id}`,
|
|
139
|
+
location: Array.isArray(p.locations) ? p.locations[0] : undefined,
|
|
140
|
+
experience: p.newcomer
|
|
141
|
+
? '신입'
|
|
142
|
+
: p.minCareer != null
|
|
143
|
+
? `${p.minCareer}~${p.maxCareer ?? p.minCareer}년`
|
|
144
|
+
: undefined,
|
|
145
|
+
keywords: Array.isArray(p.techStacks) ? p.techStacks : undefined,
|
|
146
|
+
deadline: toYmd(p.closedAt),
|
|
147
|
+
external_id: String(p.id),
|
|
148
|
+
} as JobSearchResult));
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const PROVIDERS: Provider[] = [wanted, jumpit];
|
|
153
|
+
|
|
154
|
+
/** Registered provider ids (key-free, no WAF bypass). */
|
|
155
|
+
export const JOB_SEARCH_SOURCES = PROVIDERS.map((p) => p.id);
|
|
156
|
+
|
|
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
|
+
*/
|
|
162
|
+
export async function searchJobs(query: string, opts: JobSearchOptions = {}): Promise<JobSearchResponse> {
|
|
163
|
+
const q = (query ?? '').trim();
|
|
164
|
+
if (!q) return { query: '', results: [], providers: [] };
|
|
165
|
+
const limit = Math.min(50, Math.max(1, opts.limit ?? 10));
|
|
166
|
+
const timeoutMs = opts.timeoutMs ?? 10000;
|
|
167
|
+
const chosen = opts.sources?.length
|
|
168
|
+
? PROVIDERS.filter((p) => opts.sources!.includes(p.id))
|
|
169
|
+
: PROVIDERS;
|
|
170
|
+
|
|
171
|
+
const settled = await Promise.all(
|
|
172
|
+
chosen.map(async (p): Promise<{ outcome: ProviderOutcome; results: JobSearchResult[] }> => {
|
|
173
|
+
try {
|
|
174
|
+
const results = await p.search(q, limit, timeoutMs);
|
|
175
|
+
return { outcome: { source: p.id, ok: true, count: results.length }, results };
|
|
176
|
+
} catch (e) {
|
|
177
|
+
return {
|
|
178
|
+
outcome: { source: p.id, ok: false, count: 0, error: e instanceof Error ? e.message : String(e) },
|
|
179
|
+
results: [],
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
}),
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
query: q,
|
|
187
|
+
results: settled.flatMap((s) => s.results),
|
|
188
|
+
providers: settled.map((s) => s.outcome),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offer comparison use-cases. CareerMate stores offers and a deterministic annual
|
|
3
|
+
* cash estimate (base+bonus+welfare); the connected AI writes the score/verdict at
|
|
4
|
+
* save time (no LLM here). compareOffers returns all offers joined with their job,
|
|
5
|
+
* pre-sorted by the AI's score — the dashboard renders that, and the head-to-head
|
|
6
|
+
* "which is better" reasoning happens in the AI chat via this same data.
|
|
7
|
+
*/
|
|
8
|
+
import { offerRepo, jobRepo } from '@careermate/db';
|
|
9
|
+
import type { OfferInput, OfferRecord, JobRecord } from '@careermate/shared';
|
|
10
|
+
|
|
11
|
+
export interface OfferWithJob extends OfferRecord {
|
|
12
|
+
company: string;
|
|
13
|
+
position: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function withJob(offer: OfferRecord, job: JobRecord | null): OfferWithJob {
|
|
17
|
+
return { ...offer, company: job?.company ?? '—', position: job?.position ?? '—' };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Save/update the offer for a job (one per job). The job must exist first. */
|
|
21
|
+
export function saveOffer(input: OfferInput): OfferWithJob {
|
|
22
|
+
const job = jobRepo.get(input.job_id);
|
|
23
|
+
if (!job) throw new Error('공고를 찾을 수 없습니다. 먼저 save_job_posting으로 공고를 저장하세요.');
|
|
24
|
+
return withJob(offerRepo.save(input), job);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The offer for one job (or null). */
|
|
28
|
+
export function getOffer(jobId: string): OfferWithJob | null {
|
|
29
|
+
const offer = offerRepo.getByJob(jobId);
|
|
30
|
+
if (!offer) return null;
|
|
31
|
+
return withJob(offer, jobRepo.get(jobId));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* All offers, joined with their job, sorted by the AI's score (desc, null last).
|
|
36
|
+
* `total_comp_annual_est` on each is the server's deterministic sum — the AI does
|
|
37
|
+
* the qualitative comparison from this structured data.
|
|
38
|
+
*/
|
|
39
|
+
export function compareOffers(): OfferWithJob[] {
|
|
40
|
+
return offerRepo.list().map((o) => withJob(o, jobRepo.get(o.job_id)));
|
|
41
|
+
}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
type CoverLetterVersionInput,
|
|
15
15
|
type ApplicationStatus,
|
|
16
16
|
type ApplicationSubmission,
|
|
17
|
+
type RejectedStage,
|
|
17
18
|
type ApplicationRecord,
|
|
18
19
|
type InterviewPrepInput,
|
|
19
20
|
type InterviewPrepRecord,
|
|
@@ -78,6 +79,22 @@ export function collectVerifyCorpus(jobId?: string | null): VerifyCorpus {
|
|
|
78
79
|
for (const s of skillRepo.list()) {
|
|
79
80
|
structured.push([s.name, s.level, s.years != null ? `${s.years}년` : ''].filter(Boolean).join(' '));
|
|
80
81
|
}
|
|
82
|
+
// Profile credentials (학점·어학·자격·수상) — so a cited GPA/score (3.8, TOEIC 920)
|
|
83
|
+
// traces to stored data and isn't hard-blocked as fabricated. Structured-only ⇒ advisory.
|
|
84
|
+
// Provenance matching is unit-keyed, and a purely numeric score is cited both bare
|
|
85
|
+
// ("920") and with the 점 counter ("920점"); emit both forms so neither false-fires.
|
|
86
|
+
const scoreForms = (s?: string): string =>
|
|
87
|
+
s && /^\d+(\.\d+)?$/.test(s.trim()) ? `${s.trim()} ${s.trim()}점` : (s ?? '');
|
|
88
|
+
const prof = profileRepo.get();
|
|
89
|
+
if (prof) {
|
|
90
|
+
for (const ed of prof.education) {
|
|
91
|
+
const gpa = ed.gpa ? `학점 ${ed.gpa}${ed.gpa_scale ? `/${ed.gpa_scale}` : ''}` : '';
|
|
92
|
+
structured.push([ed.school, ed.degree, ed.major, gpa, ed.status].filter(Boolean).join(' '));
|
|
93
|
+
}
|
|
94
|
+
for (const c of prof.certifications) structured.push([c.name, c.issuer, scoreForms(c.score)].filter(Boolean).join(' '));
|
|
95
|
+
for (const l of prof.language_scores) structured.push(`${l.test} ${scoreForms(l.score)}`);
|
|
96
|
+
for (const a of prof.awards) structured.push([a.title, a.issuer, a.description].filter(Boolean).join(' '));
|
|
97
|
+
}
|
|
81
98
|
const job = jobId ? jobRepo.get(jobId) : null;
|
|
82
99
|
const jobText = job ? [job.description, ...(job.requirements ?? []), ...(job.keywords ?? [])].filter(Boolean).join('\n') : '';
|
|
83
100
|
return { documents: documents.join('\n'), structured: structured.join('\n'), job: jobText };
|
|
@@ -280,7 +297,11 @@ export function saveCoverLetterVersion(input: CoverLetterVersionInput & { force?
|
|
|
280
297
|
const { coverLetter, version } = coverLetterRepo.addVersion({
|
|
281
298
|
cover_letter_id: input.cover_letter_id,
|
|
282
299
|
title: input.title,
|
|
283
|
-
|
|
300
|
+
// Pass job_id through verbatim — do NOT coerce undefined→null here. addVersion
|
|
301
|
+
// treats `undefined` as "leave the link alone" and `null` as "clear it"; the old
|
|
302
|
+
// `?? null` wiped a cover letter's job link whenever a new version was saved
|
|
303
|
+
// without re-specifying job_id (e.g. editing an existing letter).
|
|
304
|
+
job_id: input.job_id,
|
|
284
305
|
content: input.content,
|
|
285
306
|
note: input.note,
|
|
286
307
|
source: input.source ?? 'ai',
|
|
@@ -295,7 +316,9 @@ export function saveCoverLetterVersion(input: CoverLetterVersionInput & { force?
|
|
|
295
316
|
}
|
|
296
317
|
activityRepo.log(
|
|
297
318
|
'cover_letter_version_saved',
|
|
298
|
-
|
|
319
|
+
// Title already names the document (often ends in "자기소개서") — don't append the
|
|
320
|
+
// word again or it doubles ("…자기소개서 자기소개서 v2"). Title + version is enough.
|
|
321
|
+
`${coverLetter.title} v${version.version_no}를 저장했습니다.`,
|
|
299
322
|
'cover_letter',
|
|
300
323
|
coverLetter.id,
|
|
301
324
|
);
|
|
@@ -328,6 +351,7 @@ export function updateApplicationStatus(
|
|
|
328
351
|
status: ApplicationStatus,
|
|
329
352
|
note?: string,
|
|
330
353
|
submission?: ApplicationSubmission,
|
|
354
|
+
rejectedStage?: RejectedStage,
|
|
331
355
|
): StatusChangeResult {
|
|
332
356
|
const job = jobRepo.get(jobId);
|
|
333
357
|
if (!job) throw notFound('공고를 찾을 수 없습니다.');
|
|
@@ -336,7 +360,7 @@ export function updateApplicationStatus(
|
|
|
336
360
|
// together: if any write throws (e.g. a bad bind), roll back so we never leave
|
|
337
361
|
// the status changed but the submission/timeline missing.
|
|
338
362
|
const application = tx(() => {
|
|
339
|
-
let application = applicationRepo.setStatus(jobId, status, note);
|
|
363
|
+
let application = applicationRepo.setStatus(jobId, status, note, rejectedStage);
|
|
340
364
|
const submittedDocs = (submission?.document_ids ?? [])
|
|
341
365
|
.map((docId) => documentRepo.get(docId))
|
|
342
366
|
.filter((doc): doc is DocumentRecord => !!doc);
|
|
@@ -385,6 +409,8 @@ export function updateApplicationStatus(
|
|
|
385
409
|
status_label: APPLICATION_STATUS_LABELS[status],
|
|
386
410
|
note: note ?? null,
|
|
387
411
|
submission: submissionPayload,
|
|
412
|
+
// 탈락 회고: rejected로 넘어갈 때 실제 저장된 단계(setStatus가 정합성 처리한 값)를 타임라인에도 남긴다.
|
|
413
|
+
rejected_stage: application.rejected_stage,
|
|
388
414
|
},
|
|
389
415
|
occurred_at: submittedAtToOccurrence(submittedAt),
|
|
390
416
|
});
|