careermate 0.0.7 → 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 (50) hide show
  1. package/README.md +30 -10
  2. package/THIRD_PARTY_NOTICES.md +2 -1
  3. package/dist/career-os/eop/cover-letter.md +4 -3
  4. package/dist/career-os/knowledge/cover-letter.md +3 -3
  5. package/dist/career-os/knowledge/fit-matching.md +2 -0
  6. package/dist/career-os/knowledge/verifiers/ai-detection-risk.md +57 -0
  7. package/dist/career-os/knowledge/verifiers/truthfulness.md +1 -0
  8. package/dist/doctor.mjs +18995 -801
  9. package/dist/mcp.mjs +19009 -815
  10. package/dist/migrate.mjs +83 -6
  11. package/dist/public/i18n/en/common.js +4 -0
  12. package/dist/public/i18n/en/documents.js +0 -10
  13. package/dist/public/i18n/en/home.js +5 -0
  14. package/dist/public/i18n/en/jobs.js +1 -1
  15. package/dist/public/i18n/en/profile.js +0 -5
  16. package/dist/public/i18n/ko/common.js +4 -0
  17. package/dist/public/i18n/ko/documents.js +0 -10
  18. package/dist/public/i18n/ko/home.js +5 -0
  19. package/dist/public/i18n/ko/jobs.js +1 -1
  20. package/dist/public/i18n/ko/profile.js +0 -5
  21. package/dist/public/lib.js +72 -0
  22. package/dist/public/pages/applications.js +10 -6
  23. package/dist/public/pages/documents.js +4 -7
  24. package/dist/public/pages/home.js +26 -6
  25. package/dist/public/pages/profile.js +2 -3
  26. package/dist/public/styles.css +39 -4
  27. package/dist/web.mjs +18547 -649
  28. package/docs/MCP_TOOLS.md +14 -7
  29. package/docs/career-os/eop/cover-letter.md +4 -3
  30. package/docs/career-os/knowledge/cover-letter.md +3 -3
  31. package/docs/career-os/knowledge/fit-matching.md +2 -0
  32. package/docs/career-os/knowledge/verifiers/ai-detection-risk.md +57 -0
  33. package/docs/career-os/knowledge/verifiers/truthfulness.md +1 -0
  34. package/package.json +3 -2
  35. package/packages/core/src/index.ts +1 -0
  36. package/packages/core/src/jobsearch.ts +186 -0
  37. package/packages/core/src/services.ts +24 -2
  38. package/packages/core/src/summary.ts +196 -0
  39. package/packages/core/src/verify/charcount.ts +87 -0
  40. package/packages/core/src/verify/index.ts +1 -0
  41. package/packages/core/src/verify/lint.ts +5 -0
  42. package/packages/db/src/repositories.ts +42 -8
  43. package/packages/db/src/schema.ts +29 -0
  44. package/packages/exporters/src/docx.ts +165 -0
  45. package/packages/exporters/src/index.ts +142 -9
  46. package/packages/exporters/src/markdown.ts +16 -11
  47. package/packages/knowledge/src/index.ts +8 -3
  48. package/packages/mcp-tools/src/tools.ts +173 -53
  49. package/packages/shared/src/schemas.ts +64 -0
  50. package/scripts/init.ts +5 -0
