careermate 0.0.1
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 +33 -0
- package/INSTALL.md +230 -0
- package/LICENSE +21 -0
- package/README.md +532 -0
- package/THIRD_PARTY_NOTICES.md +57 -0
- package/bin/careermate.mjs +77 -0
- package/dist/career-os/README.md +78 -0
- package/dist/career-os/eop/cover-letter.md +46 -0
- package/dist/career-os/eop/fit-analysis.md +48 -0
- package/dist/career-os/eop/interview-prep.md +39 -0
- package/dist/career-os/eop/job-analysis.md +42 -0
- package/dist/career-os/eop/profile-extraction.md +37 -0
- package/dist/career-os/knowledge/ats.md +131 -0
- package/dist/career-os/knowledge/company-research.md +137 -0
- package/dist/career-os/knowledge/cover-letter.md +135 -0
- package/dist/career-os/knowledge/fit-matching.md +121 -0
- package/dist/career-os/knowledge/human-writing.md +118 -0
- package/dist/career-os/knowledge/interview-behavioral.md +132 -0
- package/dist/career-os/knowledge/interview-technical.md +126 -0
- package/dist/career-os/knowledge/linkedin-profile.md +152 -0
- package/dist/career-os/knowledge/networking-referrals.md +166 -0
- package/dist/career-os/knowledge/offer-evaluation-decision.md +121 -0
- package/dist/career-os/knowledge/onboarding-first-90-days.md +126 -0
- package/dist/career-os/knowledge/portfolio.md +121 -0
- package/dist/career-os/knowledge/recruiter-screen.md +105 -0
- package/dist/career-os/knowledge/rejection-triage-iteration.md +115 -0
- package/dist/career-os/knowledge/resume.md +123 -0
- package/dist/career-os/knowledge/salary-negotiation.md +123 -0
- package/dist/career-os/knowledge/verifiers/ats-compat.md +64 -0
- package/dist/career-os/knowledge/verifiers/consistency.md +61 -0
- package/dist/career-os/knowledge/verifiers/human-voice.md +82 -0
- package/dist/career-os/knowledge/verifiers/recency-staleness.md +72 -0
- package/dist/career-os/knowledge/verifiers/responsiveness-on-target.md +82 -0
- package/dist/career-os/knowledge/verifiers/truthfulness.md +67 -0
- package/dist/doctor.mjs +79094 -0
- package/dist/mcp.mjs +96108 -0
- package/dist/migrate.mjs +4735 -0
- package/dist/pdf.worker.min.mjs +28 -0
- package/dist/public/app.js +150 -0
- package/dist/public/fonts/PretendardVariable.woff2 +0 -0
- package/dist/public/icon-180.png +0 -0
- package/dist/public/index.html +52 -0
- package/dist/public/lib.js +394 -0
- package/dist/public/pages/applications.js +107 -0
- package/dist/public/pages/documents.js +467 -0
- package/dist/public/pages/home.js +261 -0
- package/dist/public/pages/interview.js +227 -0
- package/dist/public/pages/jobs.js +493 -0
- package/dist/public/pages/profile.js +556 -0
- package/dist/public/pages/settings.js +240 -0
- package/dist/public/styles.css +498 -0
- package/dist/site/analytics.js +97 -0
- package/dist/site/favicon.svg +1 -0
- package/dist/site/fonts/PretendardVariable.woff2 +0 -0
- package/dist/site/icon-180.png +0 -0
- package/dist/site/icon-512.png +0 -0
- package/dist/site/index.html +598 -0
- package/dist/site/landing.js +54 -0
- package/dist/site/llms-install.txt +217 -0
- package/dist/site/robots.txt +4 -0
- package/dist/site/sitemap.xml +21 -0
- package/dist/site/start.html +326 -0
- package/dist/site/style.css +550 -0
- package/dist/site/vercel.json +20 -0
- package/dist/web.mjs +46852 -0
- package/docs/FAQ.md +101 -0
- package/docs/MCP_TOOLS.md +311 -0
- package/docs/README.md +28 -0
- package/docs/START_WORKFLOW.md +137 -0
- package/docs/SUPPORTED_AI_APPS.md +112 -0
- package/docs/WORKFLOWS.md +202 -0
- package/docs/career-os/README.md +78 -0
- package/docs/career-os/eop/cover-letter.md +46 -0
- package/docs/career-os/eop/fit-analysis.md +48 -0
- package/docs/career-os/eop/interview-prep.md +39 -0
- package/docs/career-os/eop/job-analysis.md +42 -0
- package/docs/career-os/eop/profile-extraction.md +37 -0
- package/docs/career-os/knowledge/ats.md +131 -0
- package/docs/career-os/knowledge/company-research.md +137 -0
- package/docs/career-os/knowledge/cover-letter.md +135 -0
- package/docs/career-os/knowledge/fit-matching.md +121 -0
- package/docs/career-os/knowledge/human-writing.md +118 -0
- package/docs/career-os/knowledge/interview-behavioral.md +132 -0
- package/docs/career-os/knowledge/interview-technical.md +126 -0
- package/docs/career-os/knowledge/linkedin-profile.md +152 -0
- package/docs/career-os/knowledge/networking-referrals.md +166 -0
- package/docs/career-os/knowledge/offer-evaluation-decision.md +121 -0
- package/docs/career-os/knowledge/onboarding-first-90-days.md +126 -0
- package/docs/career-os/knowledge/portfolio.md +121 -0
- package/docs/career-os/knowledge/recruiter-screen.md +105 -0
- package/docs/career-os/knowledge/rejection-triage-iteration.md +115 -0
- package/docs/career-os/knowledge/resume.md +123 -0
- package/docs/career-os/knowledge/salary-negotiation.md +123 -0
- package/docs/career-os/knowledge/verifiers/ats-compat.md +64 -0
- package/docs/career-os/knowledge/verifiers/consistency.md +61 -0
- package/docs/career-os/knowledge/verifiers/human-voice.md +82 -0
- package/docs/career-os/knowledge/verifiers/recency-staleness.md +72 -0
- package/docs/career-os/knowledge/verifiers/responsiveness-on-target.md +82 -0
- package/docs/career-os/knowledge/verifiers/truthfulness.md +67 -0
- package/package.json +89 -0
- package/packages/core/src/context.ts +74 -0
- package/packages/core/src/index.ts +8 -0
- package/packages/core/src/onboarding.ts +81 -0
- package/packages/core/src/services.ts +187 -0
- package/packages/core/src/summary.ts +104 -0
- package/packages/db/src/connection.ts +76 -0
- package/packages/db/src/index.ts +23 -0
- package/packages/db/src/paths.ts +52 -0
- package/packages/db/src/repositories.ts +891 -0
- package/packages/db/src/runtime.ts +58 -0
- package/packages/db/src/schema.ts +307 -0
- package/packages/exporters/src/html.ts +110 -0
- package/packages/exporters/src/index.ts +364 -0
- package/packages/exporters/src/markdown.ts +178 -0
- package/packages/knowledge/src/index.ts +168 -0
- package/packages/mcp-tools/src/bridge.ts +106 -0
- package/packages/mcp-tools/src/index.ts +17 -0
- package/packages/mcp-tools/src/result.ts +49 -0
- package/packages/mcp-tools/src/tools.ts +747 -0
- package/packages/mcp-tools/src/update.ts +330 -0
- package/packages/parsers/src/documents.ts +533 -0
- package/packages/parsers/src/html.ts +86 -0
- package/packages/parsers/src/index.ts +231 -0
- package/packages/parsers/src/keywords.ts +151 -0
- package/packages/prompts/src/humanize.ts +59 -0
- package/packages/prompts/src/index.ts +82 -0
- package/packages/prompts/src/install.ts +43 -0
- package/packages/prompts/src/onboarding.ts +35 -0
- package/packages/prompts/src/system.ts +57 -0
- package/packages/shared/src/build-info.ts +22 -0
- package/packages/shared/src/enums.ts +142 -0
- package/packages/shared/src/index.ts +19 -0
- package/packages/shared/src/schemas.ts +453 -0
- package/packages/workflows/src/definitions.ts +109 -0
- package/packages/workflows/src/index.ts +39 -0
- package/scripts/init.ts +371 -0
- package/tsconfig.json +32 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @careermate/exporters — turn CareerMate records into downloadable documents.
|
|
3
|
+
*
|
|
4
|
+
* Every exporter returns an {@link ExportResult} (`{ filename, mimeType, content }`).
|
|
5
|
+
* Markdown exporters emit `text/markdown`; HTML exporters emit a standalone,
|
|
6
|
+
* print-optimized document (`text/html`) the user can "Print → Save as PDF".
|
|
7
|
+
*
|
|
8
|
+
* Filenames are slugified and ASCII-fallback safe; they carry NO timestamp —
|
|
9
|
+
* the caller is expected to add one if desired.
|
|
10
|
+
*/
|
|
11
|
+
import type {
|
|
12
|
+
CoverLetterRecord,
|
|
13
|
+
DocumentRecord,
|
|
14
|
+
ExperienceRecord,
|
|
15
|
+
InterviewPrepRecord,
|
|
16
|
+
JobRecord,
|
|
17
|
+
ProfileRecord,
|
|
18
|
+
ProjectRecord,
|
|
19
|
+
SkillRecord,
|
|
20
|
+
} from '@careermate/shared';
|
|
21
|
+
import { DOCUMENT_KIND_LABELS } from '@careermate/shared';
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
bulletList,
|
|
25
|
+
dateRange,
|
|
26
|
+
joinBlocks,
|
|
27
|
+
kv,
|
|
28
|
+
slugify,
|
|
29
|
+
} from './markdown.ts';
|
|
30
|
+
import { escapeHtml, toPrintableHtml } from './html.ts';
|
|
31
|
+
|
|
32
|
+
/** The uniform shape every exporter returns. */
|
|
33
|
+
export interface ExportResult {
|
|
34
|
+
/** Safe, slugified base filename including extension (no timestamp). */
|
|
35
|
+
filename: string;
|
|
36
|
+
/** MIME type, e.g. `text/markdown` or `text/html`. */
|
|
37
|
+
mimeType: string;
|
|
38
|
+
/** The document body. */
|
|
39
|
+
content: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type ExportOptions = {
|
|
43
|
+
job?: JobRecord | null;
|
|
44
|
+
profile?: ProfileRecord | null;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const MD_MIME = 'text/markdown';
|
|
48
|
+
const HTML_MIME = 'text/html';
|
|
49
|
+
|
|
50
|
+
/* ------------------------------------------------------------- cover letters */
|
|
51
|
+
|
|
52
|
+
/** Build the shared markdown body for a cover letter (used by MD + HTML). */
|
|
53
|
+
function coverLetterBody(cl: CoverLetterRecord, opts?: ExportOptions): string {
|
|
54
|
+
const job = opts?.job ?? null;
|
|
55
|
+
const profile = opts?.profile ?? null;
|
|
56
|
+
|
|
57
|
+
const meta: string[] = [];
|
|
58
|
+
if (profile?.name) meta.push(kv('지원자', profile.name));
|
|
59
|
+
if (job) meta.push(kv('지원 회사', job.company), kv('지원 직무', job.position));
|
|
60
|
+
|
|
61
|
+
const content = (cl.current_content ?? '').trim() || '_(내용 없음)_';
|
|
62
|
+
|
|
63
|
+
return joinBlocks([
|
|
64
|
+
`# ${cl.title}`,
|
|
65
|
+
meta.length ? meta.filter(Boolean).join('\n\n') : '',
|
|
66
|
+
meta.length ? '---' : '',
|
|
67
|
+
content,
|
|
68
|
+
]);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Cover letter → Markdown. */
|
|
72
|
+
export function coverLetterToMarkdown(
|
|
73
|
+
cl: CoverLetterRecord,
|
|
74
|
+
opts?: ExportOptions,
|
|
75
|
+
): ExportResult {
|
|
76
|
+
return {
|
|
77
|
+
filename: `${slugify(cl.title, 'cover-letter')}.md`,
|
|
78
|
+
mimeType: MD_MIME,
|
|
79
|
+
content: coverLetterBody(cl, opts),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Cover letter → standalone, print-optimized HTML (our PDF strategy). */
|
|
84
|
+
export function coverLetterToHtml(
|
|
85
|
+
cl: CoverLetterRecord,
|
|
86
|
+
opts?: ExportOptions,
|
|
87
|
+
): ExportResult {
|
|
88
|
+
return {
|
|
89
|
+
filename: `${slugify(cl.title, 'cover-letter')}.html`,
|
|
90
|
+
mimeType: HTML_MIME,
|
|
91
|
+
content: toPrintableHtml(cl.title, coverLetterBody(cl, opts)),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* -------------------------------------------------------------------- resume */
|
|
96
|
+
|
|
97
|
+
/** A stored resume/career document → Markdown. */
|
|
98
|
+
export function resumeToMarkdown(
|
|
99
|
+
doc: DocumentRecord,
|
|
100
|
+
profile?: ProfileRecord | null,
|
|
101
|
+
): ExportResult {
|
|
102
|
+
const kindLabel = DOCUMENT_KIND_LABELS[doc.kind] ?? doc.kind;
|
|
103
|
+
|
|
104
|
+
const header: string[] = [];
|
|
105
|
+
if (profile?.name) header.push(kv('이름', profile.name));
|
|
106
|
+
if (profile?.email) header.push(kv('이메일', profile.email));
|
|
107
|
+
if (profile?.phone) header.push(kv('연락처', profile.phone));
|
|
108
|
+
|
|
109
|
+
const body = joinBlocks([
|
|
110
|
+
`# ${doc.title}`,
|
|
111
|
+
`_${kindLabel}_`,
|
|
112
|
+
header.length ? header.filter(Boolean).join('\n\n') : '',
|
|
113
|
+
header.length ? '---' : '',
|
|
114
|
+
(doc.content ?? '').trim() || '_(내용 없음)_',
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
filename: `${slugify(doc.title, doc.kind)}.md`,
|
|
119
|
+
mimeType: MD_MIME,
|
|
120
|
+
content: body,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A stored resume/career document → print-optimized HTML. */
|
|
125
|
+
export function resumeToHtml(
|
|
126
|
+
doc: DocumentRecord,
|
|
127
|
+
profile?: ProfileRecord | null,
|
|
128
|
+
): ExportResult {
|
|
129
|
+
const md = resumeToMarkdown(doc, profile);
|
|
130
|
+
return {
|
|
131
|
+
filename: `${slugify(doc.title, doc.kind)}.html`,
|
|
132
|
+
mimeType: HTML_MIME,
|
|
133
|
+
content: toPrintableHtml(doc.title, md.content),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/* ------------------------------------------------------------------- profile */
|
|
138
|
+
|
|
139
|
+
/** Full resume-style profile export → Markdown. */
|
|
140
|
+
export function profileToMarkdown(
|
|
141
|
+
profile: ProfileRecord,
|
|
142
|
+
experiences: ExperienceRecord[] = [],
|
|
143
|
+
projects: ProjectRecord[] = [],
|
|
144
|
+
skills: SkillRecord[] = [],
|
|
145
|
+
): ExportResult {
|
|
146
|
+
const name = profile.name?.trim() || '이력서';
|
|
147
|
+
|
|
148
|
+
// Header block.
|
|
149
|
+
const contact: string[] = [];
|
|
150
|
+
if (profile.email) contact.push(profile.email);
|
|
151
|
+
if (profile.phone) contact.push(profile.phone);
|
|
152
|
+
if (profile.location) contact.push(profile.location);
|
|
153
|
+
const links = (profile.links ?? [])
|
|
154
|
+
.filter((l) => l.url)
|
|
155
|
+
.map((l) => `[${l.label || l.url}](${l.url})`);
|
|
156
|
+
|
|
157
|
+
const headerBlocks = joinBlocks([
|
|
158
|
+
`# ${name}`,
|
|
159
|
+
profile.headline ? `## ${profile.headline}` : '',
|
|
160
|
+
contact.length ? contact.join(' · ') : '',
|
|
161
|
+
links.length ? links.join(' · ') : '',
|
|
162
|
+
]);
|
|
163
|
+
|
|
164
|
+
// Summary.
|
|
165
|
+
const summaryBlock = profile.summary?.trim()
|
|
166
|
+
? joinBlocks(['## 소개', profile.summary.trim()])
|
|
167
|
+
: '';
|
|
168
|
+
|
|
169
|
+
// Desired roles / conditions.
|
|
170
|
+
const desiredLines: string[] = [];
|
|
171
|
+
if (profile.desired_roles?.length) {
|
|
172
|
+
desiredLines.push(kv('희망 직무', profile.desired_roles.join(', ')));
|
|
173
|
+
}
|
|
174
|
+
if (profile.desired_conditions?.trim()) {
|
|
175
|
+
desiredLines.push(kv('희망 조건', profile.desired_conditions));
|
|
176
|
+
}
|
|
177
|
+
const desiredBlock = desiredLines.length
|
|
178
|
+
? joinBlocks(['## 희망 사항', desiredLines.filter(Boolean).join('\n\n')])
|
|
179
|
+
: '';
|
|
180
|
+
|
|
181
|
+
// Experience.
|
|
182
|
+
const expBlocks = experiences.length
|
|
183
|
+
? joinBlocks([
|
|
184
|
+
'## 경력',
|
|
185
|
+
...experiences.map((e) => {
|
|
186
|
+
const range = dateRange(e.start_date, e.end_date, e.is_current);
|
|
187
|
+
const titleParts = [e.company, e.role].filter(Boolean).join(' — ');
|
|
188
|
+
const subParts = [e.employment_type, range].filter(Boolean).join(' · ');
|
|
189
|
+
return joinBlocks([
|
|
190
|
+
`### ${titleParts}`,
|
|
191
|
+
subParts ? `_${subParts}_` : '',
|
|
192
|
+
e.description?.trim() ? e.description.trim() : '',
|
|
193
|
+
e.achievements?.length ? bulletList(e.achievements) : '',
|
|
194
|
+
e.tech?.length ? kv('기술', e.tech.join(', ')) : '',
|
|
195
|
+
]);
|
|
196
|
+
}),
|
|
197
|
+
])
|
|
198
|
+
: '';
|
|
199
|
+
|
|
200
|
+
// Projects.
|
|
201
|
+
const projBlocks = projects.length
|
|
202
|
+
? joinBlocks([
|
|
203
|
+
'## 프로젝트',
|
|
204
|
+
...projects.map((p) => {
|
|
205
|
+
const range = dateRange(p.start_date, p.end_date);
|
|
206
|
+
const subParts = [p.role, range].filter(Boolean).join(' · ');
|
|
207
|
+
return joinBlocks([
|
|
208
|
+
`### ${p.name}`,
|
|
209
|
+
subParts ? `_${subParts}_` : '',
|
|
210
|
+
p.description?.trim() ? p.description.trim() : '',
|
|
211
|
+
p.highlights?.length ? bulletList(p.highlights) : '',
|
|
212
|
+
p.tech?.length ? kv('기술', p.tech.join(', ')) : '',
|
|
213
|
+
p.url ? kv('링크', p.url) : '',
|
|
214
|
+
]);
|
|
215
|
+
}),
|
|
216
|
+
])
|
|
217
|
+
: '';
|
|
218
|
+
|
|
219
|
+
// Skills — grouped by category when present.
|
|
220
|
+
let skillsBlock = '';
|
|
221
|
+
if (skills.length) {
|
|
222
|
+
const groups = new Map<string, string[]>();
|
|
223
|
+
for (const s of skills) {
|
|
224
|
+
const cat = s.category?.trim() || '기타';
|
|
225
|
+
const label = s.level?.trim() ? `${s.name} (${s.level.trim()})` : s.name;
|
|
226
|
+
if (!groups.has(cat)) groups.set(cat, []);
|
|
227
|
+
groups.get(cat)!.push(label);
|
|
228
|
+
}
|
|
229
|
+
const lines = [...groups.entries()].map(
|
|
230
|
+
([cat, items]) => kv(cat, items.join(', ')),
|
|
231
|
+
);
|
|
232
|
+
skillsBlock = joinBlocks(['## 보유 기술', lines.filter(Boolean).join('\n\n')]);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const content = joinBlocks([
|
|
236
|
+
headerBlocks,
|
|
237
|
+
'---',
|
|
238
|
+
summaryBlock,
|
|
239
|
+
desiredBlock,
|
|
240
|
+
expBlocks,
|
|
241
|
+
projBlocks,
|
|
242
|
+
skillsBlock,
|
|
243
|
+
]);
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
filename: `${slugify(name, 'resume')}-resume.md`,
|
|
247
|
+
mimeType: MD_MIME,
|
|
248
|
+
content,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Full resume-style profile export → print-optimized HTML. */
|
|
253
|
+
export function profileToHtml(
|
|
254
|
+
profile: ProfileRecord,
|
|
255
|
+
experiences: ExperienceRecord[] = [],
|
|
256
|
+
projects: ProjectRecord[] = [],
|
|
257
|
+
skills: SkillRecord[] = [],
|
|
258
|
+
): ExportResult {
|
|
259
|
+
const md = profileToMarkdown(profile, experiences, projects, skills);
|
|
260
|
+
const title = profile.name?.trim() || '이력서';
|
|
261
|
+
return {
|
|
262
|
+
filename: `${slugify(title, 'resume')}-resume.html`,
|
|
263
|
+
mimeType: HTML_MIME,
|
|
264
|
+
content: toPrintableHtml(title, md.content),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/* ------------------------------------------------------------ interview prep */
|
|
269
|
+
|
|
270
|
+
/** Interview prep record → Markdown study sheet. */
|
|
271
|
+
export function interviewPrepToMarkdown(
|
|
272
|
+
prep: InterviewPrepRecord,
|
|
273
|
+
job?: JobRecord | null,
|
|
274
|
+
): ExportResult {
|
|
275
|
+
const titleBase = job ? `${job.company} ${job.position} 면접 준비` : '면접 준비';
|
|
276
|
+
|
|
277
|
+
const meta: string[] = [];
|
|
278
|
+
if (job) {
|
|
279
|
+
meta.push(kv('회사', job.company), kv('직무', job.position));
|
|
280
|
+
if (job.deadline) meta.push(kv('마감', job.deadline));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const introBlock = prep.self_introduction?.trim()
|
|
284
|
+
? joinBlocks(['## 1분 자기소개', prep.self_introduction.trim()])
|
|
285
|
+
: '';
|
|
286
|
+
|
|
287
|
+
const questionsBlock = prep.questions?.length
|
|
288
|
+
? joinBlocks([
|
|
289
|
+
'## 예상 질문',
|
|
290
|
+
...prep.questions.map((q, idx) =>
|
|
291
|
+
joinBlocks([
|
|
292
|
+
`### Q${idx + 1}. ${q.question}`,
|
|
293
|
+
q.intent?.trim() ? kv('의도', q.intent) : '',
|
|
294
|
+
q.answer_outline?.trim()
|
|
295
|
+
? joinBlocks(['**답변 가이드**', q.answer_outline.trim()])
|
|
296
|
+
: '',
|
|
297
|
+
q.followups?.length
|
|
298
|
+
? joinBlocks(['**예상 꼬리 질문**', bulletList(q.followups)])
|
|
299
|
+
: '',
|
|
300
|
+
]),
|
|
301
|
+
),
|
|
302
|
+
])
|
|
303
|
+
: '';
|
|
304
|
+
|
|
305
|
+
const starBlock = prep.star_guides?.length
|
|
306
|
+
? joinBlocks([
|
|
307
|
+
'## STAR 정리',
|
|
308
|
+
...prep.star_guides.map((s) =>
|
|
309
|
+
joinBlocks([
|
|
310
|
+
`### ${s.question}`,
|
|
311
|
+
kv('Situation', s.situation),
|
|
312
|
+
kv('Task', s.task),
|
|
313
|
+
kv('Action', s.action),
|
|
314
|
+
kv('Result', s.result),
|
|
315
|
+
]),
|
|
316
|
+
),
|
|
317
|
+
])
|
|
318
|
+
: '';
|
|
319
|
+
|
|
320
|
+
const notesBlock = prep.notes?.trim()
|
|
321
|
+
? joinBlocks(['## 메모', prep.notes.trim()])
|
|
322
|
+
: '';
|
|
323
|
+
|
|
324
|
+
const content = joinBlocks([
|
|
325
|
+
`# ${titleBase}`,
|
|
326
|
+
meta.length ? meta.filter(Boolean).join('\n\n') : '',
|
|
327
|
+
meta.length ? '---' : '',
|
|
328
|
+
introBlock,
|
|
329
|
+
questionsBlock,
|
|
330
|
+
starBlock,
|
|
331
|
+
notesBlock,
|
|
332
|
+
]);
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
filename: `${slugify(titleBase, 'interview-prep')}.md`,
|
|
336
|
+
mimeType: MD_MIME,
|
|
337
|
+
content,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Interview prep record → print-optimized HTML. */
|
|
342
|
+
export function interviewPrepToHtml(
|
|
343
|
+
prep: InterviewPrepRecord,
|
|
344
|
+
job?: JobRecord | null,
|
|
345
|
+
): ExportResult {
|
|
346
|
+
const md = interviewPrepToMarkdown(prep, job);
|
|
347
|
+
const title = job ? `${job.company} ${job.position} 면접 준비` : '면접 준비';
|
|
348
|
+
return {
|
|
349
|
+
filename: `${slugify(title, 'interview-prep')}.html`,
|
|
350
|
+
mimeType: HTML_MIME,
|
|
351
|
+
content: toPrintableHtml(title, md.content),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/* ----------------------------------------------------------------- re-exports */
|
|
356
|
+
|
|
357
|
+
export {
|
|
358
|
+
escapeHtml,
|
|
359
|
+
toPrintableHtml,
|
|
360
|
+
} from './html.ts';
|
|
361
|
+
export {
|
|
362
|
+
markdownToHtml,
|
|
363
|
+
slugify,
|
|
364
|
+
} from './markdown.ts';
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @careermate/exporters — Markdown rendering helpers.
|
|
3
|
+
*
|
|
4
|
+
* Pure, dependency-free utilities for turning CareerMate records into Markdown
|
|
5
|
+
* documents and for converting that Markdown into print-ready HTML. The markdown
|
|
6
|
+
* renderer here is intentionally tiny: it covers the constructs our own exporters
|
|
7
|
+
* emit (headings, bold, lists, paragraphs, line breaks) and nothing more.
|
|
8
|
+
*/
|
|
9
|
+
import { escapeHtml } from './html.ts';
|
|
10
|
+
|
|
11
|
+
/* ----------------------------------------------------------- slug / filenames */
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Make a filesystem-safe, ASCII-leaning slug from arbitrary (often Korean) text.
|
|
15
|
+
* Non-ASCII runs collapse to `-`; the result is lowercased and trimmed of
|
|
16
|
+
* leading/trailing separators. Falls back to `document` when nothing survives.
|
|
17
|
+
*/
|
|
18
|
+
export function slugify(input: string | null | undefined, fallback = 'document'): string {
|
|
19
|
+
const raw = (input ?? '').normalize('NFKD');
|
|
20
|
+
// Keep ASCII alphanumerics; everything else becomes a separator.
|
|
21
|
+
const slug = raw
|
|
22
|
+
.replace(/[^\x00-\x7F]+/g, '-') // drop non-ASCII (e.g. Hangul) -> separator
|
|
23
|
+
.replace(/[^a-zA-Z0-9]+/g, '-') // remaining punctuation/space -> separator
|
|
24
|
+
.replace(/-+/g, '-')
|
|
25
|
+
.replace(/^-+|-+$/g, '')
|
|
26
|
+
.toLowerCase();
|
|
27
|
+
return slug || fallback;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/* ----------------------------------------------------------- markdown helpers */
|
|
31
|
+
|
|
32
|
+
/** Join non-empty lines/blocks with blank lines between them. */
|
|
33
|
+
export function joinBlocks(blocks: (string | null | undefined | false)[]): string {
|
|
34
|
+
return blocks
|
|
35
|
+
.filter((b): b is string => typeof b === 'string' && b.trim().length > 0)
|
|
36
|
+
.join('\n\n');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Render a markdown bullet list from string items (empty/whitespace skipped). */
|
|
40
|
+
export function bulletList(items: (string | null | undefined)[]): string {
|
|
41
|
+
const lines = items
|
|
42
|
+
.filter((i): i is string => typeof i === 'string' && i.trim().length > 0)
|
|
43
|
+
.map((i) => `- ${i.trim()}`);
|
|
44
|
+
return lines.join('\n');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `**label**: value` style key/value line; returns '' if value empty. */
|
|
48
|
+
export function kv(label: string, value: string | null | undefined): string {
|
|
49
|
+
if (!value || !value.trim()) return '';
|
|
50
|
+
return `**${label}**: ${value.trim()}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Render a date range like `2021-03 ~ 현재` / `2021-03 ~ 2023-06`. */
|
|
54
|
+
export function dateRange(
|
|
55
|
+
start: string | null | undefined,
|
|
56
|
+
end: string | null | undefined,
|
|
57
|
+
current = false,
|
|
58
|
+
): string {
|
|
59
|
+
const s = (start ?? '').trim();
|
|
60
|
+
const e = current ? '현재' : (end ?? '').trim();
|
|
61
|
+
if (!s && !e) return '';
|
|
62
|
+
if (!s) return e;
|
|
63
|
+
if (!e) return s;
|
|
64
|
+
return `${s} ~ ${e}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* ------------------------------------------------------ tiny markdown → HTML */
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Convert inline markdown (bold, links, line breaks) within already
|
|
71
|
+
* HTML-escaped text. Order matters: escaping happens before this so user
|
|
72
|
+
* angle-brackets are safe; we only re-introduce the tags we generate.
|
|
73
|
+
*/
|
|
74
|
+
function renderInline(escaped: string): string {
|
|
75
|
+
let out = escaped;
|
|
76
|
+
// **bold**
|
|
77
|
+
out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
78
|
+
// [label](url) — url is already escaped; guard against javascript: schemes.
|
|
79
|
+
out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, label: string, url: string) => {
|
|
80
|
+
const safe = /^(https?:|mailto:|#|\/)/i.test(url.trim()) ? url.trim() : '#';
|
|
81
|
+
return `<a href="${safe}">${label}</a>`;
|
|
82
|
+
});
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Minimal Markdown → HTML renderer. Supports:
|
|
88
|
+
* - ATX headings `#`..`######`
|
|
89
|
+
* - unordered lists (`-`, `*`, `+`)
|
|
90
|
+
* - ordered lists (`1.`)
|
|
91
|
+
* - horizontal rule `---`
|
|
92
|
+
* - bold + links inline
|
|
93
|
+
* - blank-line separated paragraphs, single newline -> <br>
|
|
94
|
+
*
|
|
95
|
+
* Anything else is treated as paragraph text. Input is HTML-escaped first, so
|
|
96
|
+
* raw HTML in the markdown is rendered as literal text (safe by default).
|
|
97
|
+
*/
|
|
98
|
+
export function markdownToHtml(md: string): string {
|
|
99
|
+
const src = (md ?? '').replace(/\r\n?/g, '\n');
|
|
100
|
+
const lines = src.split('\n');
|
|
101
|
+
const html: string[] = [];
|
|
102
|
+
|
|
103
|
+
let i = 0;
|
|
104
|
+
let paragraph: string[] = [];
|
|
105
|
+
|
|
106
|
+
const flushParagraph = () => {
|
|
107
|
+
if (paragraph.length === 0) return;
|
|
108
|
+
const text = paragraph
|
|
109
|
+
.map((l) => renderInline(escapeHtml(l)))
|
|
110
|
+
.join('<br>\n');
|
|
111
|
+
html.push(`<p>${text}</p>`);
|
|
112
|
+
paragraph = [];
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
while (i < lines.length) {
|
|
116
|
+
const line = lines[i];
|
|
117
|
+
const trimmed = line.trim();
|
|
118
|
+
|
|
119
|
+
// Blank line -> paragraph boundary.
|
|
120
|
+
if (trimmed === '') {
|
|
121
|
+
flushParagraph();
|
|
122
|
+
i++;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Horizontal rule.
|
|
127
|
+
if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
|
|
128
|
+
flushParagraph();
|
|
129
|
+
html.push('<hr>');
|
|
130
|
+
i++;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Heading.
|
|
135
|
+
const heading = /^(#{1,6})\s+(.*)$/.exec(trimmed);
|
|
136
|
+
if (heading) {
|
|
137
|
+
flushParagraph();
|
|
138
|
+
const level = heading[1].length;
|
|
139
|
+
const content = renderInline(escapeHtml(heading[2].trim()));
|
|
140
|
+
html.push(`<h${level}>${content}</h${level}>`);
|
|
141
|
+
i++;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Unordered list block.
|
|
146
|
+
if (/^[-*+]\s+/.test(trimmed)) {
|
|
147
|
+
flushParagraph();
|
|
148
|
+
const items: string[] = [];
|
|
149
|
+
while (i < lines.length && /^[-*+]\s+/.test(lines[i].trim())) {
|
|
150
|
+
const item = lines[i].trim().replace(/^[-*+]\s+/, '');
|
|
151
|
+
items.push(`<li>${renderInline(escapeHtml(item))}</li>`);
|
|
152
|
+
i++;
|
|
153
|
+
}
|
|
154
|
+
html.push(`<ul>\n${items.join('\n')}\n</ul>`);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Ordered list block.
|
|
159
|
+
if (/^\d+\.\s+/.test(trimmed)) {
|
|
160
|
+
flushParagraph();
|
|
161
|
+
const items: string[] = [];
|
|
162
|
+
while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) {
|
|
163
|
+
const item = lines[i].trim().replace(/^\d+\.\s+/, '');
|
|
164
|
+
items.push(`<li>${renderInline(escapeHtml(item))}</li>`);
|
|
165
|
+
i++;
|
|
166
|
+
}
|
|
167
|
+
html.push(`<ol>\n${items.join('\n')}\n</ol>`);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Otherwise: accumulate into the current paragraph.
|
|
172
|
+
paragraph.push(line.trim());
|
|
173
|
+
i++;
|
|
174
|
+
}
|
|
175
|
+
flushParagraph();
|
|
176
|
+
|
|
177
|
+
return html.join('\n');
|
|
178
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @careermate/knowledge — serves the Career-OS expert playbooks, verifier
|
|
3
|
+
* rubrics, and per-feature execution procedures (EOP) to the connected AI.
|
|
4
|
+
*
|
|
5
|
+
* Single source of truth = the markdown under docs/career-os/{eop,knowledge}.
|
|
6
|
+
* CareerMate has no LLM: these documents are *served* to the user's AI so it
|
|
7
|
+
* applies senior-expert procedures and self-verifies before saving. Nothing
|
|
8
|
+
* here computes or judges — that is the connected AI's job (Phase B-1: serve +
|
|
9
|
+
* route; the det engine / save-time gates are deferred to B-2, see
|
|
10
|
+
* docs/career-os/implementation/README.md).
|
|
11
|
+
*
|
|
12
|
+
* Content is read from disk lazily and cached. The path resolves in both
|
|
13
|
+
* runtimes (mirrors the apps/web WEB_ROOT BUNDLED pattern):
|
|
14
|
+
* - source / tsx : <repo or package root>/docs/career-os
|
|
15
|
+
* (this file is packages/knowledge/src/index.ts → up 3)
|
|
16
|
+
* - dist bundle : <dist>/career-os
|
|
17
|
+
* (scripts/build-dist.mjs copies docs/career-os → dist/career-os)
|
|
18
|
+
*/
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { BUNDLED } from '@careermate/shared';
|
|
23
|
+
|
|
24
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const CAREER_OS_DIR = BUNDLED
|
|
26
|
+
? path.join(here, 'career-os')
|
|
27
|
+
: path.resolve(here, '..', '..', '..', 'docs', 'career-os');
|
|
28
|
+
|
|
29
|
+
/** 16 expert domains — 1:1 with docs/career-os/knowledge/<domain>.md (CONTRACT C3). */
|
|
30
|
+
export const EXPERT_DOMAINS = [
|
|
31
|
+
'resume',
|
|
32
|
+
'ats',
|
|
33
|
+
'cover-letter',
|
|
34
|
+
'fit-matching',
|
|
35
|
+
'human-writing',
|
|
36
|
+
'company-research',
|
|
37
|
+
'recruiter-screen',
|
|
38
|
+
'interview-behavioral',
|
|
39
|
+
'interview-technical',
|
|
40
|
+
'salary-negotiation',
|
|
41
|
+
'offer-evaluation-decision',
|
|
42
|
+
'rejection-triage-iteration',
|
|
43
|
+
'linkedin-profile',
|
|
44
|
+
'portfolio',
|
|
45
|
+
'networking-referrals',
|
|
46
|
+
'onboarding-first-90-days',
|
|
47
|
+
] as const;
|
|
48
|
+
export type ExpertDomain = (typeof EXPERT_DOMAINS)[number];
|
|
49
|
+
|
|
50
|
+
/** 6 verifier rubrics — 1:1 with docs/career-os/knowledge/verifiers/<id>.md (CONTRACT C3). */
|
|
51
|
+
export const VERIFIER_IDS = [
|
|
52
|
+
'truthfulness',
|
|
53
|
+
'consistency',
|
|
54
|
+
'recency-staleness',
|
|
55
|
+
'responsiveness-on-target',
|
|
56
|
+
'ats-compat',
|
|
57
|
+
'human-voice',
|
|
58
|
+
] as const;
|
|
59
|
+
export type VerifierId = (typeof VERIFIER_IDS)[number];
|
|
60
|
+
|
|
61
|
+
/** 5 per-feature execution procedures — docs/career-os/eop/<feature>.md. */
|
|
62
|
+
export const EOP_FEATURES = [
|
|
63
|
+
'fit-analysis',
|
|
64
|
+
'job-analysis',
|
|
65
|
+
'profile-extraction',
|
|
66
|
+
'cover-letter',
|
|
67
|
+
'interview-prep',
|
|
68
|
+
] as const;
|
|
69
|
+
export type EopFeature = (typeof EOP_FEATURES)[number];
|
|
70
|
+
|
|
71
|
+
const cache = new Map<string, string>();
|
|
72
|
+
function readDoc(rel: string): string {
|
|
73
|
+
let cached = cache.get(rel);
|
|
74
|
+
if (cached === undefined) {
|
|
75
|
+
cached = fs.readFileSync(path.join(CAREER_OS_DIR, rel), 'utf8');
|
|
76
|
+
cache.set(rel, cached);
|
|
77
|
+
}
|
|
78
|
+
return cached;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Deep playbook (principles · Do/Don't · Before→After · rubric refs) for a domain. */
|
|
82
|
+
export function getPlaybook(domain: ExpertDomain): string {
|
|
83
|
+
return readDoc(`knowledge/${domain}.md`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Critic rubric for a verifier id (self-checked by the connected AI, not computed here). */
|
|
87
|
+
export function getVerifier(id: VerifierId): string {
|
|
88
|
+
return readDoc(path.join('knowledge', 'verifiers', `${id}.md`));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Thin execution procedure (EOP) for a feature. */
|
|
92
|
+
export function getEop(feature: EopFeature): string {
|
|
93
|
+
return readDoc(`eop/${feature}.md`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A goal → which EOP, expert playbooks, and verifiers to apply, in order. */
|
|
97
|
+
export interface CareerRoute {
|
|
98
|
+
eop?: EopFeature;
|
|
99
|
+
expertSequence: ExpertDomain[];
|
|
100
|
+
verifierSequence: VerifierId[];
|
|
101
|
+
loop?: 'draft_verify_revise';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Routes keyed by the existing get_workflow_guide workflow ids (additive — CONTRACT C3/C5). */
|
|
105
|
+
export const CAREER_ROUTES: Record<string, CareerRoute> = {
|
|
106
|
+
onboarding: {
|
|
107
|
+
eop: 'profile-extraction',
|
|
108
|
+
expertSequence: ['resume'],
|
|
109
|
+
verifierSequence: ['truthfulness', 'consistency'],
|
|
110
|
+
},
|
|
111
|
+
analyze_job: {
|
|
112
|
+
eop: 'job-analysis',
|
|
113
|
+
expertSequence: ['fit-matching', 'company-research'],
|
|
114
|
+
verifierSequence: ['truthfulness', 'consistency', 'responsiveness-on-target'],
|
|
115
|
+
loop: 'draft_verify_revise',
|
|
116
|
+
},
|
|
117
|
+
write_cover_letter: {
|
|
118
|
+
eop: 'cover-letter',
|
|
119
|
+
expertSequence: ['cover-letter', 'human-writing', 'company-research'],
|
|
120
|
+
verifierSequence: ['human-voice', 'truthfulness', 'ats-compat', 'consistency'],
|
|
121
|
+
loop: 'draft_verify_revise',
|
|
122
|
+
},
|
|
123
|
+
prepare_interview: {
|
|
124
|
+
eop: 'interview-prep',
|
|
125
|
+
expertSequence: ['interview-behavioral', 'interview-technical', 'company-research'],
|
|
126
|
+
verifierSequence: ['truthfulness', 'consistency'],
|
|
127
|
+
loop: 'draft_verify_revise',
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export function getRoute(id: string): CareerRoute | undefined {
|
|
132
|
+
return CAREER_ROUTES[id];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Render a route as the inline guide appended to get_workflow_guide: the EOP
|
|
137
|
+
* execution procedure + which playbooks/verifiers to pull next. This is the
|
|
138
|
+
* consumption wiring (CONTRACT C7) that makes the expert layer actually reach
|
|
139
|
+
* the connected AI instead of sitting orphaned in docs/.
|
|
140
|
+
*/
|
|
141
|
+
export function renderRouteGuide(id: string): string | undefined {
|
|
142
|
+
const route = CAREER_ROUTES[id];
|
|
143
|
+
if (!route) return undefined;
|
|
144
|
+
const lines: string[] = [];
|
|
145
|
+
if (route.eop) {
|
|
146
|
+
lines.push('---', '');
|
|
147
|
+
lines.push('## 전문가 실행 절차 (EOP) — 즉답하지 말고 이 절차를 따르세요');
|
|
148
|
+
lines.push('');
|
|
149
|
+
lines.push(getEop(route.eop));
|
|
150
|
+
lines.push('');
|
|
151
|
+
}
|
|
152
|
+
lines.push('## 이 작업에 적용할 전문가 플레이북');
|
|
153
|
+
lines.push('작성 직전, 아래 도메인의 깊은 지식을 받아 적용하세요:');
|
|
154
|
+
for (const d of route.expertSequence) lines.push(`- \`get_playbook({ domain: "${d}" })\``);
|
|
155
|
+
lines.push('');
|
|
156
|
+
lines.push('## 저장 전 자가검증 (verifier)');
|
|
157
|
+
lines.push(
|
|
158
|
+
'저장(save_*) 직전, 아래 루브릭을 받아 **당신이 직접** 점검하고 고치세요(통과/판단은 당신 몫 — CareerMate는 셀 수 있는 것만 돕습니다):',
|
|
159
|
+
);
|
|
160
|
+
for (const v of route.verifierSequence) lines.push(`- \`get_verifier({ id: "${v}" })\``);
|
|
161
|
+
if (route.loop) {
|
|
162
|
+
lines.push('');
|
|
163
|
+
lines.push(
|
|
164
|
+
'> 루프: 초안 → 자가검증 → 수정. 같은 핵심 결함(허위 수치·미앵커 사실)이 2번 고쳐도 남으면 멈추고 사용자에게 사실을 확인하세요(무한정 고쳐쓰지 않음).',
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
return lines.join('\n');
|
|
168
|
+
}
|