careermate 0.0.6 → 0.0.8

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 (69) hide show
  1. package/AGENTS.md +5 -3
  2. package/README.md +30 -10
  3. package/THIRD_PARTY_NOTICES.md +2 -1
  4. package/dist/career-os/README.md +4 -3
  5. package/dist/career-os/eop/career-description.md +3 -0
  6. package/dist/career-os/eop/cover-letter.md +8 -4
  7. package/dist/career-os/eop/fit-analysis.md +1 -1
  8. package/dist/career-os/eop/job-analysis.md +1 -1
  9. package/dist/career-os/knowledge/cover-letter.md +4 -3
  10. package/dist/career-os/knowledge/fit-matching.md +5 -3
  11. package/dist/career-os/knowledge/human-writing.md +2 -0
  12. package/dist/career-os/knowledge/verifiers/ai-detection-risk.md +57 -0
  13. package/dist/career-os/knowledge/verifiers/ats-compat.md +1 -1
  14. package/dist/career-os/knowledge/verifiers/human-voice.md +2 -0
  15. package/dist/career-os/knowledge/verifiers/responsiveness-on-target.md +1 -1
  16. package/dist/career-os/knowledge/verifiers/truthfulness.md +1 -0
  17. package/dist/doctor.mjs +19012 -809
  18. package/dist/mcp.mjs +19027 -824
  19. package/dist/migrate.mjs +83 -6
  20. package/dist/public/i18n/en/common.js +4 -0
  21. package/dist/public/i18n/en/documents.js +0 -10
  22. package/dist/public/i18n/en/home.js +5 -0
  23. package/dist/public/i18n/en/jobs.js +1 -1
  24. package/dist/public/i18n/en/profile.js +0 -5
  25. package/dist/public/i18n/ko/common.js +4 -0
  26. package/dist/public/i18n/ko/documents.js +0 -10
  27. package/dist/public/i18n/ko/home.js +5 -0
  28. package/dist/public/i18n/ko/jobs.js +1 -1
  29. package/dist/public/i18n/ko/profile.js +0 -5
  30. package/dist/public/lib.js +72 -0
  31. package/dist/public/pages/applications.js +10 -6
  32. package/dist/public/pages/documents.js +4 -7
  33. package/dist/public/pages/home.js +26 -6
  34. package/dist/public/pages/profile.js +2 -3
  35. package/dist/public/styles.css +39 -4
  36. package/dist/web.mjs +18548 -649
  37. package/docs/MCP_TOOLS.md +19 -10
  38. package/docs/START_WORKFLOW.md +1 -1
  39. package/docs/career-os/README.md +4 -3
  40. package/docs/career-os/eop/career-description.md +3 -0
  41. package/docs/career-os/eop/cover-letter.md +8 -4
  42. package/docs/career-os/eop/fit-analysis.md +1 -1
  43. package/docs/career-os/eop/job-analysis.md +1 -1
  44. package/docs/career-os/knowledge/cover-letter.md +4 -3
  45. package/docs/career-os/knowledge/fit-matching.md +5 -3
  46. package/docs/career-os/knowledge/human-writing.md +2 -0
  47. package/docs/career-os/knowledge/verifiers/ai-detection-risk.md +57 -0
  48. package/docs/career-os/knowledge/verifiers/ats-compat.md +1 -1
  49. package/docs/career-os/knowledge/verifiers/human-voice.md +2 -0
  50. package/docs/career-os/knowledge/verifiers/responsiveness-on-target.md +1 -1
  51. package/docs/career-os/knowledge/verifiers/truthfulness.md +1 -0
  52. package/package.json +3 -2
  53. package/packages/core/src/index.ts +1 -0
  54. package/packages/core/src/jobsearch.ts +186 -0
  55. package/packages/core/src/services.ts +24 -2
  56. package/packages/core/src/summary.ts +196 -0
  57. package/packages/core/src/verify/charcount.ts +87 -0
  58. package/packages/core/src/verify/index.ts +1 -0
  59. package/packages/core/src/verify/lint.ts +5 -0
  60. package/packages/db/src/repositories.ts +42 -8
  61. package/packages/db/src/schema.ts +29 -0
  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 +17 -5
  66. package/packages/mcp-tools/src/tools.ts +181 -59
  67. package/packages/prompts/src/system.ts +1 -0
  68. package/packages/shared/src/schemas.ts +64 -0
  69. package/scripts/init.ts +5 -0
@@ -47,7 +47,9 @@ export const EXPERT_DOMAINS = [
47
47
  ] as const;
48
48
  export type ExpertDomain = (typeof EXPERT_DOMAINS)[number];
49
49
 
50
- /** 6 verifier rubrics — 1:1 with docs/career-os/knowledge/verifiers/<id>.md (CONTRACT C3). */
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
 
@@ -115,22 +118,29 @@ export const CAREER_ROUTES: Record<string, CareerRoute> = {
115
118
  verifierSequence: ['truthfulness', 'consistency', 'responsiveness-on-target'],
116
119
  loop: 'draft_verify_revise',
117
120
  },
121
+ // R22(audit): responsiveness-on-target는 한국 문항형 자소서 전용 검증기(문항별 1:1 응답·
122
+ // 글자수 상·하한 = "핵심 탈락 사유")인데 analyze_job에만 라우팅돼 있어 정작 자소서 자가검증
123
+ // 루프에서 빠져 있었다. 동문서답·미응답 문항·글자수 초과를 잡도록 write_cover_letter에 추가.
118
124
  write_cover_letter: {
119
125
  eop: 'cover-letter',
120
126
  expertSequence: ['cover-letter', 'human-writing', 'company-research'],
121
- verifierSequence: ['human-voice', 'truthfulness', 'ats-compat', 'consistency'],
127
+ // ai-detection-risk: 한국 자소서는 AI 탐지(무하유 GPT킬러 등)가 제도화돼 있어, 저장 전
128
+ // "진정성·면접 방어가능성·자기복제 여부"를 점검한다(탐지 우회가 아님 — 짝은 human-voice).
129
+ verifierSequence: ['human-voice', 'truthfulness', 'responsiveness-on-target', 'ats-compat', 'consistency', 'ai-detection-risk'],
122
130
  loop: 'draft_verify_revise',
123
131
  },
124
132
  write_career_description: {
125
133
  eop: 'career-description',
126
134
  expertSequence: ['resume', 'ats'],
127
- verifierSequence: ['truthfulness', 'consistency', 'ats-compat'],
135
+ verifierSequence: ['truthfulness', 'consistency', 'ats-compat', 'ai-detection-risk'],
128
136
  loop: 'draft_verify_revise',
129
137
  },