@@ -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
  {
@@ -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);
@@ -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);
@@ -821,7 +845,8 @@ export const TOOLS: ToolDef[] = [
821
845
  description:
822
846
  '예상 면접 질문, 꼬리 질문, STAR 답변 가이드, 1분 자기소개 초안 등 해당 공고 기준 면접 준비 자료를 저장합니다. 공고/직무/자기소개서를 근거로 당신(AI)이 생성한 자료를 저장하는 데 사용합니다. ' +
823
847
  "질문은 두 갈래로 준비하세요 — 각 질문의 category를 'technical'(기술·직무 1차 면접) 또는 'behavioral'(인성·컬처핏·임원 2차 면접, 자기소개서·프로필 기반)로 지정합니다. 가능하면 두 갈래 모두 채워 한 번에 저장하세요(대시보드가 기술/인성·컬처핏 탭으로 나눠 보여줍니다). " +
824
- 'job_id는 필수입니다. 같은 공고에 다시 저장하면 갱신됩니다.',
848
+ '면접을 본 뒤에는 같은 도구에 debrief(받은 질문 asked_questions·잘된 점 went_well·개선점 to_improve·다음 라운드 포커스 next_round_focus·체감 분위기 sentiment)를 채워 저장하면 다음 라운드 준비와 일관성 점검에 그대로 이어집니다(면접 전 준비와 면접 후 디브리프가 한 공고에 함께 쌓입니다). ' +
849
+ 'job_id는 필수입니다. 같은 공고에 다시 저장하면 갱신됩니다(debrief는 기존 값과 병합되어 기존 필드를 지우지 않습니다).',
825
850
  inputSchema: InterviewPrepInputSchema.shape,
826
851
  handler: (args) => {
827
852
  try {
@@ -844,27 +869,27 @@ export const TOOLS: ToolDef[] = [
844
869
  name: 'export_cover_letter',
845
870
  title: '자기소개서 내보내기',
846
871
  description:
847
- '자기소개서를 Markdown 또는 인쇄용 HTML(브라우저에서 PDF로 저장) 파일로 내보내 데이터 폴더의 exports에 저장하고, 파일 경로와 본문을 돌려줍니다. 사용자가 "자소서 파일로 받고 싶어"라고 할 때 사용하세요. 대시보드에서 직접 다운로드할 수도 있습니다.',
872
+ '자기소개서를 Word(.docx)·Markdown·인쇄용 HTML(브라우저에서 PDF로 저장) 파일로 내보내 데이터 폴더의 exports에 저장하고, 파일 경로를 돌려줍니다. ATS 제출처럼 Word 파일이 필요하면 format=docx를 쓰세요. 사용자가 "자소서 파일로 받고 싶어"라고 할 때 사용하세요. 대시보드에서 직접 다운로드할 수도 있습니다.',
848
873
  inputSchema: {
849
874
  cover_letter_id: z.string(),
850
- format: z.enum(['md', 'html']).optional().describe('기본 md'),
875
+ format: z.enum(['md', 'html', 'docx']).optional().describe('기본 md'),
851
876
  },
852
- handler: (args) => {
877
+ handler: async (args) => {
853
878
  const cl = coverLetterRepo.get(args.cover_letter_id, true);
854
879
  if (!cl) return fail('자기소개서를 찾을 수 없습니다.');
855
880
  const job = cl.job_id ? jobRepo.get(cl.job_id) : null;
856
881
  const profile = profileRepo.get();
857
882
  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,
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,
868
893
  filename: result.filename,
869
894
  content: result.content,
870
895
  });
@@ -874,26 +899,26 @@ export const TOOLS: ToolDef[] = [
874
899
  name: 'export_resume',
875
900
  title: '이력서/경력기술서 내보내기',
876
901
  description:
877
- '저장된 이력서·경력기술서 등 문서를 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로 확인할 수 있습니다. 대시보드에서 직접 다운로드할 수도 있습니다.',
878
903
  inputSchema: {
879
904
  document_id: z.string(),
880
- format: z.enum(['md', 'html']).optional().describe('기본 md'),
905
+ format: z.enum(['md', 'html', 'docx']).optional().describe('기본 md'),
881
906
  },
882
- handler: (args) => {
907
+ handler: async (args) => {
883
908
  const doc = documentRepo.get(args.document_id);
884
909
  if (!doc) return fail('문서를 찾을 수 없습니다.');
885
910
  const profile = profileRepo.get();
886
911
  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,
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,
897
922
  filename: result.filename,
898
923
  content: result.content,
899
924
  });
@@ -903,11 +928,11 @@ export const TOOLS: ToolDef[] = [
903
928
  name: 'export_profile',
904
929
  title: '프로필을 이력서로 내보내기',
905
930
  description:
906
- '저장된 프로필·경력·프로젝트·보유 기술을 한 장의 이력서 형식으로 모아 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를 쓰세요. 대시보드에서 직접 다운로드할 수도 있습니다.',
907
932
  inputSchema: {
908
- format: z.enum(['md', 'html']).optional().describe('기본 md'),
933
+ format: z.enum(['md', 'html', 'docx', 'json']).optional().describe('기본 md. json = JSON Resume 표준'),
909
934
  },
910
- handler: (args) => {
935
+ handler: async (args) => {
911
936
  const profile = profileRepo.get();
912
937
  if (!profile) return fail('프로필이 없습니다. 먼저 프로필을 저장하세요.');
913
938
  const format = args.format ?? 'md';
@@ -915,18 +940,17 @@ export const TOOLS: ToolDef[] = [
915
940
  const projects = projectRepo.list();
916
941
  const skills = skillRepo.list();
917
942
  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,
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,
930
954
  filename: result.filename,
931
955
  content: result.content,
932
956
  });
@@ -975,6 +999,78 @@ export const TOOLS: ToolDef[] = [
975
999
  return ok(`최근 활동 ${acts.length}건`, acts);
976
1000
  },
977
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
+ },
978
1074
 
979
1075
  /* ------------------------------------------------------- workflow guide */
980
1076
  {
@@ -1041,7 +1137,7 @@ export const TOOLS: ToolDef[] = [
1041
1137
  name: 'get_verifier',
1042
1138
  title: '검증 루브릭 (저장 전 자가검증)',
1043
1139
  description:
1044
- '커리어 산출물(자소서·이력서·면접 답변·적합도 등)을 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-자소서 탐지 시장 — 진정성·면접 방어가능성·자기복제 점검, 탐지 우회 아님).',
1045
1141
  inputSchema: {
1046
1142
  id: z
1047
1143
  .enum([...VERIFIER_IDS] as [VerifierId, ...VerifierId[]])
@@ -1063,14 +1159,20 @@ export const TOOLS: ToolDef[] = [
1063
1159
  name: 'validate_cover_letter',
1064
1160
  title: '자기소개서 자동 점검 (저장 전 미리보기)',
1065
1161
  description:
1066
- '자기소개서를 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를 주면 공고 수치까지 비교합니다.',
1067
1163
  inputSchema: {
1068
- text: z.string().max(200_000).describe('점검할 자기소개서 본문'),
1164
+ text: z.string().max(200_000).describe('점검할 자기소개서 본문(문항형이면 한 문항 본문)'),
1069
1165
  job_id: z.string().optional().describe('연결할 공고(있으면 공고 수치까지 비교)'),
1070
1166
  strict: z
1071
1167
  .boolean()
1072
1168
  .optional()
1073
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 바이트. 공고가 지정한 기준을 쓰고, 모르면 공백포함'),
1074
1176
  },
1075
1177
  readOnly: true,
1076
1178
  handler: (args) => {
@@ -1081,7 +1183,25 @@ export const TOOLS: ToolDef[] = [
1081
1183
  ? `⛔ 이대로 저장하면 막힙니다 — ${report.blocking.map((b) => `${b.label}: ${b.detail}`).join('; ')}`
1082
1184
  : '✅ 자동 차단 항목 없음(셀 수 있는 항목 기준).';
1083
1185
  const modeLine = report.strict ? '\n(엄격 모드 적용 중)' : '';
1084
- 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
+ });
1085
1205
  },
1086
1206
  },
1087
1207
  {
@@ -114,6 +114,43 @@ export const LinkSchema = z.object({
114
114
  });
115
115
  export type Link = z.infer<typeof LinkSchema>;
116
116
 
117
+ /* Structured quantitative credentials (한국 이력서 스펙). Values are kept verbatim as
118
+ strings so the AI cites them exactly (학점 3.8, TOEIC 920) without re-deriving. */
119
+ export const EducationSchema = z.object({
120
+ school: reqLine.describe('학교명'),
121
+ degree: z.string().max(MAX_LINE).optional().describe('학위/과정 (고졸·학사·석사·박사 등)'),
122
+ major: z.string().max(MAX_LINE).optional().describe('전공'),
123
+ gpa: z.string().max(MAX_LINE).optional().describe('학점 (예: 3.8)'),
124
+ gpa_scale: z.string().max(MAX_LINE).optional().describe('학점 만점 (예: 4.5)'),
125
+ start_date: z.string().max(MAX_LINE).optional().describe('YYYY-MM'),
126
+ end_date: z.string().max(MAX_LINE).optional().describe('YYYY-MM (졸업/예정)'),
127
+ status: z.string().max(MAX_LINE).optional().describe('졸업/재학/휴학/중퇴/수료'),
128
+ });
129
+ export type Education = z.infer<typeof EducationSchema>;
130
+
131
+ export const CertificationSchema = z.object({
132
+ name: reqLine.describe('자격증/면허명'),
133
+ issuer: z.string().max(MAX_LINE).optional().describe('발급기관'),
134
+ date: z.string().max(MAX_LINE).optional().describe('취득일 YYYY-MM'),
135
+ score: z.string().max(MAX_LINE).optional().describe('점수/등급(있으면)'),
136
+ });
137
+ export type Certification = z.infer<typeof CertificationSchema>;
138
+
139
+ export const LanguageScoreSchema = z.object({
140
+ test: reqLine.describe('시험명 (TOEIC·OPIc·TOEIC Speaking·JLPT·HSK 등)'),
141
+ score: reqLine.describe('점수/등급 (예: 920, IH, N1)'),
142
+ date: z.string().max(MAX_LINE).optional().describe('응시/취득일 YYYY-MM'),
143
+ });
144
+ export type LanguageScore = z.infer<typeof LanguageScoreSchema>;
145
+
146
+ export const AwardSchema = z.object({
147
+ title: reqLine.describe('수상/대외활동명'),
148
+ issuer: z.string().max(MAX_LINE).optional().describe('주최/수여기관'),
149
+ date: z.string().max(MAX_LINE).optional().describe('YYYY-MM'),
150
+ description: z.string().max(MAX_NOTE).optional().describe('간단 설명'),
151
+ });
152
+ export type Award = z.infer<typeof AwardSchema>;
153
+
117
154
  export const ProfileInputSchema = z.object({
118
155
  name: reqLine.optional(),
119
156
  email: optLine,
@@ -126,6 +163,10 @@ export const ProfileInputSchema = z.object({
126
163
  preferred_tone: z.string().max(MAX_LINE).optional().describe('자기소개서 선호 문체 (예: 담백하고 구체적)'),
127
164
  emphasis_points: strList.optional().describe('강조하고 싶은 핵심 포인트'),
128
165
  links: z.array(LinkSchema).max(MAX_ITEMS).optional().describe('포트폴리오/깃허브/링크드인 등'),
166
+ education: z.array(EducationSchema).max(MAX_ITEMS).optional().describe('학력 (학교·학위·전공·학점)'),
167
+ certifications: z.array(CertificationSchema).max(MAX_ITEMS).optional().describe('자격증/면허'),
168
+ language_scores: z.array(LanguageScoreSchema).max(MAX_ITEMS).optional().describe('어학 점수 (토익·오픽 등)'),
169
+ awards: z.array(AwardSchema).max(MAX_ITEMS).optional().describe('수상/대외활동'),
129
170
  });
130
171
  export type ProfileInput = z.infer<typeof ProfileInputSchema>;
131
172
 
@@ -142,6 +183,10 @@ export const ProfileRecordSchema = z.object({
142
183
  preferred_tone: z.string().nullable(),
143
184
  emphasis_points: z.array(z.string()),
144
185
  links: z.array(LinkSchema),
186
+ education: z.array(EducationSchema),
187
+ certifications: z.array(CertificationSchema),
188
+ language_scores: z.array(LanguageScoreSchema),
189
+ awards: z.array(AwardSchema),
145
190
  });
146
191
  export type ProfileRecord = z.infer<typeof ProfileRecordSchema>;
147
192
 
@@ -346,6 +391,9 @@ export const JobInputSchema = z.object({
346
391
  keywords: strList.optional().describe('핵심 키워드'),
347
392
  deadline: z.string().max(MAX_LINE).optional().describe('마감일 YYYY-MM-DD'),
348
393
  source: z.string().max(MAX_LINE).optional().describe('출처 (사람인/원티드/직접 입력 등)'),
394
+ company_overview: optBody.describe('회사 사업개요·미션·주요 제품·최근 활동 (AI가 리서치해 채움)'),
395
+ talent_profile: optBody.describe('회사가 강조하는 인재상 (한국 자소서·면접에서 정렬 기준)'),
396
+ core_values: strList.optional().describe('회사 핵심가치 (키워드 배열)'),
349
397
  });
350
398
  export type JobInput = z.infer<typeof JobInputSchema>;
351
399
 
@@ -361,6 +409,9 @@ export const JobRecordSchema = z.object({
361
409
  keywords: z.array(z.string()),
362
410
  deadline: z.string().nullable(),
363
411
  source: z.string().nullable(),
412
+ company_overview: z.string().nullable(),
413
+ talent_profile: z.string().nullable(),
414
+ core_values: z.array(z.string()),
364
415
  });
365
416
  export type JobRecord = z.infer<typeof JobRecordSchema>;
366
417
 
@@ -459,12 +510,24 @@ export const InterviewQuestionSchema = z.object({
459
510
  });
460
511
  export type InterviewQuestion = z.infer<typeof InterviewQuestionSchema>;
461
512
 
513
+ /** Post-interview debrief — captured AFTER the interview to close the loop and feed the next round. */
514
+ export const InterviewDebriefSchema = z.object({
515
+ asked_questions: strList.optional().describe('실제로 받은 질문들'),
516
+ went_well: optBody.describe('잘된 점/강했던 답변'),
517
+ to_improve: optBody.describe('아쉬운 점/다음에 개선할 점'),
518
+ next_round_focus: optBody.describe('다음 라운드/면접에 대비해 준비할 포인트'),
519
+ sentiment: z.enum(['positive', 'neutral', 'negative']).optional().describe('체감 분위기'),
520
+ interviewed_at: optLine.describe('면접 본 날짜(YYYY-MM-DD)'),
521
+ });
522
+ export type InterviewDebrief = z.infer<typeof InterviewDebriefSchema>;
523
+
462
524
  export const InterviewPrepInputSchema = z.object({
463
525
  job_id: reqLine,
464
526
  questions: z.array(InterviewQuestionSchema).max(MAX_ITEMS).optional(),
465
527
  star_guides: z.array(StarGuideSchema).max(MAX_ITEMS).optional(),
466
528
  self_introduction: optBody.describe('1분 자기소개 초안'),
467
529
  notes: optNote.describe('면접 후기/메모'),
530
+ debrief: InterviewDebriefSchema.optional().describe('면접 후 디브리프(받은 질문·잘된 점·개선점·다음 라운드 포커스)'),
468
531
  });
469
532
  export type InterviewPrepInput = z.infer<typeof InterviewPrepInputSchema>;
470
533
 
@@ -475,6 +538,7 @@ export const InterviewPrepRecordSchema = z.object({
475
538
  star_guides: z.array(StarGuideSchema),
476
539
  self_introduction: z.string().nullable(),
477
540
  notes: z.string().nullable(),
541
+ debrief: InterviewDebriefSchema.nullable(),
478
542
  });
479
543
  export type InterviewPrepRecord = z.infer<typeof InterviewPrepRecordSchema>;
480
544
 
package/scripts/init.ts CHANGED
@@ -394,6 +394,11 @@ const SAFE_TOOLS: readonly string[] = [
394
394
  'get_application_context', 'save_fit_analysis', 'update_application_status', 'save_interview_prep',
395
395
  'export_cover_letter', 'export_resume', 'export_profile', 'list_recent_activity', 'get_workflow_guide', 'get_playbook', 'get_verifier',
396
396
  'validate_cover_letter', 'set_verify_mode', 'get_writing_style_guide', 'check_for_update',
397
+ // 읽기전용 조회 도구(DB 변경·외부 전송 없음) → 사전허용 안전.
398
+ 'get_followups', 'get_pipeline_stats', 'prescreen_job',
399
+ // search_jobs: 공개 잡보드에서 공고를 가져오기만(보내는 건 검색어뿐, 사용자 데이터 미전송).
400
+ // check_for_update와 같은 "네트워크 read" 부류라 사전허용 안전(우회·쓰기 없음).
401
+ 'search_jobs',
397
402
  // careermate 자신의 인박스 폴더·대시보드(127.0.0.1)만 여는 로컬 동작 + 인박스 내부 문서 읽기 → 사전허용 안전.
398
403
  'read_inbox', 'open_inbox', 'open_dashboard', 'open_application',
399
404
  ];