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.
Files changed (71) hide show
  1. package/AGENTS.md +2 -2
  2. package/LICENSE +1 -1
  3. package/README.md +108 -91
  4. package/THIRD_PARTY_NOTICES.md +2 -1
  5. package/bin/careermate.mjs +11 -2
  6. package/dist/career-os/eop/cover-letter.md +13 -6
  7. package/dist/career-os/eop/interview-prep.md +1 -0
  8. package/dist/career-os/knowledge/cover-letter.md +7 -3
  9. package/dist/career-os/knowledge/fit-matching.md +2 -0
  10. package/dist/career-os/knowledge/verifiers/ai-detection-risk.md +57 -0
  11. package/dist/career-os/knowledge/verifiers/human-voice.md +7 -2
  12. package/dist/career-os/knowledge/verifiers/truthfulness.md +1 -0
  13. package/dist/doctor.mjs +19760 -1018
  14. package/dist/mcp.mjs +19783 -1035
  15. package/dist/migrate.mjs +289 -20
  16. package/dist/public/i18n/en/applications.js +19 -0
  17. package/dist/public/i18n/en/common.js +10 -0
  18. package/dist/public/i18n/en/documents.js +0 -10
  19. package/dist/public/i18n/en/home.js +5 -0
  20. package/dist/public/i18n/en/jobs.js +18 -1
  21. package/dist/public/i18n/en/profile.js +0 -5
  22. package/dist/public/i18n/en/settings.js +5 -2
  23. package/dist/public/i18n/ko/applications.js +19 -0
  24. package/dist/public/i18n/ko/common.js +9 -0
  25. package/dist/public/i18n/ko/documents.js +0 -10
  26. package/dist/public/i18n/ko/home.js +5 -0
  27. package/dist/public/i18n/ko/jobs.js +18 -1
  28. package/dist/public/i18n/ko/profile.js +0 -5
  29. package/dist/public/i18n/ko/settings.js +5 -2
  30. package/dist/public/lib.js +101 -0
  31. package/dist/public/pages/applications.js +95 -15
  32. package/dist/public/pages/documents.js +20 -14
  33. package/dist/public/pages/home.js +38 -6
  34. package/dist/public/pages/interview.js +11 -4
  35. package/dist/public/pages/jobs.js +71 -1
  36. package/dist/public/pages/profile.js +2 -3
  37. package/dist/public/pages/settings.js +2 -2
  38. package/dist/public/styles.css +52 -4
  39. package/dist/web.mjs +19001 -758
  40. package/docs/FAQ.md +13 -3
  41. package/docs/MCP_TOOLS.md +32 -12
  42. package/docs/career-os/eop/cover-letter.md +13 -6
  43. package/docs/career-os/eop/interview-prep.md +1 -0
  44. package/docs/career-os/knowledge/cover-letter.md +7 -3
  45. package/docs/career-os/knowledge/fit-matching.md +2 -0
  46. package/docs/career-os/knowledge/verifiers/ai-detection-risk.md +57 -0
  47. package/docs/career-os/knowledge/verifiers/human-voice.md +7 -2
  48. package/docs/career-os/knowledge/verifiers/truthfulness.md +1 -0
  49. package/package.json +5 -2
  50. package/packages/core/src/index.ts +2 -0
  51. package/packages/core/src/jobsearch.ts +190 -0
  52. package/packages/core/src/offers.ts +41 -0
  53. package/packages/core/src/services.ts +29 -3
  54. package/packages/core/src/summary.ts +431 -9
  55. package/packages/core/src/verify/charcount.ts +87 -0
  56. package/packages/core/src/verify/index.ts +1 -0
  57. package/packages/core/src/verify/lint.ts +5 -0
  58. package/packages/db/src/connection.ts +70 -2
  59. package/packages/db/src/index.ts +1 -0
  60. package/packages/db/src/repositories.ts +179 -13
  61. package/packages/db/src/schema.ts +169 -8
  62. package/packages/exporters/src/docx.ts +165 -0
  63. package/packages/exporters/src/index.ts +142 -9
  64. package/packages/exporters/src/markdown.ts +16 -11
  65. package/packages/knowledge/src/index.ts +8 -3
  66. package/packages/mcp-tools/src/result.ts +9 -0
  67. package/packages/mcp-tools/src/tools.ts +303 -60
  68. package/packages/parsers/src/documents.ts +7 -1
  69. package/packages/prompts/src/humanize.ts +4 -0
  70. package/packages/shared/src/schemas.ts +198 -0
  71. 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
- job_id: input.job_id ?? null,
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
- `${coverLetter.title} 자기소개서 v${version.version_no}를 저장했습니다.`,
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
  });