130
138
  prepare_interview: {
131
139
  eop: 'interview-prep',
132
140
  expertSequence: ['recruiter-screen', 'interview-behavioral', 'interview-technical', 'company-research'],
133
- verifierSequence: ['truthfulness', 'consistency'],
141
+ // R22(audit): recency-staleness 추가 — 면접의 "최근 소식"·회사 근황이 낡으면 거짓 신선도가
142
+ // 되므로(company-research) 저장 전 신선도 점검을 푸시한다.
143
+ verifierSequence: ['truthfulness', 'consistency', 'recency-staleness'],
134
144
  loop: 'draft_verify_revise',
135
145
  },
136
146
  // R7 A2: manage_application_status had no route → rejection-triage / offer-evaluation /
@@ -152,7 +162,9 @@ export const CAREER_ROUTES: Record<string, CareerRoute> = {
152
162
  'onboarding-first-90-days',
153
163
  'networking-referrals',
154
164
  ],
155
- verifierSequence: ['truthfulness', 'consistency'],
165
+ // R22(audit): recency-staleness 추가 — 연봉 벤치마크·오퍼 비교는 시점이 지나면 낡은 기준이
166
+ // 되므로(salary-negotiation/offer-evaluation) 저장 전 신선도 점검을 푸시한다.
167
+ verifierSequence: ['truthfulness', 'consistency', 'recency-staleness'],
156
168
  },
157
169
  // R7 후속: linkedin-profile·portfolio는 onboarding(이력서 임포트)과 단계 의미가
158
170
  // 달라(개인 브랜드 자산 *생성*) 전용 워크플로우로 분리(consensus 권고). networking은
@@ -27,7 +27,7 @@ import {
27
27
  APPLICATION_STATUSES,
28
28
  APPLICATION_STATUS_LABELS,
29
29
  DOCUMENT_KINDS,
30
- CONTENT_SOURCES,
30
+ INTERVIEW_UNLOCK_STATUSES,
31
31
  } from '@careermate/shared';
