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.
- package/README.md +30 -10
- package/THIRD_PARTY_NOTICES.md +2 -1
- package/dist/career-os/eop/cover-letter.md +4 -3
- package/dist/career-os/knowledge/cover-letter.md +3 -3
- package/dist/career-os/knowledge/fit-matching.md +2 -0
- package/dist/career-os/knowledge/verifiers/ai-detection-risk.md +57 -0
- package/dist/career-os/knowledge/verifiers/truthfulness.md +1 -0
- package/dist/doctor.mjs +18995 -801
- package/dist/mcp.mjs +19009 -815
- package/dist/migrate.mjs +83 -6
- package/dist/public/i18n/en/common.js +4 -0
- package/dist/public/i18n/en/documents.js +0 -10
- package/dist/public/i18n/en/home.js +5 -0
- package/dist/public/i18n/en/jobs.js +1 -1
- package/dist/public/i18n/en/profile.js +0 -5
- package/dist/public/i18n/ko/common.js +4 -0
- package/dist/public/i18n/ko/documents.js +0 -10
- package/dist/public/i18n/ko/home.js +5 -0
- package/dist/public/i18n/ko/jobs.js +1 -1
- package/dist/public/i18n/ko/profile.js +0 -5
- package/dist/public/lib.js +72 -0
- package/dist/public/pages/applications.js +10 -6
- package/dist/public/pages/documents.js +4 -7
- package/dist/public/pages/home.js +26 -6
- package/dist/public/pages/profile.js +2 -3
- package/dist/public/styles.css +39 -4
- package/dist/web.mjs +18547 -649
- package/docs/MCP_TOOLS.md +14 -7
- package/docs/career-os/eop/cover-letter.md +4 -3
- package/docs/career-os/knowledge/cover-letter.md +3 -3
- package/docs/career-os/knowledge/fit-matching.md +2 -0
- package/docs/career-os/knowledge/verifiers/ai-detection-risk.md +57 -0
- package/docs/career-os/knowledge/verifiers/truthfulness.md +1 -0
- package/package.json +3 -2
- package/packages/core/src/index.ts +1 -0
- package/packages/core/src/jobsearch.ts +186 -0
- package/packages/core/src/services.ts +24 -2
- package/packages/core/src/summary.ts +196 -0
- package/packages/core/src/verify/charcount.ts +87 -0
- package/packages/core/src/verify/index.ts +1 -0
- package/packages/core/src/verify/lint.ts +5 -0
- package/packages/db/src/repositories.ts +42 -8
- package/packages/db/src/schema.ts +29 -0
- package/packages/exporters/src/docx.ts +165 -0
- package/packages/exporters/src/index.ts +142 -9
- package/packages/exporters/src/markdown.ts +16 -11
- package/packages/knowledge/src/index.ts +8 -3
- package/packages/mcp-tools/src/tools.ts +173 -53
- package/packages/shared/src/schemas.ts +64 -0
- 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
|
-
|
|
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 &&
|
|
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
|
-
'
|
|
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
|
|
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 =
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
return ok(`'${cl.title}'를 ${format.toUpperCase()}로 내보냈습니다.\n저장 위치: ${
|
|
867
|
-
path:
|
|
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
|
|
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 =
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
return ok(`'${doc.title}'를 ${format.toUpperCase()}로 내보냈습니다.\n저장 위치: ${
|
|
896
|
-
path:
|
|
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
|
|
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 === '
|
|
919
|
-
?
|
|
920
|
-
:
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
}
|
|
928
|
-
|
|
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이 없어 의미 판단을 대신하지 않습니다).
|
|
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
|
-
|
|
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
|
];
|