careermate 0.0.7 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/AGENTS.md +2 -2
  2. package/LICENSE +1 -1
  3. package/README.md +108 -91
  4. package/THIRD_PARTY_NOTICES.md +2 -1
  5. package/bin/careermate.mjs +11 -2
  6. package/dist/career-os/eop/cover-letter.md +13 -6
  7. package/dist/career-os/eop/interview-prep.md +1 -0
  8. package/dist/career-os/knowledge/cover-letter.md +7 -3
  9. package/dist/career-os/knowledge/fit-matching.md +2 -0
  10. package/dist/career-os/knowledge/verifiers/ai-detection-risk.md +57 -0
  11. package/dist/career-os/knowledge/verifiers/human-voice.md +7 -2
  12. package/dist/career-os/knowledge/verifiers/truthfulness.md +1 -0
  13. package/dist/doctor.mjs +19760 -1018
  14. package/dist/mcp.mjs +19783 -1035
  15. package/dist/migrate.mjs +289 -20
  16. package/dist/public/i18n/en/applications.js +19 -0
  17. package/dist/public/i18n/en/common.js +10 -0
  18. package/dist/public/i18n/en/documents.js +0 -10
  19. package/dist/public/i18n/en/home.js +5 -0
  20. package/dist/public/i18n/en/jobs.js +18 -1
  21. package/dist/public/i18n/en/profile.js +0 -5
  22. package/dist/public/i18n/en/settings.js +5 -2
  23. package/dist/public/i18n/ko/applications.js +19 -0
  24. package/dist/public/i18n/ko/common.js +9 -0
  25. package/dist/public/i18n/ko/documents.js +0 -10
  26. package/dist/public/i18n/ko/home.js +5 -0
  27. package/dist/public/i18n/ko/jobs.js +18 -1
  28. package/dist/public/i18n/ko/profile.js +0 -5
  29. package/dist/public/i18n/ko/settings.js +5 -2
  30. package/dist/public/lib.js +101 -0
  31. package/dist/public/pages/applications.js +95 -15
  32. package/dist/public/pages/documents.js +20 -14
  33. package/dist/public/pages/home.js +38 -6
  34. package/dist/public/pages/interview.js +11 -4
  35. package/dist/public/pages/jobs.js +71 -1
  36. package/dist/public/pages/profile.js +2 -3
  37. package/dist/public/pages/settings.js +2 -2
  38. package/dist/public/styles.css +52 -4
  39. package/dist/web.mjs +19001 -758
  40. package/docs/FAQ.md +13 -3
  41. package/docs/MCP_TOOLS.md +32 -12
  42. package/docs/career-os/eop/cover-letter.md +13 -6
  43. package/docs/career-os/eop/interview-prep.md +1 -0
  44. package/docs/career-os/knowledge/cover-letter.md +7 -3
  45. package/docs/career-os/knowledge/fit-matching.md +2 -0
  46. package/docs/career-os/knowledge/verifiers/ai-detection-risk.md +57 -0
  47. package/docs/career-os/knowledge/verifiers/human-voice.md +7 -2
  48. package/docs/career-os/knowledge/verifiers/truthfulness.md +1 -0
  49. package/package.json +5 -2
  50. package/packages/core/src/index.ts +2 -0
  51. package/packages/core/src/jobsearch.ts +190 -0
  52. package/packages/core/src/offers.ts +41 -0
  53. package/packages/core/src/services.ts +29 -3
  54. package/packages/core/src/summary.ts +431 -9
  55. package/packages/core/src/verify/charcount.ts +87 -0
  56. package/packages/core/src/verify/index.ts +1 -0
  57. package/packages/core/src/verify/lint.ts +5 -0
  58. package/packages/db/src/connection.ts +70 -2
  59. package/packages/db/src/index.ts +1 -0
  60. package/packages/db/src/repositories.ts +179 -13
  61. package/packages/db/src/schema.ts +169 -8
  62. package/packages/exporters/src/docx.ts +165 -0
  63. package/packages/exporters/src/index.ts +142 -9
  64. package/packages/exporters/src/markdown.ts +16 -11
  65. package/packages/knowledge/src/index.ts +8 -3
  66. package/packages/mcp-tools/src/result.ts +9 -0
  67. package/packages/mcp-tools/src/tools.ts +303 -60
  68. package/packages/parsers/src/documents.ts +7 -1
  69. package/packages/prompts/src/humanize.ts +4 -0
  70. package/packages/shared/src/schemas.ts +198 -0
  71. package/scripts/init.ts +7 -0