32
32
  import {
33
33
  profileRepo,
@@ -60,9 +60,15 @@ import {
60
60
  updateApplicationStatus,
61
61
  saveInterviewPrep,
62
62
  listRecentActivity,
63
+ getActionDigest,
64
+ getPipelineStats,
65
+ prescreenJob,
66
+ searchJobs,
67
+ JOB_SEARCH_SOURCES,
63
68
  jobWithMeta,
64
69
  previewCoverLetter,
65
70
  summarizeReport,
71
+ checkCharLimit,
66
72
  } from '@careermate/core';
67
73
  import { getWritingStyleGuide } from '@careermate/prompts';
68
74
  import { getWorkflow, renderWorkflowMarkdown, WORKFLOWS } from '@careermate/workflows';
@@ -79,10 +85,15 @@ import {
79
85
  import {
80
86
  coverLetterToMarkdown,
81
87
  coverLetterToHtml,
88
+ coverLetterToDocx,
82
89
  resumeToMarkdown,
83
90
  resumeToHtml,
91
+ resumeToDocx,
84
92
  profileToMarkdown,
85
93
  profileToHtml,
94
+ profileToDocx,
95
+ profileToJsonResume,
96
+ type ExportResult,
86
97
  } from '@careermate/exporters';
87
98
  import { extractDocument } from '@careermate/parsers';
88
99
  import { ok, fail, type ToolDef } from './result.ts';
@@ -146,9 +157,6 @@ async function mapWithLimit<T, R>(items: T[], limit: number, fn: (item: T, index
146
157
  return out;
147
158
  }
148
159
 
149
- /** Statuses at/after which interview prep is recommended as a next step. */
150
- const INTERVIEW_UNLOCKED = ['document_passed', 'interview', 'final_passed'];
151
-
152
160
  /**
153
161
  * Pick the recommended Career-OS route from the loaded context, so the AI is
154
162
  * nudged onto the expert procedure for what it's most likely about to do.
@@ -162,11 +170,27 @@ function pickRoute(ctx: ReturnType<typeof getApplicationContext>): string | null
162
170
  const app = ctx.recent_applications.find(
163
171
  (a) => (a as { job_id?: string }).job_id === jobId,
164
172
  ) as { status?: string } | undefined;
165
- if (app?.status && INTERVIEW_UNLOCKED.includes(app.status)) return 'prepare_interview';
173
+ if (app?.status && (INTERVIEW_UNLOCK_STATUSES as readonly string[]).includes(app.status)) return 'prepare_interview';
166
174
  if (!ctx.fit_analysis) return 'analyze_job';
167
175
  return 'write_cover_letter';
168
176
  }
169
177
 
178
+ /**
179
+ * Write an export to the exports dir. Binary formats (`.docx`) carry their
180
+ * payload in `bytes`; text formats in `content`. Returns the absolute path or an
181
+ * error string so the caller can `fail()` with a human message.
182
+ */
183
+ function writeExport(result: ExportResult): { path: string } | { error: string } {
184
+ const filePath = path.join(getExportsDir(), result.filename);
185
+ try {
186
+ if (result.bytes) fs.writeFileSync(filePath, result.bytes);
187
+ else fs.writeFileSync(filePath, result.content, 'utf8');
188
+ return { path: filePath };
189
+ } catch (e) {
190
+ return { error: `파일 저장 실패: ${e instanceof Error ? e.message : e}` };
191
+ }
192
+ }
193
+
170
194
  export const TOOLS: ToolDef[] = [
171
195
  /* --------------------------------------------------------- read document */
172
196
  {
@@ -223,7 +247,7 @@ export const TOOLS: ToolDef[] = [
223
247
  const files = listInboxFiles(dir);
224
248
  const list = files.length ? files.map((f) => `- ${f.name}`).join('\n') : '(아직 비어 있음)';
225
249
  return ok(
226
- `문서 인입 폴더를 열었습니다:\n${dir}\n\n여기에 이력서·경력기술서·포트폴리오·기존 자기소개서 파일을 넣고 "다 넣었어"라고 알려주세요. (hwp/hwpx/docx는 자동으로 텍스트를 추출하고, pdf·이미지는 제가 직접 읽습니다.)\n\n현재 파일:\n${list}`,
250
+ `문서 인입 폴더를 열었습니다:\n${dir}\n\n여기에 이력서·경력기술서·포트폴리오·기존 자기소개서 파일을 넣고 "다 넣었어"라고 알려주세요. (hwp·hwpx·docx·pdf는 자동으로 텍스트를 추출하고, 텍스트 레이어가 없는 이미지·스캔 PDF만 제가 직접 읽습니다.)\n\n현재 파일:\n${list}`,
227
251
  { dir, files },
228
252
  );
229
253
  },
@@ -232,7 +256,7 @@ export const TOOLS: ToolDef[] = [
232
256
  name: 'read_inbox',
233
257
  title: '인입 폴더 파일 읽기',
234
258
  description:
235
- '인입 폴더(~/.careermate/inbox)에 사용자가 넣어 둔 문서들의 본문을 한 번에 읽어 돌려줍니다. open_inbox로 폴더를 안내한 뒤 사용자가 "다 넣었어"라고 하면 호출하세요. docx/hwp/hwpx/텍스트는 텍스트를 추출하고, pdf·이미지는 추출하지 않고 파일 경로를 돌려주니 AI 클라이언트의 파일 읽기 기능으로 그 경로를 직접 열어 읽으세요. 읽은 내용을 구조화해 프로필은 save_profile, 이력서·경력기술서·포트폴리오는 add_resume, 기존 자기소개서는 save_cover_letter_version으로 저장하세요(파일 내용을 새 파일로 다시 쓰지 마세요). filename을 주면 해당 파일만 읽습니다.',
259
+ '인입 폴더(~/.careermate/inbox)에 사용자가 넣어 둔 문서들의 본문을 한 번에 읽어 돌려줍니다. open_inbox로 폴더를 안내한 뒤 사용자가 "다 넣었어"라고 하면 호출하세요. docx·hwp·hwpx·pdf·텍스트(.txt/.md)에서 본문 텍스트를 추출해 돌려줍니다(read_document와 동일한 추출 스택). 단 텍스트 레이어가 없는 이미지·스캔 PDF는 추출되지 않아(unsupported=true) 파일 경로만 돌려주니, 그 경로는 AI 클라이언트의 파일 읽기 기능으로 직접 열어 읽으세요. 읽은 내용을 구조화해 프로필은 save_profile, 이력서·경력기술서·포트폴리오는 add_resume, 기존 자기소개서는 save_cover_letter_version으로 저장하세요(파일 내용을 새 파일로 다시 쓰지 마세요). filename을 주면 해당 파일만 읽습니다.',
236
260
  inputSchema: {
237
261
  filename: z.string().optional().describe('특정 파일만 읽을 때 파일명(대소문자 무시). 생략하면 폴더 안 모든 파일을 읽습니다.'),
238
262
  max_chars: z.number().optional().describe('파일별 반환 텍스트 최대 길이(초과 시 잘라냄). 기본 20000자'),
@@ -335,7 +359,7 @@ export const TOOLS: ToolDef[] = [
335
359
  name: 'save_profile',
336
360
  title: '프로필 저장',
337
361
  description:
338
- '사용자 프로필을 저장합니다. 전달한 필드만 갱신되고 나머지는 유지됩니다(부분 저장 안전). 이력서를 구조화한 뒤 이름/한 줄 소개/희망 직무/선호 문체/강조 포인트 등을 채워 저장하세요. 선호 문체(preferred_tone)와 강조 포인트(emphasis_points)는 이후 자기소개서 작성 품질에 직접 영향을 줍니다.',
362
+ '사용자 프로필을 저장합니다. 전달한 필드만 갱신되고 나머지는 유지됩니다(부분 저장 안전). 이력서를 구조화한 뒤 이름/한 줄 소개/희망 직무/선호 문체/강조 포인트 등을 채워 저장하세요. 선호 문체(preferred_tone)와 강조 포인트(emphasis_points)는 이후 자기소개서 작성 품질에 직접 영향을 줍니다. 학력(education)·자격증(certifications)·어학점수(language_scores: 토익·오픽 등)·수상(awards)이 이력서에 있으면 구조화해 저장하세요 — 한국 이력서 핵심 스펙이고, 저장하면 이후 자소서·이력서에서 정확한 수치(학점 3.8, 토익 920)를 지어내지 않고 그대로 인용합니다.',
339
363
  inputSchema: ProfileInputSchema.shape,
340
364
  handler: (args) => {
341
365
  const p = saveProfile(args);
@@ -348,7 +372,7 @@ export const TOOLS: ToolDef[] = [
348
372
  name: 'add_resume',
349
373
  title: '이력서/경력기술서 추가',
350
374
  description:
351
- '이력서, 경력기술서, 포트폴리오 등 문서를 저장합니다. content에는 사용자가 업로드/붙여넣은 텍스트 또는 AI가 전문가 절차로 정리한 Markdown을 넣으세요. 경력기술서를 작성해 저장할 때는 kind=career_description, source=ai를 사용합니다. kind 기본값은 resume입니다. is_primary=true로 대표 문서를 지정하면 get_application_context의 primary_resume로 노출됩니다.',
375
+ '이력서, 경력기술서, 포트폴리오 등 문서를 저장합니다. content에는 사용자가 업로드/붙여넣은 텍스트 또는 AI가 전문가 절차로 정리한 Markdown을 넣으세요. 경력기술서를 작성해 저장할 때는 kind=career_description, source=ai를 사용합니다. ⚠️ content에는 이 문서 한 종류의 본문만 넣고, 자기소개서 등 다른 종류 문서를 같은 content에 이어붙이지 마세요 — 자기소개서는 save_cover_letter_version으로 따로 저장합니다(한 번에 둘 다 요청받았어도 한 저장 호출에 두 문서를 합치지 않습니다). kind 기본값은 resume입니다. is_primary=true로 대표 문서를 지정하면 get_application_context의 primary_resume로 노출됩니다.',
352
376
  // Reuse the shared (length-bounded, whitespace-rejecting) Document schema so
353
377
  // the MCP ingestion path — the primary route for résumé text — inherits the
354
378
  // same caps as the web API. kind stays optional here (defaults to resume).
@@ -536,7 +560,7 @@ export const TOOLS: ToolDef[] = [
536
560
  name: 'save_cover_letter_version',
537
561
  title: '자기소개서 버전 저장',
538
562
  description:
539
- '자기소개서의 새 버전을 저장합니다. CareerMate 자소서 작성 워크플로우의 핵심 저장 단계입니다. cover_letter_id를 주면 기존 자소서에 새 버전을 추가하고, 없으면 새 자소서를 만들어 v1로 저장합니다(이때 title 권장). note에 이 버전의 변경 요약(예: "지원동기 보강")을 남기면 사용자가 대시보드에서 버전 히스토리를 이해하기 쉽습니다. job_id로 공고에 연결하면 지원 항목과 자동 연결됩니다. 저장 직전 자동으로 "근거 없는 수치" 점검을 돌립니다 — 본문의 정량 수치가 저장된 경력·이력서·프로젝트에 근거가 없으면(환각 의심) 저장이 막힙니다. 그때는 원본의 실제 수치로 고치거나, 의도한 값이면 force:true로 다시 저장하세요.',
563
+ '자기소개서의 새 버전을 저장합니다. CareerMate 자소서 작성 워크플로우의 핵심 저장 단계입니다. ⚠️ content에는 자기소개서 본문만 넣고, 경력기술서 등 다른 종류 문서를 이어붙이지 마세요(경력기술서는 add_resume(kind=career_description)로 따로 저장). cover_letter_id를 주면 기존 자소서에 새 버전을 추가하고, 없으면 새 자소서를 만들어 v1로 저장합니다(이때 title 권장). note에 이 버전의 변경 요약(예: "지원동기 보강")을 남기면 사용자가 대시보드에서 버전 히스토리를 이해하기 쉽습니다. job_id로 공고에 연결하면 지원 항목과 자동 연결됩니다. 저장 직전 자동으로 "근거 없는 수치" 점검을 돌립니다 — 본문의 정량 수치가 저장된 경력·이력서·프로젝트에 근거가 없으면(환각 의심) 저장이 막힙니다. 그때는 원본의 실제 수치로 고치거나, 의도한 값이면 force:true로 다시 저장하세요.',
540
564
  inputSchema: CoverLetterVersionInputSchema.shape,
541
565
  handler: (args) => {
542
566
  try {
@@ -590,7 +614,7 @@ export const TOOLS: ToolDef[] = [
590
614
  name: 'save_job_posting',
591
615
  title: '채용공고 저장',
592
616
  description:
593
- '채용공고를 저장합니다(있으면 갱신). company와 position은 필수입니다. 공고 원문은 description에, 핵심 자격요건/우대사항은 requirements 배열에, 핵심 키워드는 keywords에 정리해 넣으면 이후 적합도 분석/자소서 작성에 활용됩니다. 같은 url이면 중복 생성 없이 갱신됩니다. 저장 시 지원(application) 항목이 자동으로 생성됩니다.',
617
+ '채용공고를 저장합니다(있으면 갱신). company와 position은 필수입니다. 공고 원문은 description에, 핵심 자격요건/우대사항은 requirements 배열에, 핵심 키워드는 keywords에 정리해 넣으면 이후 적합도 분석/자소서 작성에 활용됩니다. 회사를 리서치했다면 사업개요·미션은 company_overview, 회사가 강조하는 인재상은 talent_profile, 핵심가치는 core_values에 넣으세요 — 한국 자소서·면접은 인재상/핵심가치 정렬이 핵심이라 저장해두면 이후 작성·면접 준비에서 그대로 끌어다 씁니다. 같은 url이면 중복 생성 없이 갱신됩니다. 저장 시 지원(application) 항목이 자동으로 생성됩니다.',
594
618
  inputSchema: JobInputSchema.shape,
595
619
  handler: (args) => {
596
620
  const { job } = saveJobPosting(args);
@@ -708,7 +732,8 @@ export const TOOLS: ToolDef[] = [
708
732
  as_of: (asOf ?? '').slice(0, 7), // 재직중=updated_at(정보 확인 시점) / 종료=end_date
709
733
  months,
710
734
  years: Math.round((months / 12) * 10) / 10,
711
- data_age_months: e.is_current ? monthsBetween(e.updated_at, today) : null,
735
+ // 음수 방지(미래 updated_at·시계 오차) — 0 미만은 0으로 클램프
736
+ data_age_months: e.is_current ? Math.max(0, monthsBetween(e.updated_at, today) ?? 0) : null,
712
737
  };
713
738
  })
714
739
  .filter((t): t is NonNullable<typeof t> => t != null);
@@ -743,7 +768,7 @@ export const TOOLS: ToolDef[] = [
743
768
  try {
744
769
  const fit = saveFitAnalysis(args);
745
770
  const suggested_next_action = {
746
- prompt: '이 공고에 맞춘 자기소개서를 써드릴까요?',
771
+ prompt: '이 공고에 맞춘 자기소개서를 써드릴까요? (회사 지정 문항·양식·글자수가 있으면 함께 알려주시면 그에 맞춰 작성합니다)',
747
772
  options: [
748
773
  {
749
774
  label: '네, 이 공고에 맞춘 자기소개서를 써줘',
@@ -763,6 +788,7 @@ export const TOOLS: ToolDef[] = [
763
788
  '사용자에게 다음 선택지를 보여주세요:',
764
789
  '1. 네, 이 공고에 맞춘 자기소개서를 써줘',
765
790
  '2. 아니요, 분석만 저장해줘',
791
+ '(1번이면, 회사 지정 문항·양식·글자수가 있으면 알려달라고 요청하세요 — 한국 자소서는 문항형이 많습니다. 없으면 일반 양식으로 진행합니다.)',
766
792
  ].join('\n'),
767
793
  {
768
794
  ...fit,
@@ -819,7 +845,8 @@ export const TOOLS: ToolDef[] = [
819
845
  description:
820
846
  '예상 면접 질문, 꼬리 질문, STAR 답변 가이드, 1분 자기소개 초안 등 해당 공고 기준 면접 준비 자료를 저장합니다. 공고/직무/자기소개서를 근거로 당신(AI)이 생성한 자료를 저장하는 데 사용합니다. ' +
821
847
  "질문은 두 갈래로 준비하세요 — 각 질문의 category를 'technical'(기술·직무 1차 면접) 또는 'behavioral'(인성·컬처핏·임원 2차 면접, 자기소개서·프로필 기반)로 지정합니다. 가능하면 두 갈래 모두 채워 한 번에 저장하세요(대시보드가 기술/인성·컬처핏 탭으로 나눠 보여줍니다). " +
822
- 'job_id는 필수입니다. 같은 공고에 다시 저장하면 갱신됩니다.',
848
+ '면접을 본 뒤에는 같은 도구에 debrief(받은 질문 asked_questions·잘된 점 went_well·개선점 to_improve·다음 라운드 포커스 next_round_focus·체감 분위기 sentiment)를 채워 저장하면 다음 라운드 준비와 일관성 점검에 그대로 이어집니다(면접 전 준비와 면접 후 디브리프가 한 공고에 함께 쌓입니다). ' +
849
+ 'job_id는 필수입니다. 같은 공고에 다시 저장하면 갱신됩니다(debrief는 기존 값과 병합되어 기존 필드를 지우지 않습니다).',
823
850
  inputSchema: InterviewPrepInputSchema.shape,
824
851
  handler: (args) => {
825
852
  try {
@@ -842,27 +869,27 @@ export const TOOLS: ToolDef[] = [
842
869
  name: 'export_cover_letter',
843
870
  title: '자기소개서 내보내기',
844
871
  description:
845
- '자기소개서를 Markdown 또는 인쇄용 HTML(브라우저에서 PDF로 저장) 파일로 내보내 데이터 폴더의 exports에 저장하고, 파일 경로와 본문을 돌려줍니다. 사용자가 "자소서 파일로 받고 싶어"라고 할 때 사용하세요. 대시보드에서 직접 다운로드할 수도 있습니다.',
872
+ '자기소개서를 Word(.docx)·Markdown·인쇄용 HTML(브라우저에서 PDF로 저장) 파일로 내보내 데이터 폴더의 exports에 저장하고, 파일 경로를 돌려줍니다. ATS 제출처럼 Word 파일이 필요하면 format=docx를 쓰세요. 사용자가 "자소서 파일로 받고 싶어"라고 할 때 사용하세요. 대시보드에서 직접 다운로드할 수도 있습니다.',
846
873
  inputSchema: {
847
874
  cover_letter_id: z.string(),
848
- format: z.enum(['md', 'html']).optional().describe('기본 md'),
875
+ format: z.enum(['md', 'html', 'docx']).optional().describe('기본 md'),
849
876
  },
850
- handler: (args) => {
877
+ handler: async (args) => {
851
878
  const cl = coverLetterRepo.get(args.cover_letter_id, true);
852
879
  if (!cl) return fail('자기소개서를 찾을 수 없습니다.');
853
880
  const job = cl.job_id ? jobRepo.get(cl.job_id) : null;
854
881
  const profile = profileRepo.get();
855
882
  const format = args.format ?? 'md';
856
- const result = format === 'html' ? coverLetterToHtml(cl, { job, profile }) : coverLetterToMarkdown(cl, { job, profile });
857
- const dir = getExportsDir();
858
- const filePath = path.join(dir, result.filename);
859
- try {
860
- fs.writeFileSync(filePath, result.content, 'utf8');
861
- } catch (e) {
862
- return fail(`파일 저장 실패: ${e instanceof Error ? e.message : e}`);
863
- }
864
- return ok(`'${cl.title}'를 ${format.toUpperCase()}로 내보냈습니다.\n저장 위치: ${filePath}`, {
865
- path: filePath,
883
+ const result =
884
+ format === 'docx'
885
+ ? await coverLetterToDocx(cl, { job, profile })
886
+ : format === 'html'
887
+ ? coverLetterToHtml(cl, { job, profile })
888
+ : coverLetterToMarkdown(cl, { job, profile });
889
+ const written = writeExport(result);
890
+ if ('error' in written) return fail(written.error);
891
+ return ok(`'${cl.title}'를 ${format.toUpperCase()}로 내보냈습니다.\n저장 위치: ${written.path}`, {
892
+ path: written.path,
866
893
  filename: result.filename,
867
894
  content: result.content,
868
895
  });
@@ -872,26 +899,26 @@ export const TOOLS: ToolDef[] = [
872
899
  name: 'export_resume',
873
900
  title: '이력서/경력기술서 내보내기',
874
901
  description:
875
- '저장된 이력서·경력기술서 등 문서를 Markdown 또는 인쇄용 HTML(브라우저에서 PDF로 저장) 파일로 내보내 데이터 폴더의 exports에 저장하고, 파일 경로와 본문을 돌려줍니다. 사용자가 공고에 맞춰 정리한 이력서를 먼저 add_resume로 저장한 뒤, "이력서 파일로 받고 싶어"라고 할 때 사용하세요. document_id는 get_resumes로 확인할 수 있습니다. 대시보드에서 직접 다운로드할 수도 있습니다.',
902
+ '저장된 이력서·경력기술서 등 문서를 Word(.docx)·Markdown·인쇄용 HTML(브라우저에서 PDF로 저장) 파일로 내보내 데이터 폴더의 exports에 저장하고, 파일 경로를 돌려줍니다. ATS 제출처럼 Word 파일이 필요하면 format=docx를 쓰세요. 사용자가 공고에 맞춰 정리한 이력서를 먼저 add_resume로 저장한 뒤, "이력서 파일로 받고 싶어"라고 할 때 사용하세요. document_id는 get_resumes로 확인할 수 있습니다. 대시보드에서 직접 다운로드할 수도 있습니다.',
876
903
  inputSchema: {
877
904
  document_id: z.string(),
878
- format: z.enum(['md', 'html']).optional().describe('기본 md'),
905
+ format: z.enum(['md', 'html', 'docx']).optional().describe('기본 md'),
879
906
  },
880
- handler: (args) => {
907
+ handler: async (args) => {
881
908
  const doc = documentRepo.get(args.document_id);
882
909
  if (!doc) return fail('문서를 찾을 수 없습니다.');
883
910
  const profile = profileRepo.get();
884
911
  const format = args.format ?? 'md';
885
- const result = format === 'html' ? resumeToHtml(doc, profile) : resumeToMarkdown(doc, profile);
886
- const dir = getExportsDir();
887
- const filePath = path.join(dir, result.filename);
888
- try {
889
- fs.writeFileSync(filePath, result.content, 'utf8');
890
- } catch (e) {
891
- return fail(`파일 저장 실패: ${e instanceof Error ? e.message : e}`);
892
- }
893
- return ok(`'${doc.title}'를 ${format.toUpperCase()}로 내보냈습니다.\n저장 위치: ${filePath}`, {
894
- path: filePath,
912
+ const result =
913
+ format === 'docx'
914
+ ? await resumeToDocx(doc, profile)
915
+ : format === 'html'
916
+ ? resumeToHtml(doc, profile)
917
+ : resumeToMarkdown(doc, profile);
918
+ const written = writeExport(result);
919
+ if ('error' in written) return fail(written.error);
920
+ return ok(`'${doc.title}'를 ${format.toUpperCase()}로 내보냈습니다.\n저장 위치: ${written.path}`, {
921
+ path: written.path,
895
922
  filename: result.filename,
896
923
  content: result.content,
897
924
  });
@@ -901,11 +928,11 @@ export const TOOLS: ToolDef[] = [
901
928
  name: 'export_profile',
902
929
  title: '프로필을 이력서로 내보내기',
903
930
  description:
904
- '저장된 프로필·경력·프로젝트·보유 기술을 한 장의 이력서 형식으로 모아 Markdown 또는 인쇄용 HTML(브라우저에서 PDF로 저장) 파일로 내보내 데이터 폴더의 exports에 저장하고, 파일 경로와 본문을 돌려줍니다. 사용자가 "내 프로필을 이력서로 뽑아줘"처럼 구조화된 데이터 전체를 이력서로 받고 싶을 때 사용하세요. 특정 공고 맞춤 이력서는 add_resume로 정리·저장한 뒤 export_resume를 쓰세요. 대시보드에서 직접 다운로드할 수도 있습니다.',
931
+ '저장된 프로필·경력·프로젝트·보유 기술을 한 장의 이력서 형식으로 모아 Word(.docx)·Markdown·인쇄용 HTML(브라우저에서 PDF로 저장)·JSON Resume(format=json, jsonresume.org 표준) 파일로 내보내 데이터 폴더의 exports에 저장하고, 파일 경로를 돌려줍니다. ATS 제출처럼 Word 파일이 필요하면 format=docx, 다른 이력서 도구·테마와 연동할 표준 데이터가 필요하면 format=json을 쓰세요. 사용자가 "내 프로필을 이력서로 뽑아줘"처럼 구조화된 데이터 전체를 이력서로 받고 싶을 때 사용하세요. 특정 공고 맞춤 이력서는 add_resume로 정리·저장한 뒤 export_resume를 쓰세요. 대시보드에서 직접 다운로드할 수도 있습니다.',
905
932
  inputSchema: {
906
- format: z.enum(['md', 'html']).optional().describe('기본 md'),
933
+ format: z.enum(['md', 'html', 'docx', 'json']).optional().describe('기본 md. json = JSON Resume 표준'),
907
934
  },
908
- handler: (args) => {
935
+ handler: async (args) => {
909
936
  const profile = profileRepo.get();
910
937
  if (!profile) return fail('프로필이 없습니다. 먼저 프로필을 저장하세요.');
911
938
  const format = args.format ?? 'md';
@@ -913,18 +940,17 @@ export const TOOLS: ToolDef[] = [
913
940
  const projects = projectRepo.list();
914
941
  const skills = skillRepo.list();
915
942
  const result =
916
- format === 'html'
917
- ? profileToHtml(profile, experiences, projects, skills)
918
- : profileToMarkdown(profile, experiences, projects, skills);
919
- const dir = getExportsDir();
920
- const filePath = path.join(dir, result.filename);
921
- try {
922
- fs.writeFileSync(filePath, result.content, 'utf8');
923
- } catch (e) {
924
- return fail(`파일 저장 실패: ${e instanceof Error ? e.message : e}`);
925
- }
926
- return ok(`프로필을 이력서(${format.toUpperCase()})로 내보냈습니다.\n저장 위치: ${filePath}`, {
927
- path: filePath,
943
+ format === 'docx'
944
+ ? await profileToDocx(profile, experiences, projects, skills)
945
+ : format === 'html'
946
+ ? profileToHtml(profile, experiences, projects, skills)
947
+ : format === 'json'
948
+ ? profileToJsonResume(profile, experiences, projects, skills)
949
+ : profileToMarkdown(profile, experiences, projects, skills);
950
+ const written = writeExport(result);
951
+ if ('error' in written) return fail(written.error);
952
+ return ok(`프로필을 이력서(${format.toUpperCase()})로 내보냈습니다.\n저장 위치: ${written.path}`, {
953
+ path: written.path,
928
954
  filename: result.filename,
929
955
  content: result.content,
930
956
  });
@@ -973,6 +999,78 @@ export const TOOLS: ToolDef[] = [
973
999
  return ok(`최근 활동 ${acts.length}건`, acts);
974
1000
  },
975
1001
  },
1002
+ {
1003
+ name: 'get_followups',
1004
+ title: '후속 조치 알림(놓친 지원·마감·면접준비)',
1005
+ description:
1006
+ '지금 챙겨야 할 것들을 한 번에 돌려줍니다: ① 응답 없이 오래된 지원(지원 후 무응답·서류합격 후 일정대기·면접 후 결과대기) ② 임박했거나 지난 지원 마감(지원 전 단계) ③ 합격했는데 아직 면접 준비가 없는 공고. 사용자가 "내 지원 현황 어때?", "뭐 챙겨야 해?", "후속 연락할 거 있어?"라고 묻거나, 세션을 이어가며 놓친 게 없는지 먼저 점검할 때 사용하세요. 읽기 전용입니다. 응답의 reason은 사용자 언어로 풀어 전하고, 후속 문의는 강요하지 말고 선택지로 제안하세요(채용 관행상 과한 독촉은 역효과).',
1007
+ inputSchema: {},
1008
+ readOnly: true,
1009
+ handler: () => {
1010
+ const d = getActionDigest();
1011
+ const n = d.followups.length + d.deadlines.length + d.interview_todo.length;
1012
+ const msg = n === 0
1013
+ ? '지금 당장 챙길 후속 항목이 없습니다(무응답 지원·임박 마감·면접 준비 대기 없음).'
1014
+ : `챙길 항목 ${n}건: 후속 ${d.followups.length} · 마감 ${d.deadlines.length} · 면접준비 ${d.interview_todo.length}`;
1015
+ return ok(msg, d);
1016
+ },
1017
+ },
1018
+ {
1019
+ name: 'get_pipeline_stats',
1020
+ title: '지원 파이프라인 분석(깔때기·합격률)',
1021
+ description:
1022
+ '전체 지원 현황을 분석해 돌려줍니다: 현재 상태 분포(by_status), 단계별 도달 깔때기(funnel: 지원→서류합격→면접→최종, 상태 변경 이력으로 복원한 실측치라 현재 불합격이어도 거쳐간 최고 단계까지 집계), 통과율(rates: 서류·면접·최종, 지원 대비 %), 결과 요약(outcomes). 사용자가 "내 합격률 어때?", "지원 통계 보여줘", "어느 단계에서 많이 떨어져?"라고 물을 때 사용하세요. 비율은 설명용이며 표본이 작으면 예측력이 낮다는 점을 함께 전하세요(note 참고). 읽기 전용입니다.',
1023
+ inputSchema: {},
1024
+ readOnly: true,
1025
+ handler: () => {
1026
+ const s = getPipelineStats();
1027
+ if (s.total === 0) return ok('아직 지원 내역이 없어 분석할 통계가 없습니다.', s);
1028
+ const r = s.rates;
1029
+ const fmt = (v: number | null) => (v == null ? '—' : `${v}%`);
1030
+ return ok(
1031
+ `지원 ${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)} (표본 작으면 추세 참고로만)`,
1032
+ s,
1033
+ );
1034
+ },
1035
+ },
1036
+ {
1037
+ name: 'search_jobs',
1038
+ title: '공고 검색·가져오기(키워드)',
1039
+ description:
1040
+ `키워드로 무인증 공개 채용 사이트(${JOB_SEARCH_SOURCES.join('·')})에서 채용 공고를 가져옵니다. 사용자가 "백엔드 신입 공고 찾아줘", "React 공고 가져와"처럼 공고를 찾아 달라고 할 때 사용하세요. 결과(회사·직무·링크·지역·경력·기술스택·마감)를 사용자에게 보기 좋게 보여 주고, **사용자가 관심 있다고 고른 것만** save_job_posting으로 저장하세요(전부 자동 저장하지 마세요). 저장한 공고는 이어서 get_application_context로 분석·자소서로 연결됩니다. ` +
1041
+ `참고: 이 도구는 외부 공개 페이지에서 공고를 가져오기만 하며(보내는 것은 검색 키워드뿐, 사용자 데이터는 절대 전송하지 않음), 봇 차단 우회는 하지 않습니다. 사람인·잡코리아는 키 발급 또는 약관 제약으로 여기 포함되지 않습니다. 결과가 비어 있으면 키워드를 바꾸거나 사용자에게 공고 URL/본문을 받아 save_job_posting으로 저장하세요.`,
1042
+ inputSchema: {
1043
+ keyword: z.string().describe('검색어(직무·기술·키워드). 예: "백엔드", "React 신입"'),
1044
+ sources: z.array(z.enum([...JOB_SEARCH_SOURCES] as [string, ...string[]])).optional().describe(`대상 사이트(생략 시 전체: ${JOB_SEARCH_SOURCES.join('·')})`),
1045
+ limit: z.number().optional().describe('사이트별 최대 건수(기본 10, 최대 50)'),
1046
+ },
1047
+ readOnly: true,
1048
+ handler: async (args) => {
1049
+ const res = await searchJobs(args.keyword, { sources: args.sources, limit: args.limit });
1050
+ const failed = res.providers.filter((p) => !p.ok);
1051
+ const okCount = res.results.length;
1052
+ const parts = [`'${res.query}' 검색 결과 ${okCount}건`];
1053
+ if (failed.length) parts.push(`(일부 사이트 응답 실패: ${failed.map((f) => f.source).join(', ')})`);
1054
+ if (okCount === 0) parts.push('— 키워드를 바꾸거나, 공고 URL/본문을 주시면 직접 저장해 드릴게요.');
1055
+ return ok(parts.join(' '), res);
1056
+ },
1057
+ },
1058
+ {
1059
+ name: 'prescreen_job',
1060
+ title: '공고 사전 프리스크린(키워드 겹침)',
1061
+ description:
1062
+ '저장된 공고의 핵심 키워드를 사용자의 보유 기술·프로젝트/경력 기술스택과 대조해 "이 공고를 깊게 볼 가치가 있는지"를 빠르게 가늠합니다. LLM 없이 셀 수 있는 키워드 겹침(matched/missing/coverage)만 돌려주는 값싼 사전 필터이며, **진짜 적합도 판단은 당신(AI)이 get_application_context로 맥락을 받아 직접 수행**합니다(이 도구는 그 대체가 아닙니다). 사용자가 공고를 여러 개 훑으며 우선순위를 정하거나 "이거 지원할 만해?"라고 물을 때, 본격 분석 전에 가볍게 호출하세요. coverage가 낮아도 키워드 미기재일 수 있으니 단정하지 말고, 표기되지 않은 역량은 사용자에게 확인하세요. 읽기 전용입니다.',
1063
+ inputSchema: { job_id: z.string() },
1064
+ readOnly: true,
1065
+ handler: (args) => {
1066
+ const r = prescreenJob(args.job_id);
1067
+ if (!r) return fail('공고를 찾을 수 없습니다. 먼저 save_job_posting으로 저장하세요.');
1068
+ if (r.coverage == null)
1069
+ return ok('이 공고에 저장된 핵심 키워드가 없어 겹침을 셀 수 없습니다. save_job_posting의 keywords에 핵심 키워드를 정리해 넣으면 프리스크린이 가능합니다.', r);
1070
+ const pct = Math.round(r.coverage * 100);
1071
+ return ok(`키워드 겹침 ${pct}% — 보유 ${r.matched.length}/${r.job_keywords.length}개. 부족: ${r.missing.join(', ') || '없음'}. (셀 수 있는 겹침일 뿐, 실제 적합도는 직접 판단하세요.)`, r);
1072
+ },
1073
+ },
976
1074
 
977
1075
  /* ------------------------------------------------------- workflow guide */
978
1076
  {
@@ -1039,7 +1137,7 @@ export const TOOLS: ToolDef[] = [
1039
1137
  name: 'get_verifier',
1040
1138
  title: '검증 루브릭 (저장 전 자가검증)',
1041
1139
  description:
1042
- '커리어 산출물(자소서·이력서·면접 답변·적합도 등)을 save_*로 저장하기 직전에 호출하세요. 해당 검증기의 점검 항목·합격 기준·세는 방법을 루브릭으로 돌려줍니다 — 실제 점검과 합격/불합격 판단은 당신(AI)이 직접 합니다(CareerMate는 내부 LLM이 없어 의미 판단을 대신하지 않습니다). 6개 검증기: truthfulness(사실/허위수치), consistency(산출물 간 모순), recency-staleness(신선도), responsiveness-on-target(질문에 답했는가), ats-compat(ATS 호환), human-voice(AI 티/사람 목소리).',
1140
+ '커리어 산출물(자소서·이력서·면접 답변·적합도 등)을 save_*로 저장하기 직전에 호출하세요. 해당 검증기의 점검 항목·합격 기준·세는 방법을 루브릭으로 돌려줍니다 — 실제 점검과 합격/불합격 판단은 당신(AI)이 직접 합니다(CareerMate는 내부 LLM이 없어 의미 판단을 대신하지 않습니다). 7개 검증기: truthfulness(사실/허위수치), consistency(산출물 간 모순), recency-staleness(신선도), responsiveness-on-target(질문에 답했는가), ats-compat(ATS 호환), human-voice(AI 티/사람 목소리), ai-detection-risk(한국 AI-자소서 탐지 시장 — 진정성·면접 방어가능성·자기복제 점검, 탐지 우회 아님).',
1043
1141
  inputSchema: {
1044
1142
  id: z
1045
1143
  .enum([...VERIFIER_IDS] as [VerifierId, ...VerifierId[]])
@@ -1061,14 +1159,20 @@ export const TOOLS: ToolDef[] = [
1061
1159
  name: 'validate_cover_letter',
1062
1160
  title: '자기소개서 자동 점검 (저장 전 미리보기)',
1063
1161
  description:
1064
- '자기소개서를 save_cover_letter_version으로 저장하기 직전에 호출하면, 저장하지 않고 미리 점검만 합니다. CareerMate가 LLM 없이 결정론으로 (1) 본문의 정량 수치가 저장된 경력·이력서·프로젝트에 근거가 있는지(없으면 환각 의심 → 저장 시 차단됨), (2) 공고에서 가져온 수치를 본인 성과처럼 쓰지 않았는지, (3) 번역투·제너릭 도입·빈 형용사·블라인드 위반 같은 문체 신호를 세어 돌려줍니다. 점검은 셀 수 있는 것만 봅니다 — 사실성·동문서답 같은 의미 판단은 당신(AI)이 직접 하세요. job_id를 주면 공고 수치까지 비교합니다.',
1162
+ '자기소개서를 save_cover_letter_version으로 저장하기 직전에 호출하면, 저장하지 않고 미리 점검만 합니다. CareerMate가 LLM 없이 결정론으로 (1) 본문의 정량 수치가 저장된 경력·이력서·프로젝트에 근거가 있는지(없으면 환각 의심 → 저장 시 차단됨), (2) 공고에서 가져온 수치를 본인 성과처럼 쓰지 않았는지, (3) 번역투·제너릭 도입·빈 형용사·블라인드 위반 같은 문체 신호를 세어 돌려줍니다. 또 (4) 글자수를 공백포함·공백제외·UTF-8 byte 세 기준으로 정확히 세고, min_chars/max_chars를 주면 상·하한 충족 여부까지 돌려줍니다(LLM은 글자수를 정확히 못 세므로 이 기능을 쓰세요). 점검은 셀 수 있는 것만 봅니다 — 사실성·동문서답 같은 의미 판단은 당신(AI)이 직접 하세요. 한국 문항형 자소서는 문항마다 글자수 한도가 다르니 각 문항 본문을 그 문항의 한도로 따로 점검하세요. job_id를 주면 공고 수치까지 비교합니다.',
1065
1163
  inputSchema: {
1066
- text: z.string().max(200_000).describe('점검할 자기소개서 본문'),
1164
+ text: z.string().max(200_000).describe('점검할 자기소개서 본문(문항형이면 한 문항 본문)'),
1067
1165
  job_id: z.string().optional().describe('연결할 공고(있으면 공고 수치까지 비교)'),
1068
1166
  strict: z
1069
1167
  .boolean()
1070
1168
  .optional()
1071
1169
  .describe('이번 점검만 엄격하게(이력서 본문에 근거 없는 수치도 차단). 사용자가 "엄격하게 봐줘"라고 할 때 true. 생략하면 저장된 모드를 따름'),
1170
+ min_chars: z.number().int().positive().optional().describe('글자수 하한(이 문항 기준). 미달이면 알려줍니다'),
1171
+ max_chars: z.number().int().positive().optional().describe('글자수 상한(이 문항 기준). 초과면 알려줍니다'),
1172
+ count_mode: z
1173
+ .enum(['with_space', 'no_space', 'byte'])
1174
+ .optional()
1175
+ .describe('글자수 기준: with_space=공백포함(기본), no_space=공백제외, byte=UTF-8 바이트. 공고가 지정한 기준을 쓰고, 모르면 공백포함'),
1072
1176
  },
1073
1177
  readOnly: true,
1074
1178
  handler: (args) => {
@@ -1079,7 +1183,25 @@ export const TOOLS: ToolDef[] = [
1079
1183
  ? `⛔ 이대로 저장하면 막힙니다 — ${report.blocking.map((b) => `${b.label}: ${b.detail}`).join('; ')}`
1080
1184
  : '✅ 자동 차단 항목 없음(셀 수 있는 항목 기준).';
1081
1185
  const modeLine = report.strict ? '\n(엄격 모드 적용 중)' : '';
1082
- return ok(`${verdict}${modeLine}\n${summarizeReport(report)}\n\n${report.disclaimer}`, report);
1186
+
1187
+ // Deterministic char count (chatbots can't count reliably) + optional limit check.
1188
+ const cc = report.charCounts;
1189
+ let charLine = `\n글자수 — 공백포함 ${cc.withSpace}자 · 공백제외 ${cc.noSpace}자 · ${cc.byte}byte`;
1190
+ const min = typeof args?.min_chars === 'number' ? args.min_chars : undefined;
1191
+ const max = typeof args?.max_chars === 'number' ? args.max_chars : undefined;
1192
+ let charLimit;
1193
+ if (min !== undefined || max !== undefined) {
1194
+ charLimit = checkCharLimit(text, { min, max, mode: args?.count_mode });
1195
+ const mLabel = charLimit.mode === 'no_space' ? '공백제외' : charLimit.mode === 'byte' ? 'byte' : '공백포함';
1196
+ const unit = charLimit.mode === 'byte' ? 'byte' : '자';
1197
+ if (charLimit.status === 'over') charLine += `\n⚠️ 한도 초과(${mLabel} ${charLimit.count}${unit} / 상한 ${max}${unit}) — ${charLimit.delta}${unit} 줄이세요`;
1198
+ else if (charLimit.status === 'under') charLine += `\n⚠️ 하한 미달(${mLabel} ${charLimit.count}${unit} / 하한 ${min}${unit}) — ${charLimit.delta}${unit} 더 쓰세요`;
1199
+ else charLine += `\n✅ 글자수 한도 충족(${mLabel} ${charLimit.count}${unit})`;
1200
+ }
1201
+ return ok(`${verdict}${modeLine}\n${summarizeReport(report)}${charLine}\n\n${report.disclaimer}`, {
1202
+ ...report,
1203
+ charLimit,
1204
+ });
1083
1205
  },
1084
1206
  },
1085
1207
  {
@@ -21,6 +21,7 @@ CareerMate는 사용자의 컴퓨터에서 동작하는 로컬 커리어 관리
21
21
 
22
22
  2. 결과는 반드시 다시 저장합니다.
23
23
  - 분석·작성한 내용은 머릿속이나 대화창에만 남기지 말고 해당 저장 툴로 CareerMate에 기록하세요: 공고는 \`save_job_posting\`, 적합도 분석은 \`save_fit_analysis\`, 자기소개서 버전은 \`save_cover_letter_version\`, 경력기술서는 \`add_resume\`(kind=career_description), 면접 준비는 \`save_interview_prep\`, 상태 변경은 \`update_application_status\`.
24
+ - 여러 문서를 한 번에 요청받아도 각 문서는 각자의 저장 툴로 따로 저장하세요. 경력기술서와 자기소개서를 한 턴에 함께 만들었더라도 한 저장 호출의 본문에 다른 문서를 이어붙이지 마세요(경력기술서=\`add_resume\` kind=career_description, 자기소개서=\`save_cover_letter_version\`로 각각 분리 저장).
24
25
  - 저장 후에는 무엇이 저장되었는지 사용자에게 간단히 알려주세요.
25
26
 
26
27
  3. 데이터를 지어내지 않습니다.