careermate 0.0.9 → 0.0.12

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 (52) hide show
  1. package/AGENTS.md +3 -2
  2. package/INSTALL.md +6 -6
  3. package/LICENSE +1 -1
  4. package/README.md +18 -16
  5. package/dist/career-os/README.md +2 -2
  6. package/dist/doctor.mjs +17857 -6595
  7. package/dist/mcp.mjs +14545 -3839
  8. package/dist/migrate.mjs +14258 -3709
  9. package/dist/pdf.worker.min.mjs +4 -4
  10. package/dist/public/app.js +3 -18
  11. package/dist/public/i18n/en/documents.js +1 -0
  12. package/dist/public/i18n/en/home.js +8 -1
  13. package/dist/public/i18n/en/jobs.js +2 -0
  14. package/dist/public/i18n/en/nav.js +1 -0
  15. package/dist/public/i18n/en/settings.js +1 -6
  16. package/dist/public/i18n/ko/documents.js +1 -0
  17. package/dist/public/i18n/ko/home.js +8 -1
  18. package/dist/public/i18n/ko/jobs.js +2 -0
  19. package/dist/public/i18n/ko/nav.js +1 -0
  20. package/dist/public/i18n/ko/settings.js +1 -6
  21. package/dist/public/index.html +0 -1
  22. package/dist/public/lib.js +13 -4
  23. package/dist/public/pages/documents.js +27 -10
  24. package/dist/public/pages/home.js +15 -5
  25. package/dist/public/pages/insights.js +156 -0
  26. package/dist/public/pages/jobs.js +21 -3
  27. package/dist/public/pages/settings.js +13 -12
  28. package/dist/public/styles.css +33 -0
  29. package/dist/web.mjs +16890 -5492
  30. package/docs/FAQ.md +3 -4
  31. package/docs/LOCAL_FIRST.md +32 -0
  32. package/docs/MCP_TOOLS.md +6 -3
  33. package/docs/REVIEW_IMPLEMENTATION_PLAN.md +22 -0
  34. package/docs/career-os/README.md +2 -2
  35. package/package.json +7 -12
  36. package/packages/core/src/data-health.ts +161 -0
  37. package/packages/core/src/fit-explain.ts +63 -0
  38. package/packages/core/src/index.ts +4 -0
  39. package/packages/core/src/job-categories.data.ts +140 -0
  40. package/packages/core/src/job-categories.ts +106 -0
  41. package/packages/core/src/jobsearch.ts +374 -66
  42. package/packages/core/src/reuse-insights.ts +126 -0
  43. package/packages/db/src/connection.ts +46 -26
  44. package/packages/db/src/index.ts +3 -0
  45. package/packages/db/src/interview-retrospectives.ts +145 -0
  46. package/packages/db/src/job-search.ts +100 -0
  47. package/packages/db/src/provenance.ts +125 -0
  48. package/packages/db/src/repositories.ts +1 -0
  49. package/packages/mcp-tools/src/tools.ts +115 -11
  50. package/packages/prompts/src/system.ts +21 -64
  51. package/scripts/init.ts +2 -0
  52. package/tsconfig.json +9 -10
package/docs/FAQ.md CHANGED
@@ -110,7 +110,7 @@ AI에게 "이 공고 저장하고 분석해줘"라고 하면서 URL이나 본문
110
110
  사용자 컴퓨터의 `~/.careermate`(Windows: `%USERPROFILE%\.careermate`)에만 저장됩니다.
111
111
  대시보드 **설정 > 데이터 저장 위치**에서 정확한 경로를 볼 수 있습니다. CareerMate가
112
112
  외부 서버로 개인 데이터를 보내는 일은 없습니다(외부 호출은 선택적 버전 확인과 공고
113
- 검색 시 검색 키워드뿐).
113
+ 검색 시 검색 키워드·직무 조건뿐).
114
114
 
115
115
  ### AI와 대화하면 내 데이터는 어디로 가나요?
116
116
  CareerMate 도구의 응답(프로필·이력서·자소서 등)은 **당신이 연결한 AI 클라이언트**
@@ -127,6 +127,5 @@ CareerMate 도구의 응답(프로필·이력서·자소서 등)은 **당신이
127
127
 
128
128
  ---
129
129
 