@@ -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
- CONTENT_SOURCES,
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 && INTERVIEW_UNLOCKED.includes(app.status)) return 'prepare_interview';
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: `지원 상태를 변경합니다. 가능한 상태: ${STATUS_LIST}. '서류 합격(document_passed)' 이상으로 바뀌면 면접 준비를 제안하는 힌트를 함께 돌려줍니다. note에 변경 사유를 남길 수 있습니다.`,
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
- const user_message =
796
- `지원 상태를 '${APPLICATION_STATUS_LABELS[args.status as keyof typeof APPLICATION_STATUS_LABELS]}'(으)로 바꿨어요.` +
797
- (res.hint ? `\n\n${res.hint}` : '');
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
- user_message,
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
- : null,
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
- 'job_id는 필수입니다. 같은 공고에 다시 저장하면 갱신됩니다.',
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 또는 인쇄용 HTML(브라우저에서 PDF로 저장) 파일로 내보내 데이터 폴더의 exports에 저장하고, 파일 경로와 본문을 돌려줍니다. 사용자가 "자소서 파일로 받고 싶어"라고 할 때 사용하세요. 대시보드에서 직접 다운로드할 수도 있습니다.',
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 = format === 'html' ? coverLetterToHtml(cl, { job, profile }) : coverLetterToMarkdown(cl, { job, profile });
859
- const dir = getExportsDir();
860
- const filePath = path.join(dir, result.filename);
861
- try {
862
- fs.writeFileSync(filePath, result.content, 'utf8');
863
- } catch (e) {
864
- return fail(`파일 저장 실패: ${e instanceof Error ? e.message : e}`);
865
- }
866
- return ok(`'${cl.title}'를 ${format.toUpperCase()}로 내보냈습니다.\n저장 위치: ${filePath}`, {
867
- path: filePath,
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 또는 인쇄용 HTML(브라우저에서 PDF로 저장) 파일로 내보내 데이터 폴더의 exports에 저장하고, 파일 경로와 본문을 돌려줍니다. 사용자가 공고에 맞춰 정리한 이력서를 먼저 add_resume로 저장한 뒤, "이력서 파일로 받고 싶어"라고 할 때 사용하세요. document_id는 get_resumes로 확인할 수 있습니다. 대시보드에서 직접 다운로드할 수도 있습니다.',
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 = format === 'html' ? resumeToHtml(doc, profile) : resumeToMarkdown(doc, profile);
888
- const dir = getExportsDir();
889
- const filePath = path.join(dir, result.filename);
890
- try {
891
- fs.writeFileSync(filePath, result.content, 'utf8');
892
- } catch (e) {
893
- return fail(`파일 저장 실패: ${e instanceof Error ? e.message : e}`);
894
- }
895
- return ok(`'${doc.title}'를 ${format.toUpperCase()}로 내보냈습니다.\n저장 위치: ${filePath}`, {
896
- path: filePath,
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 또는 인쇄용 HTML(브라우저에서 PDF로 저장) 파일로 내보내 데이터 폴더의 exports에 저장하고, 파일 경로와 본문을 돌려줍니다. 사용자가 "내 프로필을 이력서로 뽑아줘"처럼 구조화된 데이터 전체를 이력서로 받고 싶을 때 사용하세요. 특정 공고 맞춤 이력서는 add_resume로 정리·저장한 뒤 export_resume를 쓰세요. 대시보드에서 직접 다운로드할 수도 있습니다.',
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 === 'html'
919
- ? profileToHtml(profile, experiences, projects, skills)
920
- : profileToMarkdown(profile, experiences, projects, skills);
921
- const dir = getExportsDir();
922
- const filePath = path.join(dir, result.filename);
923
- try {
924
- fs.writeFileSync(filePath, result.content, 'utf8');
925
- } catch (e) {
926
- return fail(`파일 저장 실패: ${e instanceof Error ? e.message : e}`);
927
- }
928
- return ok(`프로필을 이력서(${format.toUpperCase()})로 내보냈습니다.\n저장 위치: ${filePath}`, {
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이 없어 의미 판단을 대신하지 않습니다). 6개 검증기: truthfulness(사실/허위수치), consistency(산출물 간 모순), recency-staleness(신선도), responsiveness-on-target(질문에 답했는가), ats-compat(ATS 호환), human-voice(AI 티/사람 목소리).',
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
- return ok(`${verdict}${modeLine}\n${summarizeReport(report)}\n\n${report.disclaimer}`, report);
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 연결) 재시작이 필요하며, 재시작 시 데이터베이스는 자동 마이그레이션되어 기존 데이터는 보존됩니다. 자동 설치가 막히면(개발 모드·비표준 설치) 직접 칠 명령을 안내합니다.',