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,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown → .docx renderer.
|
|
3
|
+
*
|
|
4
|
+
* The other exporters already produce a small, well-known Markdown dialect
|
|
5
|
+
* (headings `#`…`####`, `- ` bullets, `**label**: value` rows, `[text](url)`
|
|
6
|
+
* links, `**bold**`, `_italic_`, `---` rules). Rather than re-derive structure
|
|
7
|
+
* from records per document type, we render THAT dialect to a Word document —
|
|
8
|
+
* exactly mirroring how `html.ts` turns the same Markdown into print HTML. One
|
|
9
|
+
* faithful, ATS-safe single-column layout; no decorative template.
|
|
10
|
+
*
|
|
11
|
+
* Binary output is a `Uint8Array` (the OOXML zip). Callers stream/write it as-is.
|
|
12
|
+
*/
|
|
13
|
+
import {
|
|
14
|
+
Document,
|
|
15
|
+
ExternalHyperlink,
|
|
16
|
+
HeadingLevel,
|
|
17
|
+
Packer,
|
|
18
|
+
Paragraph,
|
|
19
|
+
TextRun,
|
|
20
|
+
} from 'docx';
|
|
21
|
+
|
|
22
|
+
const HEADING_BY_LEVEL = [
|
|
23
|
+
HeadingLevel.HEADING_1,
|
|
24
|
+
HeadingLevel.HEADING_2,
|
|
25
|
+
HeadingLevel.HEADING_3,
|
|
26
|
+
HeadingLevel.HEADING_4,
|
|
27
|
+
] as const;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* XML 1.0 forbids these control chars even as numeric references; the `docx`
|
|
31
|
+
* lib writes run text verbatim into document.xml, so a single one makes the
|
|
32
|
+
* whole .docx not well-formed and Word refuses to open it. Tab (0x09) and
|
|
33
|
+
* newline (0x0A) are valid and preserved. Strip everything else in the C0 set.
|
|
34
|
+
*/
|
|
35
|
+
const XML_INVALID_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F]/g;
|
|
36
|
+
const xmlSafe = (s: string): string => s.replace(XML_INVALID_RE, '');
|
|
37
|
+
|
|
38
|
+
const ALNUM_RE = /[\p{L}\p{N}]/u;
|
|
39
|
+
/** Link-scheme allowlist — mirrors html.ts renderInline so the docx path can't
|
|
40
|
+
* emit javascript:/file:// hyperlinks that the HTML path neutralizes. */
|
|
41
|
+
const SAFE_LINK_RE = /^(https?:|mailto:|#|\/)/i;
|
|
42
|
+
|
|
43
|
+
/** Inline tokens: **bold**, _italic_, [text](url). Order-preserving, non-nesting. */
|
|
44
|
+
const INLINE_RE = /(\*\*([^*]+)\*\*|_([^_]+)_|\[([^\]]+)\]\(([^)]+)\))/g;
|
|
45
|
+
|
|
46
|
+
function inlineRuns(text: string): (TextRun | ExternalHyperlink)[] {
|
|
47
|
+
const src = xmlSafe(text);
|
|
48
|
+
const out: (TextRun | ExternalHyperlink)[] = [];
|
|
49
|
+
let last = 0;
|
|
50
|
+
for (const m of src.matchAll(INLINE_RE)) {
|
|
51
|
+
const idx = m.index ?? 0;
|
|
52
|
+
if (idx > last) out.push(new TextRun(src.slice(last, idx)));
|
|
53
|
+
if (m[2] !== undefined) {
|
|
54
|
+
out.push(new TextRun({ text: m[2], bold: true }));
|
|
55
|
+
} else if (m[3] !== undefined) {
|
|
56
|
+
// CommonMark intraword-underscore rule: `_` is emphasis only when flanked
|
|
57
|
+
// by non-alphanumerics, so snake_case / env-vars / file_paths
|
|
58
|
+
// (read_write_latency, API_KEY) are kept literal instead of being mangled.
|
|
59
|
+
const before = idx > 0 ? src[idx - 1] : '';
|
|
60
|
+
const after = src[idx + m[0].length] ?? '';
|
|
61
|
+
if (ALNUM_RE.test(before) || ALNUM_RE.test(after)) {
|
|
62
|
+
out.push(new TextRun(m[0])); // not emphasis — emit underscores literally
|
|
63
|
+
} else {
|
|
64
|
+
out.push(new TextRun({ text: m[3], italics: true }));
|
|
65
|
+
}
|
|
66
|
+
} else if (m[4] !== undefined) {
|
|
67
|
+
// [text](url) — a real Word hyperlink so it survives ATS text extraction.
|
|
68
|
+
const link = (m[5] ?? '').trim();
|
|
69
|
+
if (SAFE_LINK_RE.test(link)) {
|
|
70
|
+
out.push(
|
|
71
|
+
new ExternalHyperlink({
|
|
72
|
+
children: [new TextRun({ text: m[4], style: 'Hyperlink' })],
|
|
73
|
+
link,
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
} else {
|
|
77
|
+
// Disallowed scheme (javascript:/file:/data:) → plain text, mirroring html.ts.
|
|
78
|
+
out.push(new TextRun(m[4]));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
last = idx + m[0].length;
|
|
82
|
+
}
|
|
83
|
+
if (last < src.length) out.push(new TextRun(src.slice(last)));
|
|
84
|
+
return out.length ? out : [new TextRun('')];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Parse our Markdown dialect (blocks split on blank lines) into docx paragraphs. */
|
|
88
|
+
function markdownToParagraphs(markdown: string): Paragraph[] {
|
|
89
|
+
const paras: Paragraph[] = [];
|
|
90
|
+
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
|
|
91
|
+
|
|
92
|
+
for (const raw of lines) {
|
|
93
|
+
const line = raw.trimEnd();
|
|
94
|
+
if (line.trim() === '') continue; // blank lines: spacing comes from paragraph config
|
|
95
|
+
|
|
96
|
+
// Horizontal rule → a thin bottom border on an empty paragraph.
|
|
97
|
+
if (/^---+$/.test(line.trim())) {
|
|
98
|
+
paras.push(
|
|
99
|
+
new Paragraph({
|
|
100
|
+
border: { bottom: { style: 'single', size: 6, space: 1, color: 'CCCCCC' } },
|
|
101
|
+
spacing: { after: 160 },
|
|
102
|
+
children: [new TextRun('')],
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ATX heading.
|
|
109
|
+
const h = /^(#{1,4})\s+(.*)$/.exec(line);
|
|
110
|
+
if (h) {
|
|
111
|
+
const level = h[1].length - 1;
|
|
112
|
+
paras.push(
|
|
113
|
+
new Paragraph({
|
|
114
|
+
heading: HEADING_BY_LEVEL[level],
|
|
115
|
+
spacing: { before: level === 0 ? 0 : 200, after: 80 },
|
|
116
|
+
children: inlineRuns(h[2]),
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Bullet (-, *, + — match the markdown dialect html.ts accepts).
|
|
123
|
+
const b = /^[-*+]\s+(.*)$/.exec(line);
|
|
124
|
+
if (b) {
|
|
125
|
+
paras.push(
|
|
126
|
+
new Paragraph({ bullet: { level: 0 }, spacing: { after: 40 }, children: inlineRuns(b[1]) }),
|
|
127
|
+
);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
paras.push(new Paragraph({ spacing: { after: 120 }, children: inlineRuns(line) }));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return paras;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const DOCX_FONT = '맑은 고딕'; // Malgun Gothic — ships with Windows, renders Korean + Latin cleanly.
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Render a title + our Markdown body to a .docx byte array (single-column,
|
|
141
|
+
* ATS-friendly). The leading `# title` in the body is rendered as Heading 1, so
|
|
142
|
+
* we don't duplicate the title here.
|
|
143
|
+
*/
|
|
144
|
+
export async function markdownToDocx(_title: string, markdown: string): Promise<Uint8Array> {
|
|
145
|
+
const doc = new Document({
|
|
146
|
+
styles: {
|
|
147
|
+
default: {
|
|
148
|
+
document: { run: { font: DOCX_FONT, size: 22 } }, // 11pt
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
sections: [
|
|
152
|
+
{
|
|
153
|
+
properties: {
|
|
154
|
+
page: { margin: { top: 1134, bottom: 1134, left: 1134, right: 1134 } }, // 2cm
|
|
155
|
+
},
|
|
156
|
+
children: markdownToParagraphs(markdown),
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
});
|
|
160
|
+
// Packer.toBuffer returns a Node Buffer (a Uint8Array subclass) in Node.
|
|
161
|
+
return Packer.toBuffer(doc);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export const DOCX_MIME =
|
|
165
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
slugify,
|
|
29
29
|
} from './markdown.ts';
|
|
30
30
|
import { escapeHtml, toPrintableHtml } from './html.ts';
|
|
31
|
+
import { markdownToDocx, DOCX_MIME } from './docx.ts';
|
|
31
32
|
|
|
32
33
|
/** The uniform shape every exporter returns. */
|
|
33
34
|
export interface ExportResult {
|
|
@@ -35,8 +36,16 @@ export interface ExportResult {
|
|
|
35
36
|
filename: string;
|
|
36
37
|
/** MIME type, e.g. `text/markdown` or `text/html`. */
|
|
37
38
|
mimeType: string;
|
|
38
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* The document body as text. For binary formats (`.docx`) this is a short,
|
|
41
|
+
* human-readable note instead of the payload — write/stream {@link bytes}.
|
|
42
|
+
*/
|
|
39
43
|
content: string;
|
|
44
|
+
/**
|
|
45
|
+
* Present only for binary formats (e.g. `.docx`). When set, callers MUST
|
|
46
|
+
* write/stream these bytes rather than {@link content}.
|
|
47
|
+
*/
|
|
48
|
+
bytes?: Uint8Array;
|
|
40
49
|
}
|
|
41
50
|
|
|
42
51
|
export type ExportOptions = {
|
|
@@ -47,6 +56,23 @@ export type ExportOptions = {
|
|
|
47
56
|
const MD_MIME = 'text/markdown';
|
|
48
57
|
const HTML_MIME = 'text/html';
|
|
49
58
|
|
|
59
|
+
/** Stand-in `content` for binary exports — the real payload rides in `bytes`. */
|
|
60
|
+
function docxNote(filename: string): string {
|
|
61
|
+
return `(Word 문서로 저장됨: ${filename})`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Compose an export filename from slugged parts joined by `_`, then the
|
|
66
|
+
* extension — e.g. `["토스 백엔드 자기소개서", "홍길동"]` → `토스_백엔드_자기소개서_홍길동.docx`.
|
|
67
|
+
* Empty parts (no title, no owner name) drop out; if nothing survives, `fallback`
|
|
68
|
+
* is used. The owner's name as the trailing part makes files self-identifying in
|
|
69
|
+
* a downloads folder.
|
|
70
|
+
*/
|
|
71
|
+
function exportName(parts: (string | null | undefined)[], ext: string, fallback: string): string {
|
|
72
|
+
const slug = parts.map((p) => slugify(p, '')).filter(Boolean).join('_') || fallback;
|
|
73
|
+
return `${slug}.${ext}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
50
76
|
/* ------------------------------------------------------------- cover letters */
|
|
51
77
|
|
|
52
78
|
/** Build the shared markdown body for a cover letter (used by MD + HTML). */
|
|
@@ -74,7 +100,7 @@ export function coverLetterToMarkdown(
|
|
|
74
100
|
opts?: ExportOptions,
|
|
75
101
|
): ExportResult {
|
|
76
102
|
return {
|
|
77
|
-
filename:
|
|
103
|
+
filename: exportName([cl.title, opts?.profile?.name], 'md', 'cover_letter'),
|
|
78
104
|
mimeType: MD_MIME,
|
|
79
105
|
content: coverLetterBody(cl, opts),
|
|
80
106
|
};
|
|
@@ -86,12 +112,22 @@ export function coverLetterToHtml(
|
|
|
86
112
|
opts?: ExportOptions,
|
|
87
113
|
): ExportResult {
|
|
88
114
|
return {
|
|
89
|
-
filename:
|
|
115
|
+
filename: exportName([cl.title, opts?.profile?.name], 'html', 'cover_letter'),
|
|
90
116
|
mimeType: HTML_MIME,
|
|
91
117
|
content: toPrintableHtml(cl.title, coverLetterBody(cl, opts)),
|
|
92
118
|
};
|
|
93
119
|
}
|
|
94
120
|
|
|
121
|
+
/** Cover letter → Word (.docx), ATS-friendly single column. */
|
|
122
|
+
export async function coverLetterToDocx(
|
|
123
|
+
cl: CoverLetterRecord,
|
|
124
|
+
opts?: ExportOptions,
|
|
125
|
+
): Promise<ExportResult> {
|
|
126
|
+
const filename = exportName([cl.title, opts?.profile?.name], 'docx', 'cover_letter');
|
|
127
|
+
const bytes = await markdownToDocx(cl.title, coverLetterBody(cl, opts));
|
|
128
|
+
return { filename, mimeType: DOCX_MIME, content: docxNote(filename), bytes };
|
|
129
|
+
}
|
|
130
|
+
|
|
95
131
|
/* -------------------------------------------------------------------- resume */
|
|
96
132
|
|
|
97
133
|
/** A stored resume/career document → Markdown. */
|
|
@@ -115,7 +151,7 @@ export function resumeToMarkdown(
|
|
|
115
151
|
]);
|
|
116
152
|
|
|
117
153
|
return {
|
|
118
|
-
filename:
|
|
154
|
+
filename: exportName([doc.title, profile?.name], 'md', doc.kind),
|
|
119
155
|
mimeType: MD_MIME,
|
|
120
156
|
content: body,
|
|
121
157
|
};
|
|
@@ -128,12 +164,23 @@ export function resumeToHtml(
|
|
|
128
164
|
): ExportResult {
|
|
129
165
|
const md = resumeToMarkdown(doc, profile);
|
|
130
166
|
return {
|
|
131
|
-
filename:
|
|
167
|
+
filename: exportName([doc.title, profile?.name], 'html', doc.kind),
|
|
132
168
|
mimeType: HTML_MIME,
|
|
133
169
|
content: toPrintableHtml(doc.title, md.content),
|
|
134
170
|
};
|
|
135
171
|
}
|
|
136
172
|
|
|
173
|
+
/** A stored resume/career document → Word (.docx). */
|
|
174
|
+
export async function resumeToDocx(
|
|
175
|
+
doc: DocumentRecord,
|
|
176
|
+
profile?: ProfileRecord | null,
|
|
177
|
+
): Promise<ExportResult> {
|
|
178
|
+
const md = resumeToMarkdown(doc, profile);
|
|
179
|
+
const filename = exportName([doc.title, profile?.name], 'docx', doc.kind);
|
|
180
|
+
const bytes = await markdownToDocx(doc.title, md.content);
|
|
181
|
+
return { filename, mimeType: DOCX_MIME, content: docxNote(filename), bytes };
|
|
182
|
+
}
|
|
183
|
+
|
|
137
184
|
/* ------------------------------------------------------------------- profile */
|
|
138
185
|
|
|
139
186
|
/** Full resume-style profile export → Markdown. */
|
|
@@ -243,7 +290,7 @@ export function profileToMarkdown(
|
|
|
243
290
|
]);
|
|
244
291
|
|
|
245
292
|
return {
|
|
246
|
-
filename:
|
|
293
|
+
filename: exportName(['프로필', profile.name], 'md', 'resume'),
|
|
247
294
|
mimeType: MD_MIME,
|
|
248
295
|
content,
|
|
249
296
|
};
|
|
@@ -259,12 +306,97 @@ export function profileToHtml(
|
|
|
259
306
|
const md = profileToMarkdown(profile, experiences, projects, skills);
|
|
260
307
|
const title = profile.name?.trim() || '이력서';
|
|
261
308
|
return {
|
|
262
|
-
filename:
|
|
309
|
+
filename: exportName(['프로필', profile.name], 'html', 'resume'),
|
|
263
310
|
mimeType: HTML_MIME,
|
|
264
311
|
content: toPrintableHtml(title, md.content),
|
|
265
312
|
};
|
|
266
313
|
}
|
|
267
314
|
|
|
315
|
+
/** Full resume-style profile export → Word (.docx). */
|
|
316
|
+
export async function profileToDocx(
|
|
317
|
+
profile: ProfileRecord,
|
|
318
|
+
experiences: ExperienceRecord[] = [],
|
|
319
|
+
projects: ProjectRecord[] = [],
|
|
320
|
+
skills: SkillRecord[] = [],
|
|
321
|
+
): Promise<ExportResult> {
|
|
322
|
+
const md = profileToMarkdown(profile, experiences, projects, skills);
|
|
323
|
+
const title = profile.name?.trim() || '이력서';
|
|
324
|
+
const filename = exportName(['프로필', profile.name], 'docx', 'resume');
|
|
325
|
+
const bytes = await markdownToDocx(title, md.content);
|
|
326
|
+
return { filename, mimeType: DOCX_MIME, content: docxNote(filename), bytes };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Full profile → JSON Resume (jsonresume.org, schema v1.0.0). Makes CareerMate
|
|
331
|
+
* data portable: the open standard is consumed by 100+ themes (HTML/PDF render)
|
|
332
|
+
* and other AI/ATS tools. Dates pass through as stored (YYYY-MM[-DD]); only
|
|
333
|
+
* populated fields are emitted to keep the document clean.
|
|
334
|
+
*/
|
|
335
|
+
export function profileToJsonResume(
|
|
336
|
+
profile: ProfileRecord,
|
|
337
|
+
experiences: ExperienceRecord[] = [],
|
|
338
|
+
projects: ProjectRecord[] = [],
|
|
339
|
+
skills: SkillRecord[] = [],
|
|
340
|
+
): ExportResult {
|
|
341
|
+
const clean = <T extends Record<string, unknown>>(o: T): T =>
|
|
342
|
+
Object.fromEntries(
|
|
343
|
+
Object.entries(o).filter(([, v]) => v != null && !(Array.isArray(v) && v.length === 0) && v !== ''),
|
|
344
|
+
) as T;
|
|
345
|
+
|
|
346
|
+
const basics = clean({
|
|
347
|
+
name: profile.name ?? '',
|
|
348
|
+
label: profile.headline ?? '',
|
|
349
|
+
email: profile.email ?? '',
|
|
350
|
+
phone: profile.phone ?? '',
|
|
351
|
+
summary: profile.summary ?? '',
|
|
352
|
+
location: profile.location ? { address: profile.location } : undefined,
|
|
353
|
+
profiles: (profile.links ?? [])
|
|
354
|
+
.filter((l) => l.url)
|
|
355
|
+
.map((l) => clean({ network: l.label ?? '', url: l.url })),
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
const work = experiences.map((e) =>
|
|
359
|
+
clean({
|
|
360
|
+
name: e.company ?? '',
|
|
361
|
+
position: e.role ?? '',
|
|
362
|
+
startDate: e.start_date ?? '',
|
|
363
|
+
endDate: e.is_current ? '' : (e.end_date ?? ''),
|
|
364
|
+
summary: e.description ?? '',
|
|
365
|
+
highlights: e.achievements ?? [],
|
|
366
|
+
}),
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
const projectsOut = projects.map((p) =>
|
|
370
|
+
clean({
|
|
371
|
+
name: p.name ?? '',
|
|
372
|
+
description: p.description ?? '',
|
|
373
|
+
highlights: p.highlights ?? [],
|
|
374
|
+
keywords: p.tech ?? [],
|
|
375
|
+
url: p.url ?? '',
|
|
376
|
+
startDate: p.start_date ?? '',
|
|
377
|
+
endDate: p.end_date ?? '',
|
|
378
|
+
roles: p.role ? [p.role] : [],
|
|
379
|
+
}),
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
const skillsOut = skills.map((s) => clean({ name: s.name, level: s.level ?? '' }));
|
|
383
|
+
|
|
384
|
+
const resume = {
|
|
385
|
+
$schema: 'https://raw.githubusercontent.com/jsonresume/resume-schema/v1.0.0/schema.json',
|
|
386
|
+
basics,
|
|
387
|
+
work,
|
|
388
|
+
projects: projectsOut,
|
|
389
|
+
skills: skillsOut,
|
|
390
|
+
meta: { canonical: 'https://jsonresume.org', version: 'v1.0.0', generatedBy: 'CareerMate' },
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
filename: exportName(['프로필', profile.name], 'json', 'resume'),
|
|
395
|
+
mimeType: 'application/json',
|
|
396
|
+
content: JSON.stringify(resume, null, 2),
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
268
400
|
/* ------------------------------------------------------------ interview prep */
|
|
269
401
|
|
|
270
402
|
/** Interview prep record → Markdown study sheet. */
|
|
@@ -343,7 +475,7 @@ export function interviewPrepToMarkdown(
|
|
|
343
475
|
]);
|
|
344
476
|
|
|
345
477
|
return {
|
|
346
|
-
filename:
|
|
478
|
+
filename: exportName([titleBase], 'md', 'interview_prep'),
|
|
347
479
|
mimeType: MD_MIME,
|
|
348
480
|
content,
|
|
349
481
|
};
|
|
@@ -357,7 +489,7 @@ export function interviewPrepToHtml(
|
|
|
357
489
|
const md = interviewPrepToMarkdown(prep, job);
|
|
358
490
|
const title = job ? `${job.company} ${job.position} 면접 준비` : '면접 준비';
|
|
359
491
|
return {
|
|
360
|
-
filename:
|
|
492
|
+
filename: exportName([title], 'html', 'interview_prep'),
|
|
361
493
|
mimeType: HTML_MIME,
|
|
362
494
|
content: toPrintableHtml(title, md.content),
|
|
363
495
|
};
|
|
@@ -373,3 +505,4 @@ export {
|
|
|
373
505
|
markdownToHtml,
|
|
374
506
|
slugify,
|
|
375
507
|
} from './markdown.ts';
|
|
508
|
+
export { DOCX_MIME } from './docx.ts';
|
|
@@ -11,19 +11,24 @@ import { escapeHtml } from './html.ts';
|
|
|
11
11
|
/* ----------------------------------------------------------- slug / filenames */
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
* Make a filesystem-safe
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* Make a filesystem-safe slug from arbitrary text, PRESERVING the original
|
|
15
|
+
* script (Hangul, CJK, Latin, digits) so a Korean title like "백엔드 자기소개서"
|
|
16
|
+
* yields "백엔드_자기소개서" instead of a generic fallback. Spaces and any
|
|
17
|
+
* characters that aren't Unicode letters/numbers (punctuation, path separators)
|
|
18
|
+
* collapse to `_`; the result is trimmed and capped to a sane filename length.
|
|
19
|
+
* Falls back to `fallback` when nothing survives (e.g. emoji-only titles); pass
|
|
20
|
+
* `''` to opt out of the fallback when composing a multi-part name.
|
|
21
|
+
* Unicode filenames are safe on modern OSes, and the download header
|
|
22
|
+
* (`http.ts` `sendDownload`) already allows Hangul and encodes UTF-8.
|
|
17
23
|
*/
|
|
18
24
|
export function slugify(input: string | null | undefined, fallback = 'document'): string {
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
.replace(/
|
|
23
|
-
.replace(
|
|
24
|
-
.
|
|
25
|
-
.replace(
|
|
26
|
-
.toLowerCase();
|
|
25
|
+
const slug = (input ?? '')
|
|
26
|
+
.normalize('NFC')
|
|
27
|
+
.replace(/[^\p{L}\p{N}]+/gu, '_') // non letter/number (space, punctuation, /\:*?…) -> separator
|
|
28
|
+
.replace(/_+/g, '_')
|
|
29
|
+
.replace(/^_+|_+$/g, '')
|
|
30
|
+
.slice(0, 80)
|
|
31
|
+
.replace(/_+$/g, ''); // re-trim a separator left dangling by the length cap
|
|
27
32
|
return slug || fallback;
|
|
28
33
|
}
|
|
29
34
|
|
|
@@ -47,7 +47,9 @@ export const EXPERT_DOMAINS = [
|
|
|
47
47
|
] as const;
|
|
48
48
|
export type ExpertDomain = (typeof EXPERT_DOMAINS)[number];
|
|
49
49
|
|
|
50
|
-
/**
|
|
50
|
+
/** 7 verifier rubrics — 1:1 with docs/career-os/knowledge/verifiers/<id>.md (CONTRACT C3).
|
|
51
|
+
* ai-detection-risk: additive (한국 AI-자소서 탐지 시장 전용 — 진정성·면접 방어가능성 점검,
|
|
52
|
+
* 탐지기 우회가 아님). human-voice(문체 "AI 티")와 역할이 다르다. */
|
|
51
53
|
export const VERIFIER_IDS = [
|
|
52
54
|
'truthfulness',
|
|
53
55
|
'consistency',
|
|
@@ -55,6 +57,7 @@ export const VERIFIER_IDS = [
|
|
|
55
57
|
'responsiveness-on-target',
|
|
56
58
|
'ats-compat',
|
|
57
59
|
'human-voice',
|
|
60
|
+
'ai-detection-risk',
|
|
58
61
|
] as const;
|
|
59
62
|
export type VerifierId = (typeof VERIFIER_IDS)[number];
|
|
60
63
|
|
|
@@ -121,13 +124,15 @@ export const CAREER_ROUTES: Record<string, CareerRoute> = {
|
|
|
121
124
|
write_cover_letter: {
|
|
122
125
|
eop: 'cover-letter',
|
|
123
126
|
expertSequence: ['cover-letter', 'human-writing', 'company-research'],
|
|
124
|
-
|
|
127
|
+
// ai-detection-risk: 한국 자소서는 AI 탐지(무하유 GPT킬러 등)가 제도화돼 있어, 저장 전
|
|
128
|
+
// "진정성·면접 방어가능성·자기복제 여부"를 점검한다(탐지 우회가 아님 — 짝은 human-voice).
|
|
129
|
+
verifierSequence: ['human-voice', 'truthfulness', 'responsiveness-on-target', 'ats-compat', 'consistency', 'ai-detection-risk'],
|
|
125
130
|
loop: 'draft_verify_revise',
|
|
126
131
|
},
|
|
127
132
|
write_career_description: {
|
|
128
133
|
eop: 'career-description',
|
|
129
134
|
expertSequence: ['resume', 'ats'],
|
|
130
|
-
verifierSequence: ['truthfulness', 'consistency', 'ats-compat'],
|
|
135
|
+
verifierSequence: ['truthfulness', 'consistency', 'ats-compat', 'ai-detection-risk'],
|
|
131
136
|
loop: 'draft_verify_revise',
|
|
132
137
|
},
|
|
133
138
|
prepare_interview: {
|
|
@@ -22,6 +22,15 @@ export interface ToolDef {
|
|
|
22
22
|
inputSchema: ZodRawShape;
|
|
23
23
|
/** Hint flags for clients (readOnly tools don't mutate). */
|
|
24
24
|
readOnly?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Tool reaches outside the user's machine (job-board fetch, npm registry).
|
|
27
|
+
* Must be honest — the privacy story depends on these hints being true.
|
|
28
|
+
*/
|
|
29
|
+
openWorld?: boolean;
|
|
30
|
+
/** Irreversible effect (hard delete, package update). Default: non-destructive upsert. */
|
|
31
|
+
destructive?: boolean;
|
|
32
|
+
/** Repeating the same call with the same args has no additional effect (documented upserts). */
|
|
33
|
+
idempotent?: boolean;
|
|
25
34
|
handler: (args: any) => ToolResult | Promise<ToolResult>;
|
|
26
35
|
}
|
|
27
36
|
|