130
- 여기서 해결되지 않으면 [GitHub 이슈](https://github.com/osntak/careermate/issues)에 남겨
131
- 주세요. **보안 문제**는 이슈가 아니라 [`../SECURITY.md`](../SECURITY.md)의 비공개 절차를
132
- 이용해 주세요.
130
+ 여기서 해결되지 않으면 [이메일](mailto:osntak@gmail.com)로 문의해 주세요. 문의에는
131
+ 이름·연락처·자기소개서 본문 같은 개인정보를 붙여넣지 마세요.
@@ -0,0 +1,32 @@
1
+ # Local-first data ownership
2
+
3
+ CareerMate uses **local-first** to mean that the primary career workspace is owned by the user and stored on the user's machine.
4
+
5
+ ## Stored locally
6
+
7
+ - SQLite database
8
+ - profile, experience, project, and skill records
9
+ - résumés, career descriptions, and cover-letter versions
10
+ - saved job postings, application status, interview preparation, and timeline history
11
+ - backups and exports
12
+ - deterministic verification results
13
+
14
+ These records are not uploaded to a CareerMate account or CareerMate-operated database.
15
+
16
+ ## Optional network activity
17
+
18
+ Some features may use the network without changing the local-first model:
19
+
20
+ - checking the npm registry for a newer CareerMate version
21
+ - opening current installation documentation
22
+ - searching supported public job-board endpoints using only the user's search keyword and job category (locally, CareerMate only records which postings it has already seen: board, posting id, date)
23
+ - loading the public product website or demo
24
+ - public-site analytics on `careermate.life`
25
+
26
+ CareerMate must not attach locally stored résumé, profile, document, or application data to these requests.
27
+
28
+ ## Product language
29
+
30
+ Do not describe CareerMate as fully offline. Prefer:
31
+
32
+ > Your career database and documents stay on your computer. Optional discovery and update features may access the internet without sending your stored career data.
package/docs/MCP_TOOLS.md CHANGED
@@ -12,11 +12,11 @@ CareerMate는 **MCP-우선** 로컬 커리어 관리 도구입니다. 사용자
12
12
 
13
13
  ## 읽기 전용(read-only) 표기 안내
14
14
 
15
- 각 도구 항목의 **읽기전용** 열은 `tools.ts`의 `readOnly: true` 플래그를 그대로 반영합니다. 읽기전용 도구는 DB를 변경하지 않고 조회만 하며, 그 외 도구는 데이터를 생성·갱신하거나(쓰기) 브라우저를 여는 등의 부수효과를 가집니다.
15
+ 각 도구 항목의 **읽기전용** 열은 `tools.ts`의 `readOnly: true` 플래그를 그대로 반영합니다. 읽기전용 도구는 DB를 변경하지 않고 조회만 하며(예외: `search_jobs`는 공고를 처음 본 날짜를, `check_for_update`는 확인 결과를 로컬 캐시로 남긴다. 사용자 데이터는 바꾸지 않는다), 그 외 도구는 데이터를 생성·갱신하거나(쓰기) 브라우저를 여는 등의 부수효과를 가집니다.
16
16
 
17
17
  | 읽기전용(✅) 도구 | 쓰기/부수효과(✏️) 도구 |
18
18
  | --- | --- |
19
- | `read_document`, `read_inbox`, `get_onboarding_status`, `get_profile`, `get_resumes`, `get_experiences`, `get_projects`, `get_skills`, `get_cover_letters`, `get_job_posting`, `list_jobs`, `prescreen_job`, `search_jobs`, `compare_offers`, `get_offer`, `get_application_context`, `list_recent_activity`, `get_followups`, `get_pipeline_stats`, `get_workflow_guide`, `get_playbook`, `get_verifier`, `validate_cover_letter`, `get_writing_style_guide`, `check_for_update` | `open_inbox`, `save_profile`, `add_resume`, `delete_resume`, `add_experience`, `delete_experience`, `add_project`, `delete_project`, `add_skill`, `delete_skill`, `save_cover_letter_version`, `delete_cover_letter`, `set_verify_mode`, `save_job_posting`, `delete_job_posting`, `save_fit_analysis`, `update_application_status`, `save_interview_prep`, `save_offer`, `export_cover_letter`, `export_resume`, `export_profile`, `open_dashboard`, `open_application`, `update_careermate` |
19
+ | `read_document`, `read_inbox`, `get_onboarding_status`, `get_profile`, `get_resumes`, `get_experiences`, `get_projects`, `get_skills`, `get_cover_letters`, `get_job_posting`, `list_jobs`, `prescreen_job`, `search_jobs`, `find_job_categories`, `get_job_search_preferences`, `compare_offers`, `get_offer`, `get_application_context`, `list_recent_activity`, `get_followups`, `get_pipeline_stats`, `get_workflow_guide`, `get_playbook`, `get_verifier`, `validate_cover_letter`, `get_writing_style_guide`, `check_for_update` | `open_inbox`, `save_profile`, `add_resume`, `delete_resume`, `add_experience`, `delete_experience`, `add_project`, `delete_project`, `add_skill`, `delete_skill`, `save_cover_letter_version`, `delete_cover_letter`, `set_verify_mode`, `set_job_search_preferences`, `save_job_posting`, `delete_job_posting`, `save_fit_analysis`, `update_application_status`, `save_interview_prep`, `save_offer`, `export_cover_letter`, `export_resume`, `export_profile`, `open_dashboard`, `open_application`, `update_careermate` |
20
20
 
21
21
  ---
22
22
 
@@ -141,7 +141,10 @@ AI 클라이언트가 연결되면 이 도구를 가장 먼저 호출해 상태
141
141
 
142
142
  | 도구 | 목적 | 주요 입력 | 반환 | 언제 호출 | 읽기전용 |
143
143
  | --- | --- | --- | --- | --- | --- |
144
- | `search_jobs` | 키워드로 무인증 공개 잡보드(원티드·점핏)에서 공고를 *가져옴*(저장은 안 함) | `keyword`(필수), `sources?`, `limit?`(기본 10) | `{ query, results[](회사·직무·url·지역·경력·기술·마감), providers[] }` | 사용자가 "○○ 공고 찾아줘"라고 할 때. 결과를 보여주고 **고른 것만** save_job_posting | ✅ |
144
+ | `search_jobs` | 키워드나 직무 조건으로 무인증 공개 잡보드(원티드·점핏·리멤버·사람인)에서 공고를 *가져옴*(저장은 안 함). 여러 사이트의 같은 공고는 한 줄로 합침 | `keyword?`, `sources?`, `limit?`(사이트별, 기본 10, posted_since가 있으면 50, 최대 100), `remember_categories?`(["재무·회계·세무>회계결산"]), `saramin_categories?`(["322"]), `posted_since?`("today"·YYYY-MM-DD). keyword와 직무 조건을 모두 비우면 저장된 검색 조건 사용 | `{ query, results[](회사·직무·url·지역·경력·기술·마감·posted_at·first_seen_at·also_on, 사람인은 category_codes), providers[], filtered_out?, conditions, used_saved_preferences }` | "○○ 공고 찾아줘", "오늘 올라온 공고 보여줘". 결과를 보여주고 **고른 것만** save_job_posting. 원티드·점핏은 게시일이 없어 처음 본 날로 거름 | ✅ |
145
+ | `find_job_categories` | 직무 이름을 리멤버 분류("상위>하위")와 사람인 직무 코드로 변환(내장 분류표, 외부 통신 없음). 모든 직군 | `query`(쉼표로 여러 개), `limit?`(기본 20) | `{ query, remember[], saramin[](code·name·group·kind) }` 관련도순 | search_jobs·set_job_search_preferences에 직무 조건을 넣기 전 | ✅ |
146
+ | `get_job_search_preferences` | 저장된 공고 검색 조건 조회 | 없음 | `{ keyword, remember_categories, saramin_categories, updated_at }` | "내 검색 조건 뭐야?" | ✅ |
147
+ | `set_job_search_preferences` | 자주 볼 공고 검색 조건 저장(준 필드만 갱신). 리멤버 분류는 내장 분류표로 검증 | `keyword?`, `remember_categories?`, `saramin_categories?` | 저장된 조건 | 사용자 직무를 find_job_categories로 찾고 확인받은 뒤 | ✏️ |
145
148
  | `save_job_posting` | 채용공고 저장(있으면 갱신). 저장 시 지원(application) 항목 자동 생성 | `company`, `position`(필수), `url?`, `location?`, `employment_type?`, `description?`, `requirements?`, `keywords?`, `deadline?`, `source?`, `questions?`(회사 지정 자소서 문항·글자수: `question`·`min_chars`·`max_chars`·`count_mode`), `schedule?`(전형 일정: `label`·`date`(YYYY-MM-DD) 필수·`time`(HH:MM)·`note`) | 저장된 `JobRecord`(`job_id` 포함) | 공고를 DB에 등록/갱신할 때 | ✏️ |
146
149
  | `get_job_posting` | 공고 1건 상세 조회 | `job_id` | `{ job, fit, application, cover_letters, interview }` | 한 공고의 분석·상태·자소서·면접 자료를 한 번에 볼 때 | ✅ |
147
150
  | `list_jobs` | 저장된 모든 공고를 지원 상태·적합도 점수와 함께 목록화 | 없음 | 공고 메타 포함 배열 | 전체 공고를 훑어볼 때 | ✅ |
@@ -0,0 +1,22 @@
1
+ # Full Review Implementation Plan
2
+
3
+ This document tracks the repository-wide improvements implemented after the 2026-08 full review.
4
+
5
+ ## Product definition
6
+
7
+ CareerMate is local-first: the primary database, career records, generated documents, and core workflows are owned and stored on the user's machine. Optional network-backed features such as install guidance, update checks, analytics, and job-board search do not change that definition.
8
+
9
+ ## Delivery groups
10
+
11
+ 1. Data safety and lifecycle
12
+ 2. Provenance and human verification
13
+ 3. Backup, restore, and portability
14
+ 4. Provider validation and health
15
+ 5. Onboarding and diagnostics
16
+ 6. Workflow history and explainability
17
+ 7. Fit analysis quality and contradiction detection
18
+ 8. Front-end maintainability and accessibility
19
+ 9. CI, release, and security hardening
20
+ 10. Documentation and SEO
21
+
22
+ Each group should be implemented as an independently reviewable commit or PR with tests and migration coverage where applicable.
@@ -1,6 +1,6 @@
1
1
  # Career-OS — 외부 LLM 실행 진입점 (단일 입구)
2
2
 
3
- > ✅ **상태(2026-06): 이 라우팅·EOP 적용은 이제 런타임에 배선되어 있다(Phase B-1).** 연결된 AI는 `get_workflow_guide`로 작업별 실행 절차(EOP)와 적용할 플레이북·검증기 순서를 받고, `get_playbook`/`get_verifier`로 이 폴더의 지식을 실제로 받아 적용한다. `get_application_context`도 `recommended_route`·`verifier_sequence`·`next_tool`를 함께 돌려준다. 아직 구현하지 않은 범위(B-2: det 점수 계산·저장 게이트·마이그레이션)와 패키지 구조는 [`implementation/README.md`](https://github.com/osntak/careermate/blob/main/docs/career-os/implementation/README.md) 참고.
3
+ > ✅ **상태(2026-06): 이 라우팅·EOP 적용은 이제 런타임에 배선되어 있다(Phase B-1).** 연결된 AI는 `get_workflow_guide`로 작업별 실행 절차(EOP)와 적용할 플레이북·검증기 순서를 받고, `get_playbook`/`get_verifier`로 이 폴더의 지식을 실제로 받아 적용한다. `get_application_context`도 `recommended_route`·`verifier_sequence`·`next_tool`를 함께 돌려준다. 아직 구현하지 않은 범위(B-2: det 점수 계산·저장 게이트·마이그레이션)와 패키지 구조는 [`implementation/README.md`](implementation/README.md) 참고.
4
4
 
5
5
  > **여기서 시작하라.** CareerMate는 수동적 지식이 아니라, 외부 AI가 사용자 대면 결과를 내기 전에 적용해야 하는
6
6
  > **실행형 플레이북(EOP)·검증 루브릭·라우팅·출력 계약**을 제공한다. 두뇌=외부 AI(당신), CareerMate=실행 기질(substrate). 내부 LLM 없음.
@@ -9,7 +9,7 @@
9
9
  > 보조(런타임 아님): `validation/`(리서치 출처 + 다중 LLM(cross-vendor) 검증 하니스) · `implementation/`(CareerMate 개발자용 Phase B 내부 설계).
10
10
  >
11
11
  > EOP(Expert Operating Procedure) = 한 기능을 *시니어 전문가처럼* 수행하는 실행 절차. 검증된 리서치(`validation/research/`)에 근거하고
12
- > [`validation/PROTOCOL.md`](https://github.com/osntak/careermate/blob/main/docs/career-os/validation/PROTOCOL.md)의 다중 LLM(cross-vendor) 교차검증으로 단단해진다.
12
+ > [`validation/PROTOCOL.md`](validation/PROTOCOL.md)의 다중 LLM(cross-vendor) 교차검증으로 단단해진다.
13
13
 
14
14
  ## 0. 절대 원칙 — 근거 우선순위
15
15
  ```
package/package.json CHANGED
@@ -1,18 +1,11 @@
1
1
  {
2
2
  "name": "careermate",
3
- "version": "0.0.9",
3
+ "version": "0.0.12",
4
4
  "type": "module",
5
- "description": "CareerMate — an MCP-first, local-first career workspace. Connect it to a local AI agent (Claude Desktop, Claude Code, Codex); CareerMate stores your career data locally on your machine and exposes it over MCP.",
5
+ "description": "CareerMate — an MCP-first, local-first career workspace. Your career database and documents stay on your machine; optional discovery and update features may use the internet without sending stored career data.",
6
6
  "license": "MIT",
7
7
  "author": "CareerMate",
8
8
  "homepage": "https://careermate.life",
9
- "repository": {
10
- "type": "git",
11
- "url": "git+https://github.com/osntak/careermate.git"
12
- },
13
- "bugs": {
14
- "url": "https://github.com/osntak/careermate/issues"
15
- },
16
9
  "keywords": [
17
10
  "career",
18
11
  "resume",
@@ -41,6 +34,8 @@
41
34
  "INSTALL.md",
42
35
  "THIRD_PARTY_NOTICES.md",
43
36
  "AGENTS.md",
37
+ "docs/LOCAL_FIRST.md",
38
+ "docs/REVIEW_IMPLEMENTATION_PLAN.md",
44
39
  "docs/career-os/README.md",
45
40
  "docs/career-os/eop",
46
41
  "docs/career-os/knowledge",
@@ -80,13 +75,13 @@
80
75
  "mammoth": "^1.12.0",
81
76
  "pdfjs-dist": "^6.0.0",
82
77
  "tsx": "^4.19.2",
83
- "zod": "^3.24.1"
78
+ "zod": "^4.4.3"
84
79
  },
85
80
  "devDependencies": {
86
81
  "@playwright/test": "^1.60.0",
87
- "@types/node": "^22.10.0",
82
+ "@types/node": "^26.1.2",
88
83
  "esbuild": "^0.28.1",
89
84
  "playwright": "^1.60.0",
90
- "typescript": "^5.7.2"
85
+ "typescript": "^7.0.2"
91
86
  }
92
87
  }
@@ -0,0 +1,161 @@
1
+ import {
2
+ documentRepo,
3
+ experienceRepo,
4
+ projectRepo,
5
+ jobRepo,
6
+ fitRepo,
7
+ applicationRepo,
8
+ } from '@careermate/db';
9
+
10
+ export interface DataHealthIssue {
11
+ code: string;
12
+ severity: 'info' | 'warning' | 'error';
13
+ entity_type: string;
14
+ entity_id?: string;
15
+ message: string;
16
+ }
17
+
18
+ export interface DataHealthReport {
19
+ score: number;
20
+ checked_at: string;
21
+ counts: Record<string, number>;
22
+ issues: DataHealthIssue[];
23
+ }
24
+
25
+ function monthValue(value: string | null | undefined): number | null {
26
+ if (!value) return null;
27
+ const m = /^(\d{4})-(\d{2})/.exec(value);
28
+ if (!m) return null;
29
+ return Number(m[1]) * 12 + Number(m[2]);
30
+ }
31
+
32
+ function normalized(value: string | null | undefined): string {
33
+ return (value ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
34
+ }
35
+
36
+ export function getDataHealthReport(now = new Date()): DataHealthReport {
37
+ const experiences = experienceRepo.list();
38
+ const projects = projectRepo.list();
39
+ const documents = documentRepo.list();
40
+ const jobs = jobRepo.list();
41
+ const issues: DataHealthIssue[] = [];
42
+
43
+ for (const exp of experiences) {
44
+ const start = monthValue(exp.start_date);
45
+ const end = monthValue(exp.end_date);
46
+ if (start != null && end != null && end < start) {
47
+ issues.push({
48
+ code: 'experience_date_reversed',
49
+ severity: 'error',
50
+ entity_type: 'experience',
51
+ entity_id: exp.id,
52
+ message: `${exp.company} 경력의 종료일이 시작일보다 빠릅니다.`,
53
+ });
54
+ }
55
+ if (exp.is_current && exp.end_date) {
56
+ issues.push({
57
+ code: 'current_experience_has_end_date',
58
+ severity: 'warning',
59
+ entity_type: 'experience',
60
+ entity_id: exp.id,
61
+ message: `${exp.company} 경력이 재직 중이지만 종료일이 입력돼 있습니다.`,
62
+ });
63
+ }
64
+ if (!exp.role) {
65
+ issues.push({
66
+ code: 'experience_missing_role',
67
+ severity: 'warning',
68
+ entity_type: 'experience',
69
+ entity_id: exp.id,
70
+ message: `${exp.company} 경력에 직무/역할이 없습니다.`,
71
+ });
72
+ }
73
+ }
74
+
75
+ const seenProjects = new Map<string, string>();
76
+ for (const project of projects) {
77
+ const key = normalized(project.name);
78
+ const duplicate = seenProjects.get(key);
79
+ if (key && duplicate) {
80
+ issues.push({
81
+ code: 'duplicate_project_name',
82
+ severity: 'warning',
83
+ entity_type: 'project',
84
+ entity_id: project.id,
85
+ message: `같은 이름의 프로젝트가 중복돼 있습니다: ${project.name}`,
86
+ });
87
+ } else if (key) {
88
+ seenProjects.set(key, project.id);
89
+ }
90
+ const start = monthValue(project.start_date);
91
+ const end = monthValue(project.end_date);
92
+ if (start != null && end != null && end < start) {
93
+ issues.push({
94
+ code: 'project_date_reversed',
95
+ severity: 'error',
96
+ entity_type: 'project',
97
+ entity_id: project.id,
98
+ message: `${project.name} 프로젝트의 종료일이 시작일보다 빠릅니다.`,
99
+ });
100
+ }
101
+ }
102
+
103
+ const primaryDocs = documents.filter((doc) => doc.is_primary);
104
+ if (documents.length > 0 && primaryDocs.length === 0) {
105
+ issues.push({
106
+ code: 'missing_primary_document',
107
+ severity: 'warning',
108
+ entity_type: 'document',
109
+ message: '대표 이력서 또는 경력기술서가 지정되지 않았습니다.',
110
+ });
111
+ }
112
+
113
+ const nowDate = now.toISOString().slice(0, 10);
114
+ let staleDrafts = 0;
115
+ let jobsWithoutFit = 0;
116
+ for (const job of jobs) {
117
+ if (!fitRepo.getByJob(job.id)) jobsWithoutFit++;
118
+ const app = applicationRepo.getByJob(job.id);
119
+ if (app?.status === 'draft') {
120
+ const updated = String(app.updated_at ?? '').slice(0, 10);
121
+ if (updated && updated < nowDate) staleDrafts++;
122
+ }
123
+ }
124
+ if (jobsWithoutFit > 0) {
125
+ issues.push({
126
+ code: 'jobs_without_fit_analysis',
127
+ severity: 'info',
128
+ entity_type: 'job',
129
+ message: `적합도 분석이 없는 공고가 ${jobsWithoutFit}개 있습니다.`,
130
+ });
131
+ }
132
+ if (staleDrafts > 0) {
133
+ issues.push({
134
+ code: 'stale_draft_applications',
135
+ severity: 'info',
136
+ entity_type: 'application',
137
+ message: `후속 행동을 확인할 임시 지원 건이 ${staleDrafts}개 있습니다.`,
138
+ });
139
+ }
140
+
141
+ const penalty = issues.reduce((sum, issue) => {
142
+ if (issue.severity === 'error') return sum + 15;
143
+ if (issue.severity === 'warning') return sum + 7;
144
+ return sum + 2;
145
+ }, 0);
146
+
147
+ return {
148
+ score: Math.max(0, 100 - penalty),
149
+ checked_at: now.toISOString(),
150
+ counts: {
151
+ experiences: experiences.length,
152
+ projects: projects.length,
153
+ documents: documents.length,
154
+ jobs: jobs.length,
155
+ issues: issues.length,
156
+ errors: issues.filter((issue) => issue.severity === 'error').length,
157
+ warnings: issues.filter((issue) => issue.severity === 'warning').length,
158
+ },
159
+ issues,
160
+ };
161
+ }
@@ -0,0 +1,63 @@
1
+ import { fitRepo, jobRepo } from '@careermate/db';
2
+
3
+ export interface ExplainableFitAnalysis {
4
+ job_id: string;
5
+ overall_score: number | null;
6
+ requirement_coverage: number | null;
7
+ evidence_strength: number;
8
+ matched_requirements: string[];
9
+ missing_requirements: string[];
10
+ strengths: string[];
11
+ risks: string[];
12
+ recommended_actions: string[];
13
+ explanation: string;
14
+ }
15
+
16
+ function ratio(matched: number, missing: number): number | null {
17
+ const total = matched + missing;
18
+ return total === 0 ? null : Math.round((matched / total) * 100);
19
+ }
20
+
21
+ export function getExplainableFitAnalysis(jobId: string): ExplainableFitAnalysis | null {
22
+ const job = jobRepo.get(jobId);
23
+ const fit = fitRepo.getByJob(jobId);
24
+ if (!job || !fit) return null;
25
+
26
+ const matched = fit.matched_keywords ?? [];
27
+ const missing = fit.missing_keywords ?? [];
28
+ const coverage = ratio(matched.length, missing.length);
29
+ const evidenceItems = (fit.strengths ?? []).filter((item) => item.trim().length > 0);
30
+ const evidenceStrength = Math.min(100, evidenceItems.length * 20 + matched.length * 5);
31
+ const risks = [
32
+ ...(fit.gaps ?? []),
33
+ ...missing.map((item) => `근거가 부족한 요구사항: ${item}`),
34
+ ];
35
+ const actions = fit.recommendations?.length
36
+ ? fit.recommendations
37
+ : missing.map((item) => `${item} 요구에 연결할 실제 경험 또는 학습 근거를 보완하세요.`);
38
+
39
+ const explanationParts = [
40
+ coverage == null
41
+ ? '요구사항 충족률을 계산할 수 있는 매칭 정보가 아직 없습니다.'
42
+ : `저장된 키워드 기준 요구사항 충족률은 ${coverage}%입니다.`,
43
+ evidenceStrength >= 70
44
+ ? '강점과 근거가 비교적 충분합니다.'
45
+ : evidenceStrength >= 40
46
+ ? '일부 근거는 있지만 지원 문서에서 더 구체적으로 연결할 필요가 있습니다.'
47
+ : '현재 분석에는 구체적인 경험 근거가 부족합니다.',
48
+ risks.length > 0 ? `확인할 위험 요소가 ${risks.length}개 있습니다.` : '명시된 주요 위험 요소는 없습니다.',
49
+ ];
50
+
51
+ return {
52
+ job_id: jobId,
53
+ overall_score: fit.score,
54
+ requirement_coverage: coverage,
55
+ evidence_strength: evidenceStrength,
56
+ matched_requirements: matched,
57
+ missing_requirements: missing,
58
+ strengths: fit.strengths ?? [],
59
+ risks,
60
+ recommended_actions: actions,
61
+ explanation: explanationParts.join(' '),
62
+ };
63
+ }
@@ -7,5 +7,9 @@ export * from './context.ts';
7
7
  export * from './services.ts';
8
8
  export * from './summary.ts';
9
9
  export * from './jobsearch.ts';
10
+ export * from './job-categories.ts';
10
11
  export * from './offers.ts';
12
+ export * from './data-health.ts';
13
+ export * from './fit-explain.ts';
14
+ export * from './reuse-insights.ts';
11
15
  export * from './verify/index.ts';