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
|
@@ -23,11 +23,14 @@ import {
|
|
|
23
23
|
CoverLetterVersionInputSchema,
|
|
24
24
|
ApplicationSubmissionSchema,
|
|
25
25
|
InterviewPrepInputSchema,
|
|
26
|
+
OfferInputSchema,
|
|
26
27
|
DocumentInputSchema,
|
|
27
28
|
APPLICATION_STATUSES,
|
|
28
29
|
APPLICATION_STATUS_LABELS,
|
|
30
|
+
REJECTED_STAGES,
|
|
31
|
+
REJECTED_STAGE_LABELS,
|
|
29
32
|
DOCUMENT_KINDS,
|
|
30
|
-
|
|
33
|
+
INTERVIEW_UNLOCK_STATUSES,
|
|
31
34
|
} from '@careermate/shared';
|
|
32
35
|
import {
|
|
33
36
|
profileRepo,
|
|
@@ -60,9 +63,18 @@ import {
|
|
|
60
63
|
updateApplicationStatus,
|
|
61
64
|
saveInterviewPrep,
|
|
62
65
|
listRecentActivity,
|
|
66
|
+
getActionDigest,
|
|
67
|
+
getPipelineStats,
|
|
68
|
+
prescreenJob,
|
|
69
|
+
searchJobs,
|
|
70
|
+
JOB_SEARCH_SOURCES,
|
|
71
|
+
saveOffer,
|
|
72
|
+
compareOffers,
|
|
73
|
+
getOffer,
|
|
63
74
|
jobWithMeta,
|
|
64
75
|
previewCoverLetter,
|
|
65
76
|
summarizeReport,
|
|
77
|
+
checkCharLimit,
|
|
66
78
|
} from '@careermate/core';
|
|
67
79
|
import { getWritingStyleGuide } from '@careermate/prompts';
|
|
68
80
|
import { getWorkflow, renderWorkflowMarkdown, WORKFLOWS } from '@careermate/workflows';
|
|
@@ -79,10 +91,15 @@ import {
|
|
|
79
91
|
import {
|
|
80
92
|
coverLetterToMarkdown,
|
|
81
93
|
coverLetterToHtml,
|
|
94
|
+
coverLetterToDocx,
|
|
82
95
|
resumeToMarkdown,
|
|
83
96
|
resumeToHtml,
|
|
97
|
+
resumeToDocx,
|
|
84
98
|
profileToMarkdown,
|
|
85
99
|
profileToHtml,
|
|
100
|
+
profileToDocx,
|
|
101
|
+
profileToJsonResume,
|
|
102
|
+
type ExportResult,
|
|
86
103
|
} from '@careermate/exporters';
|
|
87
104
|
import { extractDocument } from '@careermate/parsers';
|
|
88
105
|
import { ok, fail, type ToolDef } from './result.ts';
|
|
@@ -146,9 +163,6 @@ async function mapWithLimit<T, R>(items: T[], limit: number, fn: (item: T, index
|
|
|
146
163
|
return out;
|
|
147
164
|
}
|
|
148
165
|
|
|
149
|
-
/** Statuses at/after which interview prep is recommended as a next step. */
|
|
150
|
-
const INTERVIEW_UNLOCKED = ['document_passed', 'interview', 'final_passed'];
|
|
151
|
-
|
|
152
166
|
/**
|
|
153
167
|
* Pick the recommended Career-OS route from the loaded context, so the AI is
|
|
154
168
|
* nudged onto the expert procedure for what it's most likely about to do.
|
|
@@ -162,11 +176,27 @@ function pickRoute(ctx: ReturnType<typeof getApplicationContext>): string | null
|
|
|
162
176
|
const app = ctx.recent_applications.find(
|
|
163
177
|
(a) => (a as { job_id?: string }).job_id === jobId,
|
|
164
178
|
) as { status?: string } | undefined;
|
|
165
|
-
if (app?.status &&
|
|
179
|
+
if (app?.status && (INTERVIEW_UNLOCK_STATUSES as readonly string[]).includes(app.status)) return 'prepare_interview';
|
|
166
180
|
if (!ctx.fit_analysis) return 'analyze_job';
|
|
167
181
|
return 'write_cover_letter';
|
|
168
182
|
}
|
|
169
183
|
|
|
184
|
+
/**
|
|
185
|
+
* Write an export to the exports dir. Binary formats (`.docx`) carry their
|
|
186
|
+
* payload in `bytes`; text formats in `content`. Returns the absolute path or an
|
|
187
|
+
* error string so the caller can `fail()` with a human message.
|
|
188
|
+
*/
|
|
189
|
+
function writeExport(result: ExportResult): { path: string } | { error: string } {
|
|
190
|
+
const filePath = path.join(getExportsDir(), result.filename);
|
|
191
|
+
try {
|
|
192
|
+
if (result.bytes) fs.writeFileSync(filePath, result.bytes);
|
|
193
|
+
else fs.writeFileSync(filePath, result.content, 'utf8');
|
|
194
|
+
return { path: filePath };
|
|
195
|
+
} catch (e) {
|
|
196
|
+
return { error: `파일 저장 실패: ${e instanceof Error ? e.message : e}` };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
170
200
|
export const TOOLS: ToolDef[] = [
|
|
171
201
|
/* --------------------------------------------------------- read document */
|
|
172
202
|
{
|
|
@@ -333,9 +363,10 @@ export const TOOLS: ToolDef[] = [
|
|
|
333
363
|
},
|
|
334
364
|
{
|
|
335
365
|
name: 'save_profile',
|
|
366
|
+
idempotent: true, // 부분 업서트 — 같은 인자 재호출은 같은 결과
|
|
336
367
|
title: '프로필 저장',
|
|
337
368
|
description:
|
|
338
|
-
'사용자 프로필을 저장합니다. 전달한 필드만 갱신되고 나머지는 유지됩니다(부분 저장 안전). 이력서를 구조화한 뒤 이름/한 줄 소개/희망 직무/선호 문체/강조 포인트 등을 채워 저장하세요. 선호 문체(preferred_tone)와 강조 포인트(emphasis_points)는 이후 자기소개서 작성 품질에 직접 영향을 줍니다.',
|
|
369
|
+
'사용자 프로필을 저장합니다. 전달한 필드만 갱신되고 나머지는 유지됩니다(부분 저장 안전). 이력서를 구조화한 뒤 이름/한 줄 소개/희망 직무/선호 문체/강조 포인트 등을 채워 저장하세요. 선호 문체(preferred_tone)와 강조 포인트(emphasis_points)는 이후 자기소개서 작성 품질에 직접 영향을 줍니다. 학력(education)·자격증(certifications)·어학점수(language_scores: 토익·오픽 등)·수상(awards)이 이력서에 있으면 구조화해 저장하세요 — 한국 이력서 핵심 스펙이고, 저장하면 이후 자소서·이력서에서 정확한 수치(학점 3.8, 토익 920)를 지어내지 않고 그대로 인용합니다.',
|
|
339
370
|
inputSchema: ProfileInputSchema.shape,
|
|
340
371
|
handler: (args) => {
|
|
341
372
|
const p = saveProfile(args);
|
|
@@ -374,6 +405,7 @@ export const TOOLS: ToolDef[] = [
|
|
|
374
405
|
},
|
|
375
406
|
{
|
|
376
407
|
name: 'delete_resume',
|
|
408
|
+
destructive: true,
|
|
377
409
|
title: '이력서/문서 삭제',
|
|
378
410
|
description:
|
|
379
411
|
'저장된 이력서·경력기술서·포트폴리오 등 문서를 삭제합니다. 사용자가 명확히 삭제를 요청했을 때만 사용하세요. 먼저 get_resumes로 목록을 확인하고 어떤 문서인지 사용자와 확인한 뒤 호출합니다. confirm에는 DELETE를 넣으세요.',
|
|
@@ -394,6 +426,7 @@ export const TOOLS: ToolDef[] = [
|
|
|
394
426
|
/* ----------------------------------------------------------- experiences */
|
|
395
427
|
{
|
|
396
428
|
name: 'add_experience',
|
|
429
|
+
idempotent: true, // 회사·직무·입사일 키 업서트(adr_0003) — 재호출 안전
|
|
397
430
|
title: '경력 추가',
|
|
398
431
|
description:
|
|
399
432
|
'구조화된 경력(직장 단위)을 저장합니다. 이력서에서 파악한 경력을 experiences 배열에 한 번에 담아 호출하세요(한 건이어도 배열로 — 항목마다 도구를 반복 호출하지 마세요). 이력서 본문(add_resume)과 달리 회사·직무·기간·성과를 필드로 나눠 저장해 적합도 분석/자소서 작성 시 정밀하게 활용됩니다. 각 항목은 company가 필수이며, achievements에는 정량 지표가 담긴 성과를, tech에는 사용 기술을 배열로 넣으세요. 같은 회사·직무·입사일(start_date)이 일치하면 갱신되고 중복 생성되지 않으니 여러 번 저장해도 안전합니다. 온보딩 완성도(get_onboarding_status)의 has_experience를 채우려면 1건 이상 추가하세요.',
|
|
@@ -419,6 +452,7 @@ export const TOOLS: ToolDef[] = [
|
|
|
419
452
|
/* -------------------------------------------------------------- projects */
|
|
420
453
|
{
|
|
421
454
|
name: 'add_project',
|
|
455
|
+
idempotent: true, // 이름 키 업서트 — 재호출 안전
|
|
422
456
|
title: '프로젝트 추가',
|
|
423
457
|
description:
|
|
424
458
|
'대표 프로젝트를 저장합니다. projects 배열에 여러 개를 한 번에 담아 호출하세요(한 개여도 배열로 — 항목마다 반복 호출하지 마세요). 각 항목은 name이 필수이며, highlights(핵심 성과)와 tech(사용 기술)를 배열로 넣으면 자소서·면접 준비에서 근거로 활용됩니다. url에 결과물 링크를 넣을 수 있습니다. 같은 이름의 프로젝트는 갱신되고 중복 생성되지 않으니 여러 번 저장해도 안전합니다.',
|
|
@@ -444,6 +478,7 @@ export const TOOLS: ToolDef[] = [
|
|
|
444
478
|
/* ---------------------------------------------------------------- skills */
|
|
445
479
|
{
|
|
446
480
|
name: 'add_skill',
|
|
481
|
+
idempotent: true, // 이름 키 업서트 — 재호출 안전
|
|
447
482
|
title: '기술스택 추가',
|
|
448
483
|
description:
|
|
449
484
|
'보유 기술스택을 저장합니다. 여러 개를 skills 배열에 한 번에 담아 호출하세요(한 개여도 배열로 — 기술마다 도구를 반복 호출하지 마세요). 각 항목은 name이 필수이며, category(언어/프레임워크/툴/소프트스킬 등)·level·years로 숙련도를 함께 기록하면 공고 키워드 매칭이 정확해집니다. 같은 이름의 기술은 갱신되고 중복 생성되지 않으니 여러 번 저장해도 안전합니다. 온보딩 완성도의 has_skills를 채우려면 1개 이상 추가하세요.',
|
|
@@ -467,6 +502,7 @@ export const TOOLS: ToolDef[] = [
|
|
|
467
502
|
},
|
|
468
503
|
{
|
|
469
504
|
name: 'delete_experience',
|
|
505
|
+
destructive: true,
|
|
470
506
|
title: '경력 항목 삭제',
|
|
471
507
|
description:
|
|
472
508
|
'구조화된 경력(직장) 항목 하나를 삭제합니다. 잘못 추출/입력된 경력을 지울 때 사용하세요. 먼저 get_experiences로 목록과 id를 확인하고 사용자와 확인한 뒤 호출합니다. (내용 수정은 add_experience로 같은 항목을 다시 저장하면 갱신됩니다.) confirm에는 DELETE를 넣으세요.',
|
|
@@ -485,6 +521,7 @@ export const TOOLS: ToolDef[] = [
|
|
|
485
521
|
},
|
|
486
522
|
{
|
|
487
523
|
name: 'delete_project',
|
|
524
|
+
destructive: true,
|
|
488
525
|
title: '프로젝트 항목 삭제',
|
|
489
526
|
description:
|
|
490
527
|
'구조화된 프로젝트 항목 하나를 삭제합니다. 잘못 추출/입력된 프로젝트를 지울 때 사용하세요. 먼저 get_projects로 목록과 id를 확인하고 사용자와 확인한 뒤 호출합니다. (내용 수정은 add_project로 같은 항목을 다시 저장하면 갱신됩니다.) confirm에는 DELETE를 넣으세요.',
|
|
@@ -503,6 +540,7 @@ export const TOOLS: ToolDef[] = [
|
|
|
503
540
|
},
|
|
504
541
|
{
|
|
505
542
|
name: 'delete_skill',
|
|
543
|
+
destructive: true,
|
|
506
544
|
title: '기술스택 항목 삭제',
|
|
507
545
|
description:
|
|
508
546
|
'구조화된 기술스택 항목 하나를 삭제합니다. 잘못 추출/입력된 기술을 지울 때 사용하세요. 먼저 get_skills로 목록과 id를 확인하고 사용자와 확인한 뒤 호출합니다. (내용 수정은 add_skill로 같은 항목을 다시 저장하면 갱신됩니다.) confirm에는 DELETE를 넣으세요.',
|
|
@@ -560,6 +598,7 @@ export const TOOLS: ToolDef[] = [
|
|
|
560
598
|
},
|
|
561
599
|
{
|
|
562
600
|
name: 'delete_cover_letter',
|
|
601
|
+
destructive: true,
|
|
563
602
|
title: '자기소개서 삭제',
|
|
564
603
|
description:
|
|
565
604
|
'사용자가 저장된 자기소개서 삭제를 명확히 요청했을 때만 사용합니다. 먼저 get_cover_letters로 전체 목록 또는 공고별 목록을 확인하고, 삭제할 항목을 사용자가 확인한 뒤 호출하세요. 삭제하면 해당 자기소개서의 모든 버전이 함께 삭제되며 되돌릴 수 없습니다. confirm에는 DELETE를 넣으세요.',
|
|
@@ -588,9 +627,10 @@ export const TOOLS: ToolDef[] = [
|
|
|
588
627
|
/* ------------------------------------------------------------------ jobs */
|
|
589
628
|
{
|
|
590
629
|
name: 'save_job_posting',
|
|
630
|
+
idempotent: true, // 같은 url 업서트 — 재호출 안전
|
|
591
631
|
title: '채용공고 저장',
|
|
592
632
|
description:
|
|
593
|
-
'채용공고를 저장합니다(있으면 갱신). company와 position은 필수입니다. 공고 원문은 description에, 핵심 자격요건/우대사항은 requirements 배열에, 핵심 키워드는 keywords에 정리해 넣으면 이후 적합도 분석/자소서 작성에 활용됩니다. 같은 url이면 중복 생성 없이 갱신됩니다. 저장 시 지원(application) 항목이 자동으로 생성됩니다.',
|
|
633
|
+
'채용공고를 저장합니다(있으면 갱신). company와 position은 필수입니다. 공고 원문은 description에, 핵심 자격요건/우대사항은 requirements 배열에, 핵심 키워드는 keywords에 정리해 넣으면 이후 적합도 분석/자소서 작성에 활용됩니다. 회사를 리서치했다면 사업개요·미션은 company_overview, 회사가 강조하는 인재상은 talent_profile, 핵심가치는 core_values에 넣으세요 — 한국 자소서·면접은 인재상/핵심가치 정렬이 핵심이라 저장해두면 이후 작성·면접 준비에서 그대로 끌어다 씁니다. 회사 평판(평점·워라밸·문화 리스크 등)을 리서치했다면 reputation에 요약(출처·최근성 표기)을 넣으면 오퍼 판단·면접에 활용됩니다. 회사 지정 자소서 문항·글자수가 있으면 questions 배열에 구조화 저장하세요(question 필수, max_chars/min_chars/count_mode). 이후 자소서 작성 시 문항별로 validate_cover_letter(min_chars/max_chars/count_mode)로 한도를 점검합니다. 면접·인적성·발표 등 전형 일정을 받으면 schedule 배열에 구조화 저장하세요(label·date 필수, time 선택; 서류 마감은 deadline 필드). 면접초대 메일을 사용자가 붙여넣으면 날짜·시간을 추출해 저장하고, 같은 날 다른 일정과 겹치면 알려주세요. 같은 url이면 중복 생성 없이 갱신됩니다. 저장 시 지원(application) 항목이 자동으로 생성됩니다.',
|
|
594
634
|
inputSchema: JobInputSchema.shape,
|
|
595
635
|
handler: (args) => {
|
|
596
636
|
const { job } = saveJobPosting(args);
|
|
@@ -636,6 +676,7 @@ export const TOOLS: ToolDef[] = [
|
|
|
636
676
|
},
|
|
637
677
|
{
|
|
638
678
|
name: 'delete_job_posting',
|
|
679
|
+
destructive: true,
|
|
639
680
|
title: '채용공고 삭제',
|
|
640
681
|
description:
|
|
641
682
|
'사용자가 저장된 채용공고 삭제를 명확히 요청했을 때만 사용합니다. 먼저 list_jobs로 전체 공고 목록을 확인하고, 삭제할 공고를 사용자가 확인한 뒤 호출하세요. 삭제하면 연결된 지원 상태·적합도 분석·면접 준비가 함께 삭제됩니다. 연결된 자기소개서는 삭제하지 않고 공고 연결만 해제합니다. confirm에는 DELETE를 넣으세요.',
|
|
@@ -736,6 +777,7 @@ export const TOOLS: ToolDef[] = [
|
|
|
736
777
|
},
|
|
737
778
|
{
|
|
738
779
|
name: 'save_fit_analysis',
|
|
780
|
+
idempotent: true, // 공고당 1건 업서트
|
|
739
781
|
title: '적합도 분석 결과 저장',
|
|
740
782
|
description:
|
|
741
783
|
'공고와 사용자 정보를 비교해 당신(AI)이 도출한 적합도 분석을 저장합니다. job_id는 필수이며 먼저 save_job_posting으로 공고가 저장되어 있어야 합니다. score(0~100), summary, strengths(강점), gaps(보완 필요), matched/missing_keywords, recommendations(자소서·지원 전략 제안)를 채워 저장하세요. 같은 공고에 다시 저장하면 갱신됩니다.',
|
|
@@ -782,21 +824,36 @@ export const TOOLS: ToolDef[] = [
|
|
|
782
824
|
{
|
|
783
825
|
name: 'update_application_status',
|
|
784
826
|
title: '지원 상태 변경',
|
|
785
|
-
description:
|
|
827
|
+
description:
|
|
828
|
+
`지원 상태를 변경합니다. 가능한 상태: ${STATUS_LIST}. '서류 합격(document_passed)' 이상으로 바뀌면 면접 준비를 제안하는 힌트를 함께 돌려줍니다. note에 변경 사유를 남길 수 있습니다. ` +
|
|
829
|
+
"'불합격(rejected)'으로 바꿀 때는 rejected_stage에 어느 단계에서 아쉽게 됐는지 함께 남기세요 — 서류=document, 인적성·AI역검=assessment, 면접=interview, 최종·처우=final, 기타=other. 탈락 사유를 알려주는 기업은 드물어, 이 자가 기록이 쌓이면 '서류는 붙는데 면접에서 떨어진다' 같은 패턴이 로컬 데이터로 드러납니다(get_pipeline_stats).",
|
|
786
830
|
inputSchema: {
|
|
787
831
|
job_id: z.string(),
|
|
788
832
|
status: z.enum(APPLICATION_STATUSES),
|
|
789
833
|
note: z.string().optional(),
|
|
790
834
|
submission: ApplicationSubmissionSchema.optional(),
|
|
835
|
+
rejected_stage: z
|
|
836
|
+
.enum(REJECTED_STAGES)
|
|
837
|
+
.optional()
|
|
838
|
+
.describe(
|
|
839
|
+
"탈락 회고 — 'rejected'로 바꿀 때 어느 단계에서 탈락했는지: 서류=document, 인적성·AI역검=assessment, 면접=interview, 최종·처우=final, 기타=other",
|
|
840
|
+
),
|
|
791
841
|
},
|
|
792
842
|
handler: (args) => {
|
|
793
843
|
try {
|
|
794
|
-
const res = updateApplicationStatus(args.job_id, args.status, args.note, args.submission);
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
844
|
+
const res = updateApplicationStatus(args.job_id, args.status, args.note, args.submission, args.rejected_stage);
|
|
845
|
+
// 탈락으로 바꿨는데 단계가 안 남았으면: 실패시키지 않고 저장은 하되, 회고를 유도한다.
|
|
846
|
+
const needsRetro = args.status === 'rejected' && !res.application.rejected_stage;
|
|
847
|
+
const retro_prompt =
|
|
848
|
+
'어느 단계에서 아쉽게 됐는지(서류/인적성/면접/최종)와 배운 점을 남겨두면 다음 지원 전략에 쓸 수 있어요. 기록할까요?';
|
|
849
|
+
const base =
|
|
850
|
+
`지원 상태를 '${APPLICATION_STATUS_LABELS[args.status as keyof typeof APPLICATION_STATUS_LABELS]}'(으)로 바꿨어요.`;
|
|
851
|
+
const user_message = base + (res.hint ? `\n\n${res.hint}` : needsRetro ? `\n\n${retro_prompt}` : '');
|
|
852
|
+
const text = needsRetro
|
|
853
|
+
? `${user_message}\n\n(AI 안내: 사용자에게 어느 단계에서 떨어졌는지와 배운 점을 물어, rejected_stage와 note로 함께 기록하도록 제안하세요.)`
|
|
854
|
+
: user_message;
|
|
798
855
|
return ok(
|
|
799
|
-
|
|
856
|
+
text,
|
|
800
857
|
{
|
|
801
858
|
...res,
|
|
802
859
|
user_message,
|
|
@@ -805,7 +862,12 @@ export const TOOLS: ToolDef[] = [
|
|
|
805
862
|
prompt: res.hint,
|
|
806
863
|
next_workflow: 'prepare_interview',
|
|
807
864
|
}
|
|
808
|
-
:
|
|
865
|
+
: needsRetro
|
|
866
|
+
? {
|
|
867
|
+
prompt: retro_prompt,
|
|
868
|
+
next_tool: 'update_application_status',
|
|
869
|
+
}
|
|
870
|
+
: null,
|
|
809
871
|
},
|
|
810
872
|
);
|
|
811
873
|
} catch (e) {
|
|
@@ -817,11 +879,13 @@ export const TOOLS: ToolDef[] = [
|
|
|
817
879
|
/* ---------------------------------------------------------- interview prep */
|
|
818
880
|
{
|
|
819
881
|
name: 'save_interview_prep',
|
|
882
|
+
idempotent: true, // 공고당 1건 업서트(debrief는 병합)
|
|
820
883
|
title: '면접 준비 자료 저장',
|
|
821
884
|
description:
|
|
822
885
|
'예상 면접 질문, 꼬리 질문, STAR 답변 가이드, 1분 자기소개 초안 등 해당 공고 기준 면접 준비 자료를 저장합니다. 공고/직무/자기소개서를 근거로 당신(AI)이 생성한 자료를 저장하는 데 사용합니다. ' +
|
|
823
886
|
"질문은 두 갈래로 준비하세요 — 각 질문의 category를 'technical'(기술·직무 1차 면접) 또는 'behavioral'(인성·컬처핏·임원 2차 면접, 자기소개서·프로필 기반)로 지정합니다. 가능하면 두 갈래 모두 채워 한 번에 저장하세요(대시보드가 기술/인성·컬처핏 탭으로 나눠 보여줍니다). " +
|
|
824
|
-
'
|
|
887
|
+
'면접을 본 뒤에는 같은 도구에 debrief(받은 질문 asked_questions·잘된 점 went_well·개선점 to_improve·다음 라운드 포커스 next_round_focus·체감 분위기 sentiment)를 채워 저장하면 다음 라운드 준비와 일관성 점검에 그대로 이어집니다(면접 전 준비와 면접 후 디브리프가 한 공고에 함께 쌓입니다). ' +
|
|
888
|
+
'job_id는 필수입니다. 같은 공고에 다시 저장하면 갱신됩니다(debrief는 기존 값과 병합되어 기존 필드를 지우지 않습니다).',
|
|
825
889
|
inputSchema: InterviewPrepInputSchema.shape,
|
|
826
890
|
handler: (args) => {
|
|
827
891
|
try {
|
|
@@ -844,27 +908,27 @@ export const TOOLS: ToolDef[] = [
|
|
|
844
908
|
name: 'export_cover_letter',
|
|
845
909
|
title: '자기소개서 내보내기',
|
|
846
910
|
description:
|
|
847
|
-
'자기소개서를 Markdown
|
|
911
|
+
'자기소개서를 Word(.docx)·Markdown·인쇄용 HTML(브라우저에서 PDF로 저장) 파일로 내보내 데이터 폴더의 exports에 저장하고, 파일 경로를 돌려줍니다. ATS 제출처럼 Word 파일이 필요하면 format=docx를 쓰세요. 사용자가 "자소서 파일로 받고 싶어"라고 할 때 사용하세요. 대시보드에서 직접 다운로드할 수도 있습니다.',
|
|
848
912
|
inputSchema: {
|
|
849
913
|
cover_letter_id: z.string(),
|
|
850
|
-
format: z.enum(['md', 'html']).optional().describe('기본 md'),
|
|
914
|
+
format: z.enum(['md', 'html', 'docx']).optional().describe('기본 md'),
|
|
851
915
|
},
|
|
852
|
-
handler: (args) => {
|
|
916
|
+
handler: async (args) => {
|
|
853
917
|
const cl = coverLetterRepo.get(args.cover_letter_id, true);
|
|
854
918
|
if (!cl) return fail('자기소개서를 찾을 수 없습니다.');
|
|
855
919
|
const job = cl.job_id ? jobRepo.get(cl.job_id) : null;
|
|
856
920
|
const profile = profileRepo.get();
|
|
857
921
|
const format = args.format ?? 'md';
|
|
858
|
-
const result =
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
return ok(`'${cl.title}'를 ${format.toUpperCase()}로 내보냈습니다.\n저장 위치: ${
|
|
867
|
-
path:
|
|
922
|
+
const result =
|
|
923
|
+
format === 'docx'
|
|
924
|
+
? await coverLetterToDocx(cl, { job, profile })
|
|
925
|
+
: format === 'html'
|
|
926
|
+
? coverLetterToHtml(cl, { job, profile })
|
|
927
|
+
: coverLetterToMarkdown(cl, { job, profile });
|
|
928
|
+
const written = writeExport(result);
|
|
929
|
+
if ('error' in written) return fail(written.error);
|
|
930
|
+
return ok(`'${cl.title}'를 ${format.toUpperCase()}로 내보냈습니다.\n저장 위치: ${written.path}`, {
|
|
931
|
+
path: written.path,
|
|
868
932
|
filename: result.filename,
|
|
869
933
|
content: result.content,
|
|
870
934
|
});
|
|
@@ -874,26 +938,26 @@ export const TOOLS: ToolDef[] = [
|
|
|
874
938
|
name: 'export_resume',
|
|
875
939
|
title: '이력서/경력기술서 내보내기',
|
|
876
940
|
description:
|
|
877
|
-
'저장된 이력서·경력기술서 등 문서를 Markdown
|
|
941
|
+
'저장된 이력서·경력기술서 등 문서를 Word(.docx)·Markdown·인쇄용 HTML(브라우저에서 PDF로 저장) 파일로 내보내 데이터 폴더의 exports에 저장하고, 파일 경로를 돌려줍니다. ATS 제출처럼 Word 파일이 필요하면 format=docx를 쓰세요. 사용자가 공고에 맞춰 정리한 이력서를 먼저 add_resume로 저장한 뒤, "이력서 파일로 받고 싶어"라고 할 때 사용하세요. document_id는 get_resumes로 확인할 수 있습니다. 대시보드에서 직접 다운로드할 수도 있습니다.',
|
|
878
942
|
inputSchema: {
|
|
879
943
|
document_id: z.string(),
|
|
880
|
-
format: z.enum(['md', 'html']).optional().describe('기본 md'),
|
|
944
|
+
format: z.enum(['md', 'html', 'docx']).optional().describe('기본 md'),
|
|
881
945
|
},
|
|
882
|
-
handler: (args) => {
|
|
946
|
+
handler: async (args) => {
|
|
883
947
|
const doc = documentRepo.get(args.document_id);
|
|
884
948
|
if (!doc) return fail('문서를 찾을 수 없습니다.');
|
|
885
949
|
const profile = profileRepo.get();
|
|
886
950
|
const format = args.format ?? 'md';
|
|
887
|
-
const result =
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
return ok(`'${doc.title}'를 ${format.toUpperCase()}로 내보냈습니다.\n저장 위치: ${
|
|
896
|
-
path:
|
|
951
|
+
const result =
|
|
952
|
+
format === 'docx'
|
|
953
|
+
? await resumeToDocx(doc, profile)
|
|
954
|
+
: format === 'html'
|
|
955
|
+
? resumeToHtml(doc, profile)
|
|
956
|
+
: resumeToMarkdown(doc, profile);
|
|
957
|
+
const written = writeExport(result);
|
|
958
|
+
if ('error' in written) return fail(written.error);
|
|
959
|
+
return ok(`'${doc.title}'를 ${format.toUpperCase()}로 내보냈습니다.\n저장 위치: ${written.path}`, {
|
|
960
|
+
path: written.path,
|
|
897
961
|
filename: result.filename,
|
|
898
962
|
content: result.content,
|
|
899
963
|
});
|
|
@@ -903,11 +967,11 @@ export const TOOLS: ToolDef[] = [
|
|
|
903
967
|
name: 'export_profile',
|
|
904
968
|
title: '프로필을 이력서로 내보내기',
|
|
905
969
|
description:
|
|
906
|
-
'저장된 프로필·경력·프로젝트·보유 기술을 한 장의 이력서 형식으로 모아 Markdown
|
|
970
|
+
'저장된 프로필·경력·프로젝트·보유 기술을 한 장의 이력서 형식으로 모아 Word(.docx)·Markdown·인쇄용 HTML(브라우저에서 PDF로 저장)·JSON Resume(format=json, jsonresume.org 표준) 파일로 내보내 데이터 폴더의 exports에 저장하고, 파일 경로를 돌려줍니다. ATS 제출처럼 Word 파일이 필요하면 format=docx, 다른 이력서 도구·테마와 연동할 표준 데이터가 필요하면 format=json을 쓰세요. 사용자가 "내 프로필을 이력서로 뽑아줘"처럼 구조화된 데이터 전체를 이력서로 받고 싶을 때 사용하세요. 특정 공고 맞춤 이력서는 add_resume로 정리·저장한 뒤 export_resume를 쓰세요. 대시보드에서 직접 다운로드할 수도 있습니다.',
|
|
907
971
|
inputSchema: {
|
|
908
|
-
format: z.enum(['md', 'html']).optional().describe('기본 md'),
|
|
972
|
+
format: z.enum(['md', 'html', 'docx', 'json']).optional().describe('기본 md. json = JSON Resume 표준'),
|
|
909
973
|
},
|
|
910
|
-
handler: (args) => {
|
|
974
|
+
handler: async (args) => {
|
|
911
975
|
const profile = profileRepo.get();
|
|
912
976
|
if (!profile) return fail('프로필이 없습니다. 먼저 프로필을 저장하세요.');
|
|
913
977
|
const format = args.format ?? 'md';
|
|
@@ -915,18 +979,17 @@ export const TOOLS: ToolDef[] = [
|
|
|
915
979
|
const projects = projectRepo.list();
|
|
916
980
|
const skills = skillRepo.list();
|
|
917
981
|
const result =
|
|
918
|
-
format === '
|
|
919
|
-
?
|
|
920
|
-
:
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
path: filePath,
|
|
982
|
+
format === 'docx'
|
|
983
|
+
? await profileToDocx(profile, experiences, projects, skills)
|
|
984
|
+
: format === 'html'
|
|
985
|
+
? profileToHtml(profile, experiences, projects, skills)
|
|
986
|
+
: format === 'json'
|
|
987
|
+
? profileToJsonResume(profile, experiences, projects, skills)
|
|
988
|
+
: profileToMarkdown(profile, experiences, projects, skills);
|
|
989
|
+
const written = writeExport(result);
|
|
990
|
+
if ('error' in written) return fail(written.error);
|
|
991
|
+
return ok(`프로필을 이력서(${format.toUpperCase()})로 내보냈습니다.\n저장 위치: ${written.path}`, {
|
|
992
|
+
path: written.path,
|
|
930
993
|
filename: result.filename,
|
|
931
994
|
content: result.content,
|
|
932
995
|
});
|
|
@@ -975,6 +1038,156 @@ export const TOOLS: ToolDef[] = [
|
|
|
975
1038
|
return ok(`최근 활동 ${acts.length}건`, acts);
|
|
976
1039
|
},
|
|
977
1040
|
},
|
|
1041
|
+
{
|
|
1042
|
+
name: 'get_followups',
|
|
1043
|
+
title: '후속 조치 알림(놓친 지원·마감·면접준비)',
|
|
1044
|
+
description:
|
|
1045
|
+
'지금 챙겨야 할 것들을 한 번에 돌려줍니다: ① 응답 없이 오래된 지원(지원 후 무응답·서류합격 후 일정대기·면접 후 결과대기) ② 임박했거나 지난 지원 마감(지원 전 단계) ③ 합격했는데 아직 면접 준비가 없는 공고 ④ 오늘~7일 내 다가오는 전형 일정(면접·인적성·발표; `upcoming_schedule`에 각 일정의 D-day) ⑤ 서로 다른 공고의 일정이 같은 날짜에 겹칠 때(`schedule_conflicts` — 같은 공고의 연속 단계는 충돌이 아니고, 서로 다른 공고 2건 이상이 한 날에 몰릴 때만; 있으면 사용자에게 알려 조정 제안). 사용자가 "내 지원 현황 어때?", "뭐 챙겨야 해?", "이번 주 일정 뭐 있어?", "후속 연락할 거 있어?"라고 묻거나, 세션을 이어가며 놓친 게 없는지 먼저 점검할 때 사용하세요. 읽기 전용입니다. 지원(applied) 항목에는 결정론 팔로업 타이밍이 붙습니다: `followup_timing="due"`는 지원 후 5~7영업일(주말 제외)로 정중한 후속 문의 적기, `"overdue"`는 8영업일 이상으로 적기가 지났다는 뜻이며, `business_days_since`는 경과 영업일 수입니다. 달력일 45일 이상 무응답이면 `suggest_on_hold=true`가 붙으니 "보류(on_hold)로 옮길까요?"라고 제안하세요. 응답의 reason은 사용자 언어로 풀어 전하고, 후속 문의는 강요하지 말고 선택지로 제안하세요(채용 관행상 과한 독촉은 역효과 — 문의는 정중히 1회).',
|
|
1046
|
+
inputSchema: {},
|
|
1047
|
+
readOnly: true,
|
|
1048
|
+
handler: () => {
|
|
1049
|
+
const d = getActionDigest();
|
|
1050
|
+
const n = d.followups.length + d.deadlines.length + d.interview_todo.length + d.upcoming_schedule.length;
|
|
1051
|
+
const parts = [`후속 ${d.followups.length}`, `마감 ${d.deadlines.length}`, `면접준비 ${d.interview_todo.length}`, `일정 ${d.upcoming_schedule.length}`];
|
|
1052
|
+
if (d.schedule_conflicts.length) parts.push(`일정충돌 ${d.schedule_conflicts.length}`);
|
|
1053
|
+
const msg = n === 0 && d.schedule_conflicts.length === 0
|
|
1054
|
+
? '지금 당장 챙길 후속 항목이 없습니다(무응답 지원·임박 마감·면접 준비 대기·다가오는 일정 없음).'
|
|
1055
|
+
: `챙길 항목: ${parts.join(' · ')}`;
|
|
1056
|
+
return ok(msg, d);
|
|
1057
|
+
},
|
|
1058
|
+
},
|
|
1059
|
+
{
|
|
1060
|
+
name: 'get_pipeline_stats',
|
|
1061
|
+
title: '지원 파이프라인 분석(깔때기·합격률)',
|
|
1062
|
+
description:
|
|
1063
|
+
'전체 지원 현황을 분석해 돌려줍니다: 현재 상태 분포(by_status), 단계별 도달 깔때기(funnel: 지원→서류합격→면접→최종, 상태 변경 이력으로 복원한 실측치라 현재 불합격이어도 거쳐간 최고 단계까지 집계), 통과율(rates: 서류·면접·최종, 지원 대비 %), 결과 요약(outcomes), 탈락 단계 분포(rejected_by_stage: 서류·인적성·면접·최종·기타별 탈락 건수 + rejected_unrecorded: 단계 미기록 수). 사용자가 "내 합격률 어때?", "지원 통계 보여줘", "어느 단계에서 많이 떨어져?"라고 물을 때 사용하세요. 비율은 설명용이며 표본이 작으면 예측력이 낮다는 점을 함께 전하세요(note 참고). 읽기 전용입니다.',
|
|
1064
|
+
inputSchema: {},
|
|
1065
|
+
readOnly: true,
|
|
1066
|
+
handler: () => {
|
|
1067
|
+
const s = getPipelineStats();
|
|
1068
|
+
if (s.total === 0) return ok('아직 지원 내역이 없어 분석할 통계가 없습니다.', s);
|
|
1069
|
+
const r = s.rates;
|
|
1070
|
+
const fmt = (v: number | null) => (v == null ? '—' : `${v}%`);
|
|
1071
|
+
// 탈락 분포 한 줄: 기록된 단계별 건수 + 미기록이 있으면 기록 유도.
|
|
1072
|
+
const rejParts = s.rejected_by_stage.map((x) => `${REJECTED_STAGE_LABELS[x.stage]} ${x.count}`);
|
|
1073
|
+
if (s.rejected_unrecorded > 0) rejParts.push(`미기록 ${s.rejected_unrecorded}건 — 기록하면 패턴이 보입니다`);
|
|
1074
|
+
const rejLine = rejParts.length ? ` · 탈락 분포: ${rejParts.join('·')}` : '';
|
|
1075
|
+
return ok(
|
|
1076
|
+
`지원 ${s.total}건 · 깔때기 지원 ${s.funnel.applied}→서류 ${s.funnel.document_passed}→면접 ${s.funnel.interview}→최종 ${s.funnel.final_passed} · 통과율 서류 ${fmt(r.document_pass)}·면접 ${fmt(r.interview)}·최종 ${fmt(r.offer)}${rejLine} (표본 작으면 추세 참고로만)`,
|
|
1077
|
+
s,
|
|
1078
|
+
);
|
|
1079
|
+
},
|
|
1080
|
+
},
|
|
1081
|
+
{
|
|
1082
|
+
name: 'search_jobs',
|
|
1083
|
+
// 외부 공개 잡보드(원티드·점핏)로 검색어가 나간다 — openWorld를 정직하게 표시.
|
|
1084
|
+
openWorld: true,
|
|
1085
|
+
title: '공고 검색·가져오기(키워드)',
|
|
1086
|
+
description:
|
|
1087
|
+
`키워드로 무인증 공개 채용 사이트(${JOB_SEARCH_SOURCES.join('·')})에서 채용 공고를 가져옵니다. 사용자가 "백엔드 신입 공고 찾아줘", "React 공고 가져와"처럼 공고를 찾아 달라고 할 때 사용하세요. 결과(회사·직무·링크·지역·경력·기술스택·마감)를 사용자에게 보기 좋게 보여 주고, **사용자가 관심 있다고 고른 것만** save_job_posting으로 저장하세요(전부 자동 저장하지 마세요). 저장한 공고는 이어서 get_application_context로 분석·자소서로 연결됩니다. ` +
|
|
1088
|
+
`참고: 이 도구는 외부 공개 페이지에서 공고를 가져오기만 하며(보내는 것은 검색 키워드뿐, 사용자 데이터는 절대 전송하지 않음), 봇 차단 우회는 하지 않습니다. 사람인·잡코리아는 키 발급 또는 약관 제약으로 여기 포함되지 않습니다. 결과가 비어 있으면 키워드를 바꾸거나 사용자에게 공고 URL/본문을 받아 save_job_posting으로 저장하세요.`,
|
|
1089
|
+
inputSchema: {
|
|
1090
|
+
keyword: z.string().describe('검색어(직무·기술·키워드). 예: "백엔드", "React 신입"'),
|
|
1091
|
+
sources: z.array(z.enum([...JOB_SEARCH_SOURCES] as [string, ...string[]])).optional().describe(`대상 사이트(생략 시 전체: ${JOB_SEARCH_SOURCES.join('·')})`),
|
|
1092
|
+
limit: z.number().optional().describe('사이트별 최대 건수(기본 10, 최대 50)'),
|
|
1093
|
+
},
|
|
1094
|
+
readOnly: true,
|
|
1095
|
+
handler: async (args) => {
|
|
1096
|
+
const res = await searchJobs(args.keyword, { sources: args.sources, limit: args.limit });
|
|
1097
|
+
const failed = res.providers.filter((p) => !p.ok);
|
|
1098
|
+
const okCount = res.results.length;
|
|
1099
|
+
const parts = [`'${res.query}' 검색 결과 ${okCount}건`];
|
|
1100
|
+
if (failed.length) parts.push(`(일부 사이트 응답 실패: ${failed.map((f) => f.source).join(', ')})`);
|
|
1101
|
+
if (okCount === 0) parts.push('— 키워드를 바꾸거나, 공고 URL/본문을 주시면 직접 저장해 드릴게요.');
|
|
1102
|
+
return ok(parts.join(' '), res);
|
|
1103
|
+
},
|
|
1104
|
+
},
|
|
1105
|
+
{
|
|
1106
|
+
name: 'prescreen_job',
|
|
1107
|
+
title: '공고 사전 프리스크린(키워드 겹침)',
|
|
1108
|
+
description:
|
|
1109
|
+
'저장된 공고의 핵심 키워드를 **사용자의 보유 기술·프로젝트/경력 기술스택(구조화 스킬)과 대표 이력서 본문 양쪽**에 대조해 "이 공고를 깊게 볼 가치가 있는지"를 빠르게 가늠합니다. LLM 없이 셀 수 있는 키워드 겹침만 돌려주는 값싼 사전 필터입니다: `matched`(스킬 목록에서 발견)·`resume_matched`(이력서 본문에서 발견)·`combined_matched`(둘의 합집합)·`combined_coverage`(합집합 커버리지 0~1)·`resume_checked`(대표 이력서 존재 여부). **진짜 적합도 판단은 당신(AI)이 get_application_context로 맥락을 받아 직접 수행**합니다(이 도구는 그 대체가 아닙니다). 사용자가 공고를 여러 개 훑으며 우선순위를 정하거나 "이거 지원할 만해?"라고 물을 때 본격 분석 전에 가볍게 호출하세요. **combined_coverage가 낮은데 실제로는 보유한 역량이라면, 이력서 본문에 그 키워드가 안 드러나는 것이니 이력서 보강(해당 키워드 노출)을 제안하세요**(ATS 키워드 매칭에 유효). coverage가 낮아도 키워드 미기재일 수 있으니 단정하지 말고, 표기되지 않은 역량은 사용자에게 확인하세요. 읽기 전용입니다.',
|
|
1110
|
+
inputSchema: { job_id: z.string() },
|
|
1111
|
+
readOnly: true,
|
|
1112
|
+
handler: (args) => {
|
|
1113
|
+
const r = prescreenJob(args.job_id);
|
|
1114
|
+
if (!r) return fail('공고를 찾을 수 없습니다. 먼저 save_job_posting으로 저장하세요.');
|
|
1115
|
+
if (r.combined_coverage == null)
|
|
1116
|
+
return ok('이 공고에 저장된 핵심 키워드가 없어 겹침을 셀 수 없습니다. save_job_posting의 keywords에 핵심 키워드를 정리해 넣으면 프리스크린이 가능합니다.', r);
|
|
1117
|
+
const pct = Math.round(r.combined_coverage * 100);
|
|
1118
|
+
const combinedMissing = r.job_keywords.filter((k) => !r.combined_matched.includes(k));
|
|
1119
|
+
const resumeNote = r.resume_checked
|
|
1120
|
+
? ''
|
|
1121
|
+
: ' (대표 이력서가 없어 스킬 목록만 대조했어요 — 이력서를 저장하면 본문 키워드까지 대조합니다.)';
|
|
1122
|
+
return ok(
|
|
1123
|
+
`키워드 커버리지 ${pct}% — 스킬+대표 이력서 본문에서 ${r.combined_matched.length}/${r.job_keywords.length}개 확인.${resumeNote} 아직 안 드러난 키워드: ${combinedMissing.join(', ') || '없음'}. (셀 수 있는 겹침일 뿐 실제 적합도는 직접 판단하세요. 보유 역량인데 안 드러났다면 이력서 보강을 제안하세요.)`,
|
|
1124
|
+
r,
|
|
1125
|
+
);
|
|
1126
|
+
},
|
|
1127
|
+
},
|
|
1128
|
+
{
|
|
1129
|
+
name: 'save_offer',
|
|
1130
|
+
idempotent: true, // 공고당 1건 업서트
|
|
1131
|
+
title: '오퍼(제안) 저장',
|
|
1132
|
+
description:
|
|
1133
|
+
'받은 채용 제안(오퍼)을 공고에 연결해 저장합니다(공고당 1건, 다시 저장하면 갱신). 현금성 항목은 **만원 단위 숫자**로 — base_salary(기본급/연봉)·bonus_amount(연 성과급·상여 기대액)·welfare_amount(복지포인트 등 연환산)·signing_amount(사이닝 일시금). 주식/스톡옵션은 가치평가가 모호하니 equity_note에 텍스트로, 그 외 보상은 comp_note에. 근무형태·계약형태·수락 마감일(accept_deadline)도 저장하세요. **당신(AI)이 이 오퍼를 종합 평가해 ai_score(0~100)와 verdict(최종평 — "어떤 상황이면 이 회사가 좋다" 식 한두 문장)를 함께 채워 저장하세요** — CareerMate엔 LLM이 없어 이 점수·평이 대시보드에 그대로 표시됩니다. 회사 평판이 판단에 필요하면 공고의 reputation을 먼저 채워두세요. 여러 오퍼의 정밀 비교는 compare_offers로 데이터를 받아 당신이 직접 하세요(총보상·성장·안정성·워라밸). **오퍼의 진행 상태는 offer_status로 기록하세요** — open(기본, 진행 중)/accepted(수락)/declined(거절). 사용자가 "그 오퍼 거절했어/수락하기로 했어"라고 하면 이 도구로 offer_status만 갱신하면 됩니다 — 삭제하지 않습니다(연봉·조건 기록은 다음 협상의 자산이고, 대시보드가 지난 오퍼로 접어서 보여줍니다). accepted로 저장하면 지원 상태(최종 합격) 갱신도 함께 제안하세요.',
|
|
1134
|
+
inputSchema: OfferInputSchema.shape,
|
|
1135
|
+
handler: (args) => {
|
|
1136
|
+
try {
|
|
1137
|
+
const offer = saveOffer(args);
|
|
1138
|
+
const total = offer.total_comp_annual_est;
|
|
1139
|
+
const statusLabel =
|
|
1140
|
+
offer.offer_status === 'accepted' ? ' · 수락' : offer.offer_status === 'declined' ? ' · 거절' : '';
|
|
1141
|
+
const user_message =
|
|
1142
|
+
`'${offer.company}' 오퍼를 저장했어요` +
|
|
1143
|
+
(offer.ai_score != null ? ` (점수 ${offer.ai_score})` : '') +
|
|
1144
|
+
(total != null ? ` · 추정 연 현금성 ${total.toLocaleString()}만원` : '') +
|
|
1145
|
+
statusLabel +
|
|
1146
|
+
'.';
|
|
1147
|
+
// 수락한 오퍼인데 지원 상태가 아직 최종 합격이 아니면 상태 갱신을 제안한다
|
|
1148
|
+
// (오퍼 상태 = 내 응답, 지원 상태 = 전형 결과 — 직교 축이라 자동 변경 대신 제안).
|
|
1149
|
+
const app = applicationRepo.getByJob(offer.job_id);
|
|
1150
|
+
const suggested_next_action =
|
|
1151
|
+
offer.offer_status === 'accepted' && app?.status !== 'final_passed'
|
|
1152
|
+
? {
|
|
1153
|
+
prompt: "지원 상태도 '최종 합격'으로 바꿀까요?",
|
|
1154
|
+
next_tool: 'update_application_status',
|
|
1155
|
+
}
|
|
1156
|
+
: null;
|
|
1157
|
+
const msg = suggested_next_action
|
|
1158
|
+
? `${user_message}\n\n사용자에게 지원 상태를 '최종 합격'으로 함께 바꿀지 물어보세요.`
|
|
1159
|
+
: user_message;
|
|
1160
|
+
return ok(msg, { ...offer, user_message, suggested_next_action });
|
|
1161
|
+
} catch (e) {
|
|
1162
|
+
return fail(e instanceof Error ? e.message : '저장 실패');
|
|
1163
|
+
}
|
|
1164
|
+
},
|
|
1165
|
+
},
|
|
1166
|
+
{
|
|
1167
|
+
name: 'compare_offers',
|
|
1168
|
+
title: '오퍼 비교',
|
|
1169
|
+
description:
|
|
1170
|
+
'저장된 모든 오퍼를 **점수순(당신이 매긴 ai_score)** 으로 회사·직무·추정 연 현금성 총액·근무형태·수락마감·최종평과 함께 돌려줍니다. total_comp_annual_est는 CareerMate가 결정론으로 합산한 추정치(기본급+성과급+복지, 일시금 제외)입니다. 각 오퍼에는 offer_status(open/accepted/declined)가 포함되니 **비교는 진행 중(open) 오퍼 위주로** 하고, 수락·거절된 지난 오퍼는 기준점(과거 조건)으로만 참고하세요. "어느 오퍼가 나은지"의 정밀 판단은 이 데이터를 받아 당신이 offer-evaluation·salary-negotiation 관점(총보상·성장·안정성·워라밸·회사 평판)으로 직접 하세요. 사용자가 "오퍼 비교해줘"라고 할 때 사용합니다. 읽기 전용입니다.',
|
|
1171
|
+
inputSchema: {},
|
|
1172
|
+
readOnly: true,
|
|
1173
|
+
handler: () => {
|
|
1174
|
+
const offers = compareOffers();
|
|
1175
|
+
if (offers.length === 0) return ok('저장된 오퍼가 없습니다. save_offer로 먼저 저장하세요.', offers);
|
|
1176
|
+
return ok(`오퍼 ${offers.length}건(점수순).`, offers);
|
|
1177
|
+
},
|
|
1178
|
+
},
|
|
1179
|
+
{
|
|
1180
|
+
name: 'get_offer',
|
|
1181
|
+
title: '오퍼 조회(공고별)',
|
|
1182
|
+
description: '특정 공고에 저장된 오퍼 1건을 돌려줍니다(없으면 null). 읽기 전용입니다.',
|
|
1183
|
+
inputSchema: { job_id: z.string() },
|
|
1184
|
+
readOnly: true,
|
|
1185
|
+
handler: (args) => {
|
|
1186
|
+
const offer = getOffer(args.job_id);
|
|
1187
|
+
if (!offer) return ok('이 공고에 저장된 오퍼가 없습니다.', null);
|
|
1188
|
+
return ok(`'${offer.company}' 오퍼.`, offer);
|
|
1189
|
+
},
|
|
1190
|
+
},
|
|
978
1191
|
|
|
979
1192
|
/* ------------------------------------------------------- workflow guide */
|
|
980
1193
|
{
|
|
@@ -1041,7 +1254,7 @@ export const TOOLS: ToolDef[] = [
|
|
|
1041
1254
|
name: 'get_verifier',
|
|
1042
1255
|
title: '검증 루브릭 (저장 전 자가검증)',
|
|
1043
1256
|
description:
|
|
1044
|
-
'커리어 산출물(자소서·이력서·면접 답변·적합도 등)을 save_*로 저장하기 직전에 호출하세요. 해당 검증기의 점검 항목·합격 기준·세는 방법을 루브릭으로 돌려줍니다 — 실제 점검과 합격/불합격 판단은 당신(AI)이 직접 합니다(CareerMate는 내부 LLM이 없어 의미 판단을 대신하지 않습니다).
|
|
1257
|
+
'커리어 산출물(자소서·이력서·면접 답변·적합도 등)을 save_*로 저장하기 직전에 호출하세요. 해당 검증기의 점검 항목·합격 기준·세는 방법을 루브릭으로 돌려줍니다 — 실제 점검과 합격/불합격 판단은 당신(AI)이 직접 합니다(CareerMate는 내부 LLM이 없어 의미 판단을 대신하지 않습니다). 7개 검증기: truthfulness(사실/허위수치), consistency(산출물 간 모순), recency-staleness(신선도), responsiveness-on-target(질문에 답했는가), ats-compat(ATS 호환), human-voice(AI 티/사람 목소리), ai-detection-risk(한국 AI-자소서 탐지 시장 — 진정성·면접 방어가능성·자기복제 점검, 탐지 우회 아님).',
|
|
1045
1258
|
inputSchema: {
|
|
1046
1259
|
id: z
|
|
1047
1260
|
.enum([...VERIFIER_IDS] as [VerifierId, ...VerifierId[]])
|
|
@@ -1063,14 +1276,20 @@ export const TOOLS: ToolDef[] = [
|
|
|
1063
1276
|
name: 'validate_cover_letter',
|
|
1064
1277
|
title: '자기소개서 자동 점검 (저장 전 미리보기)',
|
|
1065
1278
|
description:
|
|
1066
|
-
'자기소개서를 save_cover_letter_version으로 저장하기 직전에 호출하면, 저장하지 않고 미리 점검만 합니다. CareerMate가 LLM 없이 결정론으로 (1) 본문의 정량 수치가 저장된 경력·이력서·프로젝트에 근거가 있는지(없으면 환각 의심 → 저장 시 차단됨), (2) 공고에서 가져온 수치를 본인 성과처럼 쓰지 않았는지, (3) 번역투·제너릭 도입·빈 형용사·블라인드 위반 같은 문체 신호를 세어 돌려줍니다. 점검은 셀 수 있는 것만 봅니다 — 사실성·동문서답 같은 의미 판단은 당신(AI)이 직접 하세요. job_id를 주면 공고 수치까지 비교합니다.',
|
|
1279
|
+
'자기소개서를 save_cover_letter_version으로 저장하기 직전에 호출하면, 저장하지 않고 미리 점검만 합니다. CareerMate가 LLM 없이 결정론으로 (1) 본문의 정량 수치가 저장된 경력·이력서·프로젝트에 근거가 있는지(없으면 환각 의심 → 저장 시 차단됨), (2) 공고에서 가져온 수치를 본인 성과처럼 쓰지 않았는지, (3) 번역투·제너릭 도입·빈 형용사·블라인드 위반 같은 문체 신호를 세어 돌려줍니다. 또 (4) 글자수를 공백포함·공백제외·UTF-8 byte 세 기준으로 정확히 세고, min_chars/max_chars를 주면 상·하한 충족 여부까지 돌려줍니다(LLM은 글자수를 정확히 못 세므로 이 기능을 쓰세요). 점검은 셀 수 있는 것만 봅니다 — 사실성·동문서답 같은 의미 판단은 당신(AI)이 직접 하세요. 한국 문항형 자소서는 문항마다 글자수 한도가 다르니 각 문항 본문을 그 문항의 한도로 따로 점검하세요. 공고에 questions가 저장돼 있으면 그 문항의 한도를 min_chars/max_chars/count_mode로 넘겨 점검하세요. job_id를 주면 공고 수치까지 비교합니다.',
|
|
1067
1280
|
inputSchema: {
|
|
1068
|
-
text: z.string().max(200_000).describe('점검할 자기소개서 본문'),
|
|
1281
|
+
text: z.string().max(200_000).describe('점검할 자기소개서 본문(문항형이면 한 문항 본문)'),
|
|
1069
1282
|
job_id: z.string().optional().describe('연결할 공고(있으면 공고 수치까지 비교)'),
|
|
1070
1283
|
strict: z
|
|
1071
1284
|
.boolean()
|
|
1072
1285
|
.optional()
|
|
1073
1286
|
.describe('이번 점검만 엄격하게(이력서 본문에 근거 없는 수치도 차단). 사용자가 "엄격하게 봐줘"라고 할 때 true. 생략하면 저장된 모드를 따름'),
|
|
1287
|
+
min_chars: z.number().int().positive().optional().describe('글자수 하한(이 문항 기준). 미달이면 알려줍니다'),
|
|
1288
|
+
max_chars: z.number().int().positive().optional().describe('글자수 상한(이 문항 기준). 초과면 알려줍니다'),
|
|
1289
|
+
count_mode: z
|
|
1290
|
+
.enum(['with_space', 'no_space', 'byte'])
|
|
1291
|
+
.optional()
|
|
1292
|
+
.describe('글자수 기준: with_space=공백포함(기본), no_space=공백제외, byte=UTF-8 바이트. 공고가 지정한 기준을 쓰고, 모르면 공백포함'),
|
|
1074
1293
|
},
|
|
1075
1294
|
readOnly: true,
|
|
1076
1295
|
handler: (args) => {
|
|
@@ -1081,11 +1300,30 @@ export const TOOLS: ToolDef[] = [
|
|
|
1081
1300
|
? `⛔ 이대로 저장하면 막힙니다 — ${report.blocking.map((b) => `${b.label}: ${b.detail}`).join('; ')}`
|
|
1082
1301
|
: '✅ 자동 차단 항목 없음(셀 수 있는 항목 기준).';
|
|
1083
1302
|
const modeLine = report.strict ? '\n(엄격 모드 적용 중)' : '';
|
|
1084
|
-
|
|
1303
|
+
|
|
1304
|
+
// Deterministic char count (chatbots can't count reliably) + optional limit check.
|
|
1305
|
+
const cc = report.charCounts;
|
|
1306
|
+
let charLine = `\n글자수 — 공백포함 ${cc.withSpace}자 · 공백제외 ${cc.noSpace}자 · ${cc.byte}byte`;
|
|
1307
|
+
const min = typeof args?.min_chars === 'number' ? args.min_chars : undefined;
|
|
1308
|
+
const max = typeof args?.max_chars === 'number' ? args.max_chars : undefined;
|
|
1309
|
+
let charLimit;
|
|
1310
|
+
if (min !== undefined || max !== undefined) {
|
|
1311
|
+
charLimit = checkCharLimit(text, { min, max, mode: args?.count_mode });
|
|
1312
|
+
const mLabel = charLimit.mode === 'no_space' ? '공백제외' : charLimit.mode === 'byte' ? 'byte' : '공백포함';
|
|
1313
|
+
const unit = charLimit.mode === 'byte' ? 'byte' : '자';
|
|
1314
|
+
if (charLimit.status === 'over') charLine += `\n⚠️ 한도 초과(${mLabel} ${charLimit.count}${unit} / 상한 ${max}${unit}) — ${charLimit.delta}${unit} 줄이세요`;
|
|
1315
|
+
else if (charLimit.status === 'under') charLine += `\n⚠️ 하한 미달(${mLabel} ${charLimit.count}${unit} / 하한 ${min}${unit}) — ${charLimit.delta}${unit} 더 쓰세요`;
|
|
1316
|
+
else charLine += `\n✅ 글자수 한도 충족(${mLabel} ${charLimit.count}${unit})`;
|
|
1317
|
+
}
|
|
1318
|
+
return ok(`${verdict}${modeLine}\n${summarizeReport(report)}${charLine}\n\n${report.disclaimer}`, {
|
|
1319
|
+
...report,
|
|
1320
|
+
charLimit,
|
|
1321
|
+
});
|
|
1085
1322
|
},
|
|
1086
1323
|
},
|
|
1087
1324
|
{
|
|
1088
1325
|
name: 'set_verify_mode',
|
|
1326
|
+
idempotent: true, // 같은 값 재설정은 무해
|
|
1089
1327
|
title: '점검 모드 설정 (기본/엄격)',
|
|
1090
1328
|
description:
|
|
1091
1329
|
'저장 전 자동 점검의 엄격도를 바꿉니다. 엄격 모드(strict=true)를 켜면, 이후 모든 자소서 저장에서 "이력서 본문에 근거 없는 수치"(구조화 경력 항목에만 있는 값)까지 차단합니다. 기본 모드는 출처가 아예 없는 수치만 차단합니다. 사용자가 "엄격하게 봐줘"라고 하면 바로 켜지 말고 "앞으로 항상 엄격하게 볼까요?"라고 먼저 확인한 뒤 동의하면 strict=true로 호출하세요. 끄기(strict=false)는 안전장치를 약하게 만드는 것이므로, 사용자가 명확히 끄겠다고 할 때만 호출하세요. 이번 한 건만 엄격하게 보려면 모드를 바꾸지 말고 validate_cover_letter / save_cover_letter_version에 strict:true를 한 번만 넘기세요.',
|
|
@@ -1134,6 +1372,8 @@ export const TOOLS: ToolDef[] = [
|
|
|
1134
1372
|
/* ----------------------------------------------------------------- update */
|
|
1135
1373
|
{
|
|
1136
1374
|
name: 'check_for_update',
|
|
1375
|
+
// npm 레지스트리로 버전 조회가 나간다(사용자 데이터 미전송).
|
|
1376
|
+
openWorld: true,
|
|
1137
1377
|
title: '업데이트 확인',
|
|
1138
1378
|
description:
|
|
1139
1379
|
'CareerMate에 새 버전이 있는지 npm 레지스트리에서 지금 확인합니다(공개 버전 정보만 GET하며 사용자 데이터는 전송하지 않습니다). 현재 버전·최신 버전·업데이트 가능 여부를 돌려줍니다. 사용자가 "최신이야?/업데이트 있어?"라고 묻거나, get_onboarding_status가 새 버전을 알릴 때 호출하세요. 확인만 하고 설치는 하지 않습니다. 사용자가 "업데이트해줘/최신으로 올려줘"처럼 명시적으로 요청하면 update_careermate를 호출하세요.',
|
|
@@ -1162,6 +1402,9 @@ export const TOOLS: ToolDef[] = [
|
|
|
1162
1402
|
},
|
|
1163
1403
|
{
|
|
1164
1404
|
name: 'update_careermate',
|
|
1405
|
+
// npm에서 패키지를 내려받아 설치 폴더의 코드를 교체한다.
|
|
1406
|
+
openWorld: true,
|
|
1407
|
+
destructive: true,
|
|
1165
1408
|
title: 'CareerMate 업데이트',
|
|
1166
1409
|
description:
|
|
1167
1410
|
'CareerMate를 최신 버전으로 업데이트합니다(설치 폴더에 npm으로 careermate@latest 설치). 사용자가 명시적으로 "업데이트해줘", "CareerMate 업데이트해줘", "최신으로 올려줘"라고 할 때 호출하세요. 이 도구가 최신 버전 확인도 함께 하므로 별도 확인 질문 없이 실행해도 됩니다. 성공하면 적용을 위해 AI 앱(또는 MCP 연결) 재시작이 필요하며, 재시작 시 데이터베이스는 자동 마이그레이션되어 기존 데이터는 보존됩니다. 자동 설치가 막히면(개발 모드·비표준 설치) 직접 칠 명령을 안내합니다.',
|