careermate 0.1.2 → 0.1.4
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/AGENTS.md +33 -0
- package/LICENSE +21 -0
- package/README.md +3 -1
- package/apps/web/public/pages/documents.js +7 -2
- package/apps/web/public/pages/home.js +4 -7
- package/apps/web/public/pages/jobs.js +1 -5
- package/apps/web/public/pages/profile.js +3 -3
- package/apps/web/public/styles.css +7 -2
- package/dist/doctor.mjs +112 -90
- package/dist/install-page/analytics.js +97 -0
- package/dist/install-page/index.html +77 -56
- package/dist/install-page/og.png +0 -0
- package/dist/install-page/robots.txt +4 -0
- package/dist/install-page/sitemap.xml +21 -0
- package/dist/install-page/start.html +13 -10
- package/dist/install-page/style.css +75 -40
- package/dist/mcp.mjs +112 -90
- package/dist/migrate.mjs +95 -82
- package/dist/public/pages/documents.js +7 -2
- package/dist/public/pages/home.js +4 -7
- package/dist/public/pages/jobs.js +1 -5
- package/dist/public/pages/profile.js +3 -3
- package/dist/public/styles.css +7 -2
- package/dist/web.mjs +113 -86
- package/docs/FAQ.md +101 -0
- package/docs/career-os/README.md +76 -0
- package/docs/career-os/eop/cover-letter.md +46 -0
- package/docs/career-os/eop/fit-analysis.md +48 -0
- package/docs/career-os/eop/interview-prep.md +39 -0
- package/docs/career-os/eop/job-analysis.md +42 -0
- package/docs/career-os/eop/profile-extraction.md +37 -0
- package/docs/career-os/knowledge/ats.md +131 -0
- package/docs/career-os/knowledge/company-research.md +137 -0
- package/docs/career-os/knowledge/cover-letter.md +135 -0
- package/docs/career-os/knowledge/fit-matching.md +121 -0
- package/docs/career-os/knowledge/human-writing.md +118 -0
- package/docs/career-os/knowledge/interview-behavioral.md +132 -0
- package/docs/career-os/knowledge/interview-technical.md +126 -0
- package/docs/career-os/knowledge/linkedin-profile.md +152 -0
- package/docs/career-os/knowledge/networking-referrals.md +166 -0
- package/docs/career-os/knowledge/offer-evaluation-decision.md +121 -0
- package/docs/career-os/knowledge/onboarding-first-90-days.md +126 -0
- package/docs/career-os/knowledge/portfolio.md +121 -0
- package/docs/career-os/knowledge/recruiter-screen.md +105 -0
- package/docs/career-os/knowledge/rejection-triage-iteration.md +115 -0
- package/docs/career-os/knowledge/resume.md +123 -0
- package/docs/career-os/knowledge/salary-negotiation.md +123 -0
- package/docs/career-os/knowledge/verifiers/ats-compat.md +64 -0
- package/docs/career-os/knowledge/verifiers/consistency.md +61 -0
- package/docs/career-os/knowledge/verifiers/human-voice.md +82 -0
- package/docs/career-os/knowledge/verifiers/recency-staleness.md +72 -0
- package/docs/career-os/knowledge/verifiers/responsiveness-on-target.md +82 -0
- package/docs/career-os/knowledge/verifiers/truthfulness.md +67 -0
- package/package.json +11 -3
- package/packages/db/src/repositories.ts +18 -1
- package/packages/mcp-tools/src/tools.ts +5 -5
- package/packages/parsers/src/documents.ts +13 -1
- package/packages/shared/src/schemas.ts +115 -81
- package/scripts/build-dist.mjs +7 -1
- package/scripts/gen-og.mjs +1 -1
- package/scripts/test.ts +25 -0
- package/scripts/ui-smoke.ts +32 -4
- package/dist/install-page/.omc/state/agent-replay-55a58c0d-bb6e-43ea-8a05-4b6c93b80b34.jsonl +0 -12
- package/dist/install-page/.omc/state/idle-notif-cooldown.json +0 -3
- package/dist/install-page/.omc/state/sessions/55a58c0d-bb6e-43ea-8a05-4b6c93b80b34/mission-state.json +0 -147
- package/dist/install-page/.omc/state/sessions/55a58c0d-bb6e-43ea-8a05-4b6c93b80b34/pre-tool-advisory-throttle.json +0 -22
- package/dist/install-page/.omc/state/sessions/55a58c0d-bb6e-43ea-8a05-4b6c93b80b34/subagent-tracking-state.json +0 -44
- package/docs/ARCHITECTURE.md +0 -208
- package/docs/CHANGES_V1.md +0 -103
- package/docs/DATA_MODEL.md +0 -460
- package/docs/DECISIONS.md +0 -277
- package/docs/DEMO.md +0 -242
- package/docs/ROADMAP.md +0 -135
- package/docs/RUNTIME_DISTRIBUTION_RESEARCH.md +0 -135
- package/docs/TODO.md +0 -57
- package/docs/UX_NOTES.md +0 -247
- package/docs/superpowers/specs/2026-06-16-demo-dashboard-deploy-design.md +0 -64
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# 진실성 / 날조 방지 (Anti-fabrication) — CareerMate 교차검증 루브릭 (Phase A)
|
|
2
|
+
|
|
3
|
+
> 이 검증기는 연결된 AI가 커리어 산출물(이력서·자소서·면접 답변·LinkedIn 등)을 점검할 때 적용하는 **크리틱 루브릭**이다. CareerMate는 루브릭을 제공하고, 실제 검사는 연결된 AI가 수행한다(내부 LLM 없음).
|
|
4
|
+
|
|
5
|
+
## 1. 목적
|
|
6
|
+
|
|
7
|
+
이 검증기는 **모든 커리어 산출물의 모든 사실 주장이 사용자의 저장된 데이터로 추적 가능함**을 보장한다. 즉, 산출물 안의 숫자·직함·회사·기간·학력·자격증·기술 숙련도·고유명사가 (a) 저장된 프로필/경력/이력서/자소서 데이터에 직접 존재하거나, (b) 그 저장 데이터에서 **명시적 산술 파생**(차·합·비율 환산)으로 나온 것이어야 한다는 뜻이다. 그 밖의 모든 사실 주장은 날조(fabrication) 또는 미검증(unverified)으로 분류해 차단하거나 `[확인 필요]`로 표시한다.
|
|
8
|
+
|
|
9
|
+
이 검증기가 막으려는 4가지 실패 모드:
|
|
10
|
+
- **날조(fabrication):** 없는 사실을 만들어냄(존재하지 않은 회사·직함·자격증, 지어낸 수치).
|
|
11
|
+
- **과장(inflation):** 있는 사실을 부풀림(직함 격상, 팀 성과를 개인 성과로 귀속, "참여"를 "주도"로).
|
|
12
|
+
- **미검증 정밀도(false precision):** 저장 데이터에 근거 없는 구체 수치("매출 37% 증가")를 생성.
|
|
13
|
+
- **갭 은폐(gap concealment):** 모르는 것을 모른다고 하지 않고 빈칸을 그럴듯하게 메움.
|
|
14
|
+
|
|
15
|
+
> 경계 원칙: **표현을 강하게 다듬는 것(action verb·두괄식·정량화)은 허용, 사실을 바꾸는 것은 금지.** 강한 동사로 쓰되 동사가 가리키는 행위·결과는 저장 데이터와 일치해야 한다. 이 검증기는 사람처럼 쓰기(`human-writing.md`)의 "Fact lock(R7)" 신호를 **전 산출물·전 사실유형으로 확장한 전용 게이트**다.
|
|
16
|
+
|
|
17
|
+
핵심 제약상 한국어 NER·실세계 사실확인은 결정론으로 100% 셀 수 없다. 따라서 항목을 **(가) LLM 없이 셀 수 있는 결정론 게이트**와 **(나) 연결된 AI가 수행하는 대조 판정**으로 나눠 설계하고, 각 항목에 어느 쪽인지 표시한다.
|
|
18
|
+
|
|
19
|
+
## 2. 검사 항목
|
|
20
|
+
|
|
21
|
+
표기: **[det]** = CareerMate가 LLM 없이 셀 수 있는 결정론 체크(개수·존재·정규식·비율). **[ai]** = 연결된 AI가 저장 데이터와 대조해 판정(개수로 환원해 보고).
|
|
22
|
+
|
|
23
|
+
| ID | 검사 | 합격 기준 | 측정 방법(셀 수 있게) |
|
|
24
|
+
|----|------|-----------|------------------------|
|
|
25
|
+
| C1 **[ai]** | **수치 앵커링** — 산출물의 모든 정량 주장(숫자+단위/%, 금액, 인원, 기간)이 저장 데이터에 존재하거나 거기서 산술 파생되는가 | 미앵커 수치 = **0** | 정규식 `\d[\d,.]*\s*(%|억|만|원|명|건|배|개월|년|시간|ms|x|배)` 등으로 산출물의 수치 토큰 수 `N_total`를 센다 → AI가 각 토큰을 저장값 또는 파생식에 매핑 → 매핑 실패 수 `N_unanchored` 보고. 합격 = `N_unanchored == 0` |
|
|
26
|
+
| C2 **[ai]** | **고유명사 화이트리스트** — 회사명·기관·제품·기술스택·인명이 저장 데이터(프로필/경력/이력서/스킬)에 존재하는가 | 화이트리스트 밖 고유명사 = **0** | 저장 데이터에서 고유명사 집합 `W`를 추출(set) → 산출물의 고유명사 후보를 `W`와 대조 → `W`에 없는 항목 수 보고. 일반어·직무 일반명사는 제외 |
|
|
27
|
+
| C3 **[ai]** | **직함·고용형태 일치** — 진술된 직함/고용형태(정규직·인턴·계약·프리랜서·동아리)가 저장 직함과 동일하거나 더 낮은 책임 수준인가 | 격상된 직함 = **0** | 저장 직함 문자열과 산출물 직함을 1:1 대조 → 불일치 건수, 그중 "격상"(예: 인턴→주니어, 팀원→팀장, 계약→정규) 건수 보고. 합격 = 격상 0 |
|
|
28
|
+
| C4 **[ai]** | **귀속(attribution) 정확성** — 팀 성과를 1인칭 단독 성과로 바꾸지 않았는가 | 부당 단독귀속 = **0** | "주도/총괄/혼자/단독/led/owned" 류 1인칭 단독 동사 출현 수를 센 뒤, 각각에 대해 저장 데이터가 단독 역할을 뒷받침하는지 AI가 판정 → 미뒷받침 건수 보고 |
|
|
29
|
+
| C5 **[ai]** | **학력·자격·점수 정확성** — 학위/전공/졸업여부/자격증/어학·시험 점수가 저장값과 정확히 일치하는가 | 불일치 = **0** | 저장된 학력·자격·점수 필드와 산출물 토큰을 정확 일치(점수는 값까지) 대조 → 불일치 건수 보고. "더 유명한 학교/상위 학위/미취득을 취득"으로의 변경은 critical |
|
|
30
|
+
| C6 **[ai]** | **숙련도 정직성** — 기술 숙련 표현(전문가/마스터/능숙/숙련/expert)이 저장 근거(연차·프로젝트·산출물)와 모순되지 않는가 | 근거 없는 최상급 = **0** | "전문가/마스터/완벽/모든/항상/expert/mastery" 류 최상급·절대표현 출현 수를 센 뒤, 저장 근거 유무를 AI가 판정 → 근거 없는 건수 보고 |
|
|
31
|
+
| C7 **[det]** | **갭 표식 보존** — AI가 만든 `[확인 필요]`/플레이스홀더가 최종본에 임의의 그럴듯한 값으로 치환되지 않았는가 | 빈칸을 지어낸 값으로 채운 흔적 = **0** | 직전 초안의 `[확인 필요]`·`{{ }}`·`___` 개수 `P_before`와 최종본 개수 `P_after` 비교. `P_after < P_before`이면 그 차이만큼 AI가 "저장 근거로 채움 vs 지어냄"을 보고. 미설명 감소 = 불합격 |
|
|
32
|
+
| C8 **[ai]** | **파생연산 검증** — 산출물의 비율/증감 환산(예: "월12건→월2건 = 83% 감소")이 저장 입력값으로 실제 계산되는가 | 계산 오류·근거 없는 파생 = **0** | 파생 표현마다 `(입력값, 식, 결과)` 3요소를 추출 → 산술 재계산이 일치하는 건수/불일치 건수 보고 |
|
|
33
|
+
| C9 **[ai]** | **시간선 정합성** — 날짜·기간·근속이 저장 경력 타임라인과 모순되지 않는가(미래일자·중복재직·음수기간 없음) | 시간선 모순 = **0** | 산출물의 날짜·기간 토큰을 저장 타임라인과 대조 → 중복재직/미래시작/기간>실재직 같은 모순 건수 보고 |
|
|
34
|
+
| C10 **[det]** | **추적성 비율(traceability)** — 검증 대상이 된 전체 사실 주장 중 저장 데이터로 매핑된 비율 | 추적성 = **100%** (= 미앵커 0) | `traceability = (N_total_claims − N_unanchored − N_unverified) / N_total_claims`. C1·C2·C5의 합산 분모로 계산해 단일 수치로 보고. 1.0 미만이면 불합격 |
|
|
35
|
+
|
|
36
|
+
> critical 등급: C1·C2·C3·C5(없는 사실/격상/학력 위조)는 **단 1건이라도** 발견 시 전체 산출물을 불합격 처리한다. C4·C6·C8·C9는 건수 누적으로 판단하되 0을 목표로 한다. C7·C10은 게이트(전부 통과해야 합격).
|
|
37
|
+
|
|
38
|
+
## 3. 불합격 시 처리(루프 연결)
|
|
39
|
+
|
|
40
|
+
연결된 AI는 아래 순서로 **수정 → 재검증**한다. 재검증은 동일 루브릭 전 항목을 다시 돌려 **C1·C2·C3·C5 = 0 이고 C10 = 1.0** 일 때만 통과로 본다.
|
|
41
|
+
|
|
42
|
+
1. **분류 먼저, 삭제·치환은 다음.** 각 불합격 토큰을 (a) 날조 (b) 과장 (c) 미검증 정밀도 (d) 갭은폐 중 하나로 라벨링한다.
|
|
43
|
+
2. **(a) 날조 → 삭제.** 저장 근거가 0인 회사·직함·수치·자격증은 문장째 삭제하거나, 저장된 실제 사실로 교체한다. 지어내서 채우지 않는다.
|
|
44
|
+
3. **(b) 과장 → 하향 정렬.** 직함·귀속·숙련도를 저장 데이터 수준으로 되돌린다(팀장→팀원, 주도→참여, 전문가→"3년 사용"). 표현은 강하게 두되 **사실 레벨만** 내린다.
|
|
45
|
+
4. **(c) 미검증 정밀도 → 일반화 또는 표식.** 근거 없는 "37%"는 (저장값이 있으면) 정확값으로 바꾸고, 없으면 정성 표현("크게 단축")으로 일반화하거나 `[확인 필요: 수치 출처]`로 표시한다. 추측 수치를 남기지 않는다.
|
|
46
|
+
5. **(d) 갭 은폐 → 정직한 표식 복원.** 지어낸 빈칸 채움을 `[확인 필요]`로 되돌리고, 사용자에게 해당 사실을 **쉬운 한국어로** 질문한다(직함·기술 용어 노출 금지). 사용자가 값을 주면 저장 도구로 보관 후 반영한다.
|
|
47
|
+
6. **재검증 게이트.** 수정본을 다시 C1~C10에 통과시킨다. critical 4종 중 하나라도 남으면 루프를 반복한다. 2회 반복 후에도 미앵커가 남으면, 해당 문장을 산출물에서 보류하고 사용자에게 확인을 요청한다(무한정 생성 금지).
|
|
48
|
+
7. **저장.** 통과한 산출물만 저장 도구(`save_cover_letter_version`·`save_fit_analysis`·`save_interview_prep` 등)로 보관한다. 검증 결과 요약(미앵커 0, traceability=1.0)을 산출물 메타에 함께 남긴다.
|
|
49
|
+
|
|
50
|
+
## 4. 출처 (Provenance)
|
|
51
|
+
|
|
52
|
+
- **STAR (Situation-Task-Action-Result)** — 행동을 검증 가능한 결과로 환원하는 표준 프레임워크. "성과를 과장하면 STAR로 풀어 쓰기 어려워 거짓이 드러난다"는 점에서 진실성 게이트와 직결. · Monster Career Advice, "How to Create a STAR Method Resume" https://www.monster.com/career-advice/resume/star-method-resume · Resume Genius, "STAR Method Resume" https://resumegenius.com/blog/resume-help/star-method-resume
|
|
53
|
+
- **정량화·임팩트 중심·"YOUR contribution(팀 아닌 본인)"** — 귀속 정확성(C4)과 정량 앵커링(C1)의 1차 근거. · Yale Office of Career Strategy, "Writing Impactful Resume Bullets" https://ocs.yale.edu/resources/writing-impactful-resume-bullets/ · Harvard FAS Mignone Center, "Create a Strong Resume" https://careerservices.fas.harvard.edu/resources/create-a-strong-resume/ · UC Berkeley Career Engagement, "Resumes" https://career.berkeley.edu/prepare-for-success/resumes/
|
|
54
|
+
- **윤리적 강화 vs 비윤리적 날조의 경계** — 직함 격상·경력 날조·성과 인플레·자격 위조를 비윤리로 명시. C2·C3·C5·C6의 근거. · MyResumee, "The Ethics of Resume Enhancements and Where to Draw the Line" https://www.myresumee.com/blog/ree-the-ethics-of-resume-enhancements-and-where-to-draw-the-line
|
|
55
|
+
- **이력서 거짓의 실태·적발·결과(통계)** — 검증 게이트의 필요성 근거. 2024 StandOutCV 설문 64.2%가 이력서에 거짓 기재 / 적발 시 41% 채용 취소·18% 해고. 거짓 유형: 미보유 스킬 숙련 주장(60%), 직함 변경(41%), 더 유명한 대학 주장(39%) → C5·C6 설계의 직접 근거. Gartner: 2028년까지 글로벌 지원자 프로필 4건 중 1건이 완전 가짜 전망. · Shortlister, "40+ Background Check Statistics in 2025" https://www.myshortlister.com/insights/background-check-statistics · Avvanz, "Resume Fraud in 2026" https://www.avvanz.com/blog/resume-fraud-2026-employer-prevention
|
|
56
|
+
- **CareerMate 프라임 디렉티브 / Fact-lock** — "두뇌는 연결된 AI, 저장은 CareerMate. 숫자·고유명사·직함은 입력 그대로 보존, 명시적 산술 파생만 허용, 모르면 `[확인 필요]`." C1·C7·C8의 내부 근거. · CareerMate `AGENTS.md`; `docs/career-os/knowledge/human-writing.md`(R7 Fact lock)
|
|
57
|
+
|
|
58
|
+
## 5. Phase B 힌트
|
|
59
|
+
|
|
60
|
+
- **MCP 도구 아이디어:**
|
|
61
|
+
- `get_verifier({ id: "truthfulness" })` — 이 루브릭(JSON: 항목·합격기준·det/ai 플래그·정규식 패턴·critical 등급)을 연결된 AI에게 그대로 serve.
|
|
62
|
+
- `get_fact_anchors({ scope })` — 저장 데이터에서 **앵커 집합**을 미리 추출해 제공: `{ numbers[], proper_nouns[](회사·기관·기술·인명), titles[], credentials[], timeline[] }`. C1·C2·C3·C5·C9의 화이트리스트로 직접 사용.
|
|
63
|
+
- `check_traceability({ artifact_id })` — det 항목(C7 갭표식 카운트, C10 추적성 비율, 정규식 수치/날짜 토큰 카운트)을 CareerMate가 LLM 없이 계산해 `{ N_total, N_unanchored, traceability, placeholder_delta }` 반환.
|
|
64
|
+
- **데이터 형태 힌트:**
|
|
65
|
+
- 앵커 집합은 `set`(정확매칭용)과 정규화 함수(콤마/단위/한글수사: `1,200ms==1200ms`, `12만==120000`)를 함께 제공해 거짓양성 감소.
|
|
66
|
+
- 산출물 메타에 `verification: { rubric: "truthfulness", critical_failures: 0, traceability: 1.0, checked_at }`를 저장해 상태 게이팅(`applied` 이상 전환 전 통과 필수) 및 대시보드 표시에 활용.
|
|
67
|
+
- 초안 버전 간 `placeholder_delta`(C7)를 추적하려면 `save_cover_letter_version`이 직전 버전 참조를 보관해야 한다(빈칸 치환 감지용).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "careermate",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"type": "module",
|
|
5
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.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -29,10 +29,18 @@
|
|
|
29
29
|
"apps",
|
|
30
30
|
"packages",
|
|
31
31
|
"scripts",
|
|
32
|
-
"docs",
|
|
33
32
|
"tsconfig.json",
|
|
34
33
|
"README.md",
|
|
35
|
-
"THIRD_PARTY_NOTICES.md"
|
|
34
|
+
"THIRD_PARTY_NOTICES.md",
|
|
35
|
+
"AGENTS.md",
|
|
36
|
+
"docs/career-os/README.md",
|
|
37
|
+
"docs/career-os/eop",
|
|
38
|
+
"docs/career-os/knowledge",
|
|
39
|
+
"docs/START_WORKFLOW.md",
|
|
40
|
+
"docs/WORKFLOWS.md",
|
|
41
|
+
"docs/MCP_TOOLS.md",
|
|
42
|
+
"docs/FAQ.md",
|
|
43
|
+
"docs/SUPPORTED_AI_APPS.md"
|
|
36
44
|
],
|
|
37
45
|
"engines": {
|
|
38
46
|
"node": ">=22.5.0"
|
|
@@ -859,7 +859,24 @@ export const activityRepo = {
|
|
|
859
859
|
*/
|
|
860
860
|
export function getEntityCounts(): Record<string, number> {
|
|
861
861
|
const db = getDb();
|
|
862
|
-
|
|
862
|
+
// Table names are hardcoded below, but the name is interpolated into SQL, so
|
|
863
|
+
// guard with an allowlist as defense-in-depth: a future caller can never turn
|
|
864
|
+
// this into an injection sink even if `t` were sourced from elsewhere.
|
|
865
|
+
const ALLOWED_TABLES = new Set([
|
|
866
|
+
'profile',
|
|
867
|
+
'experiences',
|
|
868
|
+
'projects',
|
|
869
|
+
'skills',
|
|
870
|
+
'documents',
|
|
871
|
+
'cover_letters',
|
|
872
|
+
'jobs',
|
|
873
|
+
'applications',
|
|
874
|
+
'interview_preps',
|
|
875
|
+
]);
|
|
876
|
+
const c = (t: string): number => {
|
|
877
|
+
if (!ALLOWED_TABLES.has(t)) throw new Error(`getEntityCounts: 허용되지 않은 테이블 이름: ${t}`);
|
|
878
|
+
return (db.prepare(`SELECT COUNT(*) AS n FROM ${t}`).get() as { n: number }).n;
|
|
879
|
+
};
|
|
863
880
|
return {
|
|
864
881
|
profile: c('profile'),
|
|
865
882
|
experiences: c('experiences'),
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
FitAnalysisInputSchema,
|
|
23
23
|
CoverLetterVersionInputSchema,
|
|
24
24
|
InterviewPrepInputSchema,
|
|
25
|
+
DocumentInputSchema,
|
|
25
26
|
APPLICATION_STATUSES,
|
|
26
27
|
APPLICATION_STATUS_LABELS,
|
|
27
28
|
DOCUMENT_KINDS,
|
|
@@ -272,13 +273,12 @@ export const TOOLS: ToolDef[] = [
|
|
|
272
273
|
title: '이력서/경력기술서 추가',
|
|
273
274
|
description:
|
|
274
275
|
'이력서, 경력기술서, 포트폴리오 등 문서를 저장합니다. content에는 사용자가 업로드/붙여넣은 텍스트(또는 정리한 Markdown)를 넣으세요. kind 기본값은 resume입니다. is_primary=true로 대표 문서를 지정하면 get_application_context의 primary_resume로 노출됩니다.',
|
|
276
|
+
// Reuse the shared (length-bounded, whitespace-rejecting) Document schema so
|
|
277
|
+
// the MCP ingestion path — the primary route for résumé text — inherits the
|
|
278
|
+
// same caps as the web API. kind stays optional here (defaults to resume).
|
|
275
279
|
inputSchema: {
|
|
276
|
-
|
|
277
|
-
content: z.string().describe('본문 (Markdown/일반 텍스트)'),
|
|
280
|
+
...DocumentInputSchema.omit({ kind: true }).shape,
|
|
278
281
|
kind: z.enum(DOCUMENT_KINDS).optional().describe('resume(이력서)/career_description(경력기술서)/portfolio/other'),
|
|
279
|
-
source: z.enum(CONTENT_SOURCES).optional(),
|
|
280
|
-
is_primary: z.boolean().optional().describe('대표 문서 여부'),
|
|
281
|
-
tags: z.array(z.string()).optional(),
|
|
282
282
|
},
|
|
283
283
|
handler: (args) => {
|
|
284
284
|
const doc = addResume(args);
|
|
@@ -427,9 +427,21 @@ async function finishPdf(buf: Buffer, base: BaseFn): Promise<ExtractDocumentResu
|
|
|
427
427
|
});
|
|
428
428
|
return base('pdf', { text, bytes: buf.length, warnings: pageWarnings });
|
|
429
429
|
} catch (e) {
|
|
430
|
+
// pdfjs throws a named PasswordException for password-protected PDFs — give
|
|
431
|
+
// a distinct, actionable hint instead of the generic "corrupted or encrypted".
|
|
432
|
+
const name = e && typeof e === 'object' && 'name' in e ? String((e as { name?: unknown }).name) : '';
|
|
433
|
+
if (name === 'PasswordException') {
|
|
434
|
+
return base('unsupported', {
|
|
435
|
+
bytes: buf.length,
|
|
436
|
+
warnings: [
|
|
437
|
+
'이 PDF는 암호(비밀번호)로 보호되어 있어 읽을 수 없습니다. PDF 뷰어에서 암호를 해제해 다시 저장하거나, 내용을 텍스트로 붙여넣어 주세요.',
|
|
438
|
+
],
|
|
439
|
+
unsupported: true,
|
|
440
|
+
});
|
|
441
|
+
}
|
|
430
442
|
return base('unsupported', {
|
|
431
443
|
bytes: buf.length,
|
|
432
|
-
warnings: [`PDF를 읽지 못했습니다(${errMsg(e)}).
|
|
444
|
+
warnings: [`PDF를 읽지 못했습니다(${errMsg(e)}). 손상되었거나 지원하지 않는 PDF일 수 있습니다. 텍스트로 붙여넣어 주세요.`],
|
|
433
445
|
unsupported: true,
|
|
434
446
|
});
|
|
435
447
|
}
|
|
@@ -35,12 +35,46 @@ function isSafeUrl(u: string): boolean {
|
|
|
35
35
|
return !/^[a-z][a-z0-9+.-]*:/i.test(s) || /^(https?:|mailto:)/i.test(s);
|
|
36
36
|
}
|
|
37
37
|
const UNSAFE_URL_MSG = 'javascript: 같은 위험한 링크는 사용할 수 없습니다. http(s) 링크를 사용하세요.';
|
|
38
|
-
const
|
|
38
|
+
const MAX_URL = 2_000;
|
|
39
|
+
const SafeUrl = z.string().min(1).max(MAX_URL).refine(isSafeUrl, { message: UNSAFE_URL_MSG });
|
|
39
40
|
const SafeUrlOptional = z
|
|
40
41
|
.string()
|
|
42
|
+
.max(MAX_URL)
|
|
41
43
|
.optional()
|
|
42
44
|
.refine((u) => u == null || u === '' || isSafeUrl(u), { message: UNSAFE_URL_MSG });
|
|
43
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Length-bounded string helpers. MCP/HTTP inputs are otherwise unbounded, so a
|
|
48
|
+
* malicious or buggy client could send multi-MB strings to exhaust memory/disk.
|
|
49
|
+
* These caps are generous for real résumés/cover letters but reject abuse.
|
|
50
|
+
* - LINE : single-line fields (names, titles, locations)
|
|
51
|
+
* - NOTE : short multi-line fields (summaries, notes)
|
|
52
|
+
* - BODY : full document bodies (resume / cover letter / job description)
|
|
53
|
+
* - ITEM : one array element; ITEMS : array length
|
|
54
|
+
*/
|
|
55
|
+
const MAX_LINE = 500;
|
|
56
|
+
const MAX_NOTE = 10_000;
|
|
57
|
+
const MAX_BODY = 200_000;
|
|
58
|
+
const MAX_ITEM = 2_000;
|
|
59
|
+
const MAX_ITEMS = 500;
|
|
60
|
+
const EMPTY_MSG = '내용이 비어 있습니다(공백만으로는 저장할 수 없습니다).';
|
|
61
|
+
|
|
62
|
+
/** Required single-line value: rejects whitespace-only, trims, bounds length. */
|
|
63
|
+
const reqLine = z.string().trim().min(1).max(MAX_LINE);
|
|
64
|
+
/** Optional single-line value (bounded). */
|
|
65
|
+
const optLine = z.string().max(MAX_LINE).optional();
|
|
66
|
+
/** Optional short multi-line note (bounded). */
|
|
67
|
+
const optNote = z.string().max(MAX_NOTE).optional();
|
|
68
|
+
/** Optional full document body (bounded). */
|
|
69
|
+
const optBody = z.string().max(MAX_BODY).optional();
|
|
70
|
+
/** Required full document body: bounded, rejects whitespace-only (no trim — preserve formatting). */
|
|
71
|
+
const reqBody = z
|
|
72
|
+
.string()
|
|
73
|
+
.max(MAX_BODY)
|
|
74
|
+
.refine((s) => s.trim().length > 0, { message: EMPTY_MSG });
|
|
75
|
+
/** Bounded array of bounded strings. */
|
|
76
|
+
const strList = z.array(z.string().max(MAX_ITEM)).max(MAX_ITEMS);
|
|
77
|
+
|
|
44
78
|
const baseRecord = {
|
|
45
79
|
id,
|
|
46
80
|
created_at: isoDateTime,
|
|
@@ -50,23 +84,23 @@ const baseRecord = {
|
|
|
50
84
|
/* ------------------------------------------------------------------ Profile */
|
|
51
85
|
|
|
52
86
|
export const LinkSchema = z.object({
|
|
53
|
-
label:
|
|
87
|
+
label: reqLine,
|
|
54
88
|
url: SafeUrl,
|
|
55
89
|
});
|
|
56
90
|
export type Link = z.infer<typeof LinkSchema>;
|
|
57
91
|
|
|
58
92
|
export const ProfileInputSchema = z.object({
|
|
59
|
-
name:
|
|
60
|
-
email:
|
|
61
|
-
phone:
|
|
62
|
-
location:
|
|
63
|
-
headline: z.string().optional().describe('한 줄 소개 / 직무 타이틀'),
|
|
64
|
-
summary: z.string().optional().describe('자기소개 요약'),
|
|
65
|
-
desired_roles:
|
|
66
|
-
desired_conditions: z.string().optional().describe('희망 근무 조건 (연봉, 지역, 근무형태 등)'),
|
|
67
|
-
preferred_tone: z.string().optional().describe('자기소개서 선호 문체 (예: 담백하고 구체적)'),
|
|
68
|
-
emphasis_points:
|
|
69
|
-
links: z.array(LinkSchema).optional().describe('포트폴리오/깃허브/링크드인 등'),
|
|
93
|
+
name: reqLine.optional(),
|
|
94
|
+
email: optLine,
|
|
95
|
+
phone: optLine,
|
|
96
|
+
location: optLine,
|
|
97
|
+
headline: z.string().max(MAX_LINE).optional().describe('한 줄 소개 / 직무 타이틀'),
|
|
98
|
+
summary: z.string().max(MAX_NOTE).optional().describe('자기소개 요약'),
|
|
99
|
+
desired_roles: strList.optional().describe('희망 직무'),
|
|
100
|
+
desired_conditions: z.string().max(MAX_NOTE).optional().describe('희망 근무 조건 (연봉, 지역, 근무형태 등)'),
|
|
101
|
+
preferred_tone: z.string().max(MAX_LINE).optional().describe('자기소개서 선호 문체 (예: 담백하고 구체적)'),
|
|
102
|
+
emphasis_points: strList.optional().describe('강조하고 싶은 핵심 포인트'),
|
|
103
|
+
links: z.array(LinkSchema).max(MAX_ITEMS).optional().describe('포트폴리오/깃허브/링크드인 등'),
|
|
70
104
|
});
|
|
71
105
|
export type ProfileInput = z.infer<typeof ProfileInputSchema>;
|
|
72
106
|
|
|
@@ -89,15 +123,15 @@ export type ProfileRecord = z.infer<typeof ProfileRecordSchema>;
|
|
|
89
123
|
/* --------------------------------------------------------------- Experience */
|
|
90
124
|
|
|
91
125
|
export const ExperienceInputSchema = z.object({
|
|
92
|
-
company:
|
|
93
|
-
role:
|
|
94
|
-
employment_type: z.string().optional().describe('정규직/계약직/인턴/프리랜서 등'),
|
|
95
|
-
start_date: z.string().optional().describe('YYYY-MM 또는 YYYY-MM-DD'),
|
|
96
|
-
end_date: z.string().optional().describe('재직 중이면 비워두기'),
|
|
126
|
+
company: reqLine,
|
|
127
|
+
role: optLine,
|
|
128
|
+
employment_type: z.string().max(MAX_LINE).optional().describe('정규직/계약직/인턴/프리랜서 등'),
|
|
129
|
+
start_date: z.string().max(MAX_LINE).optional().describe('YYYY-MM 또는 YYYY-MM-DD'),
|
|
130
|
+
end_date: z.string().max(MAX_LINE).optional().describe('재직 중이면 비워두기'),
|
|
97
131
|
is_current: z.boolean().optional(),
|
|
98
|
-
description:
|
|
99
|
-
achievements:
|
|
100
|
-
tech:
|
|
132
|
+
description: optBody,
|
|
133
|
+
achievements: strList.optional().describe('성과/업적 (정량 지표 권장)'),
|
|
134
|
+
tech: strList.optional().describe('사용 기술/도구'),
|
|
101
135
|
order_index: z.number().optional(),
|
|
102
136
|
});
|
|
103
137
|
export type ExperienceInput = z.infer<typeof ExperienceInputSchema>;
|
|
@@ -120,14 +154,14 @@ export type ExperienceRecord = z.infer<typeof ExperienceRecordSchema>;
|
|
|
120
154
|
/* ------------------------------------------------------------------ Project */
|
|
121
155
|
|
|
122
156
|
export const ProjectInputSchema = z.object({
|
|
123
|
-
name:
|
|
124
|
-
role:
|
|
125
|
-
description:
|
|
126
|
-
highlights:
|
|
127
|
-
tech:
|
|
157
|
+
name: reqLine,
|
|
158
|
+
role: optLine,
|
|
159
|
+
description: optBody,
|
|
160
|
+
highlights: strList.optional(),
|
|
161
|
+
tech: strList.optional(),
|
|
128
162
|
url: SafeUrlOptional,
|
|
129
|
-
start_date:
|
|
130
|
-
end_date:
|
|
163
|
+
start_date: optLine,
|
|
164
|
+
end_date: optLine,
|
|
131
165
|
order_index: z.number().optional(),
|
|
132
166
|
});
|
|
133
167
|
export type ProjectInput = z.infer<typeof ProjectInputSchema>;
|
|
@@ -149,9 +183,9 @@ export type ProjectRecord = z.infer<typeof ProjectRecordSchema>;
|
|
|
149
183
|
/* -------------------------------------------------------------------- Skill */
|
|
150
184
|
|
|
151
185
|
export const SkillInputSchema = z.object({
|
|
152
|
-
name:
|
|
153
|
-
category: z.string().optional().describe('언어/프레임워크/툴/소프트스킬 등'),
|
|
154
|
-
level: z.string().optional().describe('상/중/하 또는 자유 서술'),
|
|
186
|
+
name: reqLine,
|
|
187
|
+
category: z.string().max(MAX_LINE).optional().describe('언어/프레임워크/툴/소프트스킬 등'),
|
|
188
|
+
level: z.string().max(MAX_LINE).optional().describe('상/중/하 또는 자유 서술'),
|
|
155
189
|
years: z.number().optional(),
|
|
156
190
|
order_index: z.number().optional(),
|
|
157
191
|
});
|
|
@@ -171,11 +205,11 @@ export type SkillRecord = z.infer<typeof SkillRecordSchema>;
|
|
|
171
205
|
|
|
172
206
|
export const DocumentInputSchema = z.object({
|
|
173
207
|
kind: z.enum(DOCUMENT_KINDS),
|
|
174
|
-
title:
|
|
175
|
-
content:
|
|
208
|
+
title: reqLine,
|
|
209
|
+
content: reqBody.describe('Markdown 또는 일반 텍스트 본문'),
|
|
176
210
|
source: z.enum(CONTENT_SOURCES).optional(),
|
|
177
211
|
is_primary: z.boolean().optional().describe('대표 문서 여부'),
|
|
178
|
-
tags:
|
|
212
|
+
tags: strList.optional(),
|
|
179
213
|
});
|
|
180
214
|
export type DocumentInput = z.infer<typeof DocumentInputSchema>;
|
|
181
215
|
|
|
@@ -193,21 +227,21 @@ export type DocumentRecord = z.infer<typeof DocumentRecordSchema>;
|
|
|
193
227
|
/* -------------------------------------------------------------- CoverLetter */
|
|
194
228
|
|
|
195
229
|
export const CoverLetterInputSchema = z.object({
|
|
196
|
-
title:
|
|
197
|
-
job_id:
|
|
230
|
+
title: reqLine,
|
|
231
|
+
job_id: optLine.describe('특정 공고에 연결할 경우'),
|
|
198
232
|
is_primary: z.boolean().optional(),
|
|
199
|
-
content:
|
|
233
|
+
content: optBody.describe('초기 버전 본문 (있으면 v1로 저장)'),
|
|
200
234
|
source: z.enum(CONTENT_SOURCES).optional(),
|
|
201
|
-
note:
|
|
235
|
+
note: optNote.describe('초기 버전 메모'),
|
|
202
236
|
});
|
|
203
237
|
export type CoverLetterInput = z.infer<typeof CoverLetterInputSchema>;
|
|
204
238
|
|
|
205
239
|
export const CoverLetterVersionInputSchema = z.object({
|
|
206
|
-
cover_letter_id:
|
|
207
|
-
title: z.string().optional().describe('새 자기소개서를 만들 때 제목'),
|
|
208
|
-
job_id:
|
|
209
|
-
content:
|
|
210
|
-
note:
|
|
240
|
+
cover_letter_id: optLine.describe('기존 자기소개서에 새 버전 추가 시'),
|
|
241
|
+
title: z.string().max(MAX_LINE).optional().describe('새 자기소개서를 만들 때 제목'),
|
|
242
|
+
job_id: optLine,
|
|
243
|
+
content: reqBody,
|
|
244
|
+
note: optNote.describe('이 버전의 변경 요약 (예: 지원동기 보강)'),
|
|
211
245
|
source: z.enum(CONTENT_SOURCES).optional(),
|
|
212
246
|
set_current: z.boolean().optional().describe('이 버전을 현재 버전으로 지정 (기본 true)'),
|
|
213
247
|
});
|
|
@@ -239,16 +273,16 @@ export type CoverLetterRecord = z.infer<typeof CoverLetterRecordSchema>;
|
|
|
239
273
|
/* --------------------------------------------------------------------- Job */
|
|
240
274
|
|
|
241
275
|
export const JobInputSchema = z.object({
|
|
242
|
-
company:
|
|
243
|
-
position:
|
|
276
|
+
company: reqLine,
|
|
277
|
+
position: reqLine.describe('직무/포지션명'),
|
|
244
278
|
url: SafeUrlOptional,
|
|
245
|
-
location:
|
|
246
|
-
employment_type:
|
|
247
|
-
description:
|
|
248
|
-
requirements:
|
|
249
|
-
keywords:
|
|
250
|
-
deadline: z.string().optional().describe('마감일 YYYY-MM-DD'),
|
|
251
|
-
source: z.string().optional().describe('출처 (사람인/원티드/직접 입력 등)'),
|
|
279
|
+
location: optLine,
|
|
280
|
+
employment_type: optLine,
|
|
281
|
+
description: optBody.describe('공고 원문 또는 정리된 텍스트'),
|
|
282
|
+
requirements: strList.optional().describe('자격요건/우대사항 핵심'),
|
|
283
|
+
keywords: strList.optional().describe('핵심 키워드'),
|
|
284
|
+
deadline: z.string().max(MAX_LINE).optional().describe('마감일 YYYY-MM-DD'),
|
|
285
|
+
source: z.string().max(MAX_LINE).optional().describe('출처 (사람인/원티드/직접 입력 등)'),
|
|
252
286
|
});
|
|
253
287
|
export type JobInput = z.infer<typeof JobInputSchema>;
|
|
254
288
|
|
|
@@ -270,14 +304,14 @@ export type JobRecord = z.infer<typeof JobRecordSchema>;
|
|
|
270
304
|
/* ------------------------------------------------------------- FitAnalysis */
|
|
271
305
|
|
|
272
306
|
export const FitAnalysisInputSchema = z.object({
|
|
273
|
-
job_id:
|
|
307
|
+
job_id: reqLine.describe('분석 대상 공고 ID'),
|
|
274
308
|
score: z.number().min(0).max(100).optional().describe('종합 적합도 0~100'),
|
|
275
|
-
summary: z.string().optional().describe('한두 문단 요약'),
|
|
276
|
-
strengths:
|
|
277
|
-
gaps:
|
|
278
|
-
matched_keywords:
|
|
279
|
-
missing_keywords:
|
|
280
|
-
recommendations:
|
|
309
|
+
summary: z.string().max(MAX_NOTE).optional().describe('한두 문단 요약'),
|
|
310
|
+
strengths: strList.optional().describe('강점 / 잘 맞는 부분'),
|
|
311
|
+
gaps: strList.optional().describe('부족한 부분 / 보완 필요'),
|
|
312
|
+
matched_keywords: strList.optional(),
|
|
313
|
+
missing_keywords: strList.optional(),
|
|
314
|
+
recommendations: strList.optional().describe('자기소개서/지원 전략 제안'),
|
|
281
315
|
});
|
|
282
316
|
export type FitAnalysisInput = z.infer<typeof FitAnalysisInputSchema>;
|
|
283
317
|
|
|
@@ -297,20 +331,20 @@ export type FitAnalysisRecord = z.infer<typeof FitAnalysisRecordSchema>;
|
|
|
297
331
|
/* ------------------------------------------------------------- Application */
|
|
298
332
|
|
|
299
333
|
export const ApplicationInputSchema = z.object({
|
|
300
|
-
job_id:
|
|
334
|
+
job_id: reqLine,
|
|
301
335
|
status: z.enum(APPLICATION_STATUSES).optional(),
|
|
302
|
-
resume_id:
|
|
303
|
-
cover_letter_id:
|
|
304
|
-
applied_at:
|
|
305
|
-
notes:
|
|
336
|
+
resume_id: optLine,
|
|
337
|
+
cover_letter_id: optLine,
|
|
338
|
+
applied_at: optLine,
|
|
339
|
+
notes: optNote,
|
|
306
340
|
});
|
|
307
341
|
export type ApplicationInput = z.infer<typeof ApplicationInputSchema>;
|
|
308
342
|
|
|
309
343
|
export const ApplicationStatusUpdateSchema = z.object({
|
|
310
|
-
job_id:
|
|
311
|
-
application_id:
|
|
344
|
+
job_id: optLine,
|
|
345
|
+
application_id: optLine,
|
|
312
346
|
status: z.enum(APPLICATION_STATUSES),
|
|
313
|
-
note:
|
|
347
|
+
note: optNote.describe('상태 변경 사유/메모'),
|
|
314
348
|
});
|
|
315
349
|
export type ApplicationStatusUpdate = z.infer<typeof ApplicationStatusUpdateSchema>;
|
|
316
350
|
|
|
@@ -328,28 +362,28 @@ export type ApplicationRecord = z.infer<typeof ApplicationRecordSchema>;
|
|
|
328
362
|
/* ----------------------------------------------------------- InterviewPrep */
|
|
329
363
|
|
|
330
364
|
export const StarGuideSchema = z.object({
|
|
331
|
-
question: z.string(),
|
|
332
|
-
situation:
|
|
333
|
-
task:
|
|
334
|
-
action:
|
|
335
|
-
result:
|
|
365
|
+
question: z.string().max(MAX_NOTE),
|
|
366
|
+
situation: optNote,
|
|
367
|
+
task: optNote,
|
|
368
|
+
action: optNote,
|
|
369
|
+
result: optNote,
|
|
336
370
|
});
|
|
337
371
|
export type StarGuide = z.infer<typeof StarGuideSchema>;
|
|
338
372
|
|
|
339
373
|
export const InterviewQuestionSchema = z.object({
|
|
340
|
-
question: z.string(),
|
|
341
|
-
intent: z.string().optional().describe('질문 의도'),
|
|
342
|
-
followups:
|
|
343
|
-
answer_outline:
|
|
374
|
+
question: z.string().max(MAX_NOTE),
|
|
375
|
+
intent: z.string().max(MAX_NOTE).optional().describe('질문 의도'),
|
|
376
|
+
followups: strList.optional().describe('예상 꼬리 질문'),
|
|
377
|
+
answer_outline: optBody.describe('답변 가이드/핵심 포인트'),
|
|
344
378
|
});
|
|
345
379
|
export type InterviewQuestion = z.infer<typeof InterviewQuestionSchema>;
|
|
346
380
|
|
|
347
381
|
export const InterviewPrepInputSchema = z.object({
|
|
348
|
-
job_id:
|
|
349
|
-
questions: z.array(InterviewQuestionSchema).optional(),
|
|
350
|
-
star_guides: z.array(StarGuideSchema).optional(),
|
|
351
|
-
self_introduction:
|
|
352
|
-
notes:
|
|
382
|
+
job_id: reqLine,
|
|
383
|
+
questions: z.array(InterviewQuestionSchema).max(MAX_ITEMS).optional(),
|
|
384
|
+
star_guides: z.array(StarGuideSchema).max(MAX_ITEMS).optional(),
|
|
385
|
+
self_introduction: optBody.describe('1분 자기소개 초안'),
|
|
386
|
+
notes: optNote.describe('면접 후기/메모'),
|
|
353
387
|
});
|
|
354
388
|
export type InterviewPrepInput = z.infer<typeof InterviewPrepInputSchema>;
|
|
355
389
|
|
package/scripts/build-dist.mjs
CHANGED
|
@@ -73,7 +73,13 @@ async function main() {
|
|
|
73
73
|
const demoDir = path.join(ROOT, 'install-page', 'demo');
|
|
74
74
|
fs.cpSync(path.join(ROOT, 'install-page'), path.join(DIST, 'install-page'), {
|
|
75
75
|
recursive: true,
|
|
76
|
-
filter: (src) =>
|
|
76
|
+
filter: (src) => {
|
|
77
|
+
const base = path.basename(src);
|
|
78
|
+
// OMC/OMX 오케스트레이션 상태(세션ID·리플레이 로그)는 사용자에게 불필요하고
|
|
79
|
+
// 게시 tarball로 새면 안 된다. install-page에 남아 있어도 dist→npm에는 제외한다.
|
|
80
|
+
if (base === '.omc' || base === '.omx') return false;
|
|
81
|
+
return base !== 'careermate.mcpb' && src !== demoDir;
|
|
82
|
+
},
|
|
77
83
|
});
|
|
78
84
|
|
|
79
85
|
// pdfjs 워커 사이드카: pdf.mjs는 번들에 인라인되지만, 워커는 런타임에 별도 파일로
|
package/scripts/gen-og.mjs
CHANGED
|
@@ -46,7 +46,7 @@ const ogHtml = `<!doctype html><meta charset="utf-8"><style>
|
|
|
46
46
|
<div class="h">내 <span class="accent">AI</span>를<br>나만의 <span class="accent">커리어 비서</span>로</div>
|
|
47
47
|
<div class="sub">이력서·자기소개서·지원 현황을 이 컴퓨터 안에서 관리하세요.<br>분석·글쓰기는 평소 쓰던 AI가, 데이터 보관은 CareerMate가.</div>
|
|
48
48
|
</div>
|
|
49
|
-
<div class="pill"><span class="dot"></span>로컬 전용 · MCP ·
|
|
49
|
+
<div class="pill"><span class="dot"></span>로컬 전용 · MCP · 무료</div>
|
|
50
50
|
</div>`;
|
|
51
51
|
|
|
52
52
|
const iconHtml = (size) => `<!doctype html><meta charset="utf-8"><style>
|
package/scripts/test.ts
CHANGED
|
@@ -107,6 +107,31 @@ ok('MCP가 API의 수정(한 줄 소개)을 즉시 봄 (←)', pr.profileHeadlin
|
|
|
107
107
|
ok('MCP save_job_posting', !!pr.jobId);
|
|
108
108
|
ok('대시보드(API)가 MCP가 저장한 공고를 봄 (→)', (await json('GET', '/api/jobs')).jobs.some((j: any) => j.id === pr.jobId));
|
|
109
109
|
|
|
110
|
+
/* --------------------------------------------- 5. 내보내기 HTML 인젝션 차단 (회귀) */
|
|
111
|
+
// Cover-letter/resume bodies are user/agent text rendered into exported .html
|
|
112
|
+
// (opened in a browser → live XSS surface). markdownToHtml MUST neutralize raw
|
|
113
|
+
// HTML and dangerous link schemes. These assertions lock that property so a
|
|
114
|
+
// future refactor of the markdown renderer can't silently reintroduce XSS.
|
|
115
|
+
section('5) 내보내기 HTML 인젝션 차단 (회귀)');
|
|
116
|
+
const { markdownToHtml } = await import('../packages/exporters/src/markdown.ts');
|
|
117
|
+
const { toPrintableHtml } = await import('../packages/exporters/src/html.ts');
|
|
118
|
+
|
|
119
|
+
const imgPayload = markdownToHtml('<img src=x onerror="alert(1)">');
|
|
120
|
+
ok('raw <img onerror> 무력화', !imgPayload.includes('<img') && imgPayload.includes('<img') && !imgPayload.includes('onerror="alert'));
|
|
121
|
+
|
|
122
|
+
const scriptPayload = markdownToHtml('<script>alert(1)</script>');
|
|
123
|
+
ok('raw <script> 무력화', !scriptPayload.includes('<script>') && scriptPayload.includes('<script>'));
|
|
124
|
+
|
|
125
|
+
const jsLink = markdownToHtml('[클릭](javascript:alert(1))');
|
|
126
|
+
ok('javascript: 링크 차단(→ #)', jsLink.includes('href="#"') && !jsLink.toLowerCase().includes('javascript:'));
|
|
127
|
+
|
|
128
|
+
// Attribute-breakout attempt: a quote inside an otherwise-valid https URL.
|
|
129
|
+
const breakout = markdownToHtml('[x](https://a.com" onmouseover="alert(1))');
|
|
130
|
+
ok('링크 속성 탈출 차단', !breakout.includes('onmouseover="alert') && breakout.includes('"'));
|
|
131
|
+
|
|
132
|
+
const titleInj = toPrintableHtml('</title><script>alert(1)</script>', '본문');
|
|
133
|
+
ok('내보내기 제목 인젝션 차단', !titleInj.includes('<script>') && !titleInj.includes('</title><script>'));
|
|
134
|
+
|
|
110
135
|
console.log(`\n${'='.repeat(40)}`);
|
|
111
136
|
console.log(`결과: ${pass} 통과 · ${fail} 실패`);
|
|
112
137
|
console.log(fail === 0 ? '✅ 전체 통과' : '❌ 실패 있음');
|
package/scripts/ui-smoke.ts
CHANGED
|
@@ -25,18 +25,20 @@ saveCoverLetterVersion({ job_id: job.id, title: '자소서', content: '내용 v1
|
|
|
25
25
|
updateApplicationStatus(job.id, 'document_passed');
|
|
26
26
|
saveInterviewPrep({ job_id: job.id, questions: [{ question: '질문?' }], self_introduction: '소개' });
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
// Skip gracefully if either the package OR the browser binary is missing — both
|
|
29
|
+
// are optional dev deps, and a confusing uncaught launch error helps no one.
|
|
30
|
+
let browser: any;
|
|
29
31
|
try {
|
|
30
|
-
|
|
32
|
+
const { chromium } = await import('playwright');
|
|
33
|
+
browser = await chromium.launch();
|
|
31
34
|
} catch {
|
|
32
|
-
console.log('⚠️ Playwright가
|
|
35
|
+
console.log('⚠️ Playwright(또는 Chromium 브라우저)가 없어 UI 스모크를 건너뜁니다.');
|
|
33
36
|
console.log(' 설치: npm i -D playwright && npx playwright install chromium');
|
|
34
37
|
process.exit(0);
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
const { port, server } = await startServer(46050);
|
|
38
41
|
const base = `http://127.0.0.1:${port}`;
|
|
39
|
-
const browser = await chromium.launch();
|
|
40
42
|
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
|
41
43
|
|
|
42
44
|
const errors: string[] = [];
|
|
@@ -62,6 +64,32 @@ for (const [name, hash] of routes) {
|
|
|
62
64
|
|
|
63
65
|
if (errors.length) { console.log('\n콘솔/페이지 오류:'); errors.forEach((e) => console.log(' ' + e)); }
|
|
64
66
|
failures += errors.length;
|
|
67
|
+
|
|
68
|
+
// Responsive: every page must fit its viewport with no horizontal overflow at
|
|
69
|
+
// a laptop (1366×768) and a small phone (390×844). A page wider than the
|
|
70
|
+
// viewport means a broken layout (off-screen content, horizontal scrollbar).
|
|
71
|
+
// 1px tolerance for sub-pixel rounding.
|
|
72
|
+
const viewports: [string, number, number][] = [
|
|
73
|
+
['laptop-1366', 1366, 768],
|
|
74
|
+
['phone-390', 390, 844],
|
|
75
|
+
];
|
|
76
|
+
for (const [vpName, width, height] of viewports) {
|
|
77
|
+
await page.setViewportSize({ width, height });
|
|
78
|
+
for (const [name, hash] of routes) {
|
|
79
|
+
route = `${vpName}/${name}`;
|
|
80
|
+
await page.goto(base + '/' + hash, { waitUntil: 'networkidle' });
|
|
81
|
+
await page.waitForTimeout(250);
|
|
82
|
+
// Runs in the browser; reach DOM globals via globalThis so this node-libbed
|
|
83
|
+
// file still typechecks (tsc has no 'dom' lib here).
|
|
84
|
+
const overflow: number = await page.evaluate(() => {
|
|
85
|
+
const w = globalThis as any;
|
|
86
|
+
return Math.max(w.document.documentElement.scrollWidth, w.document.body.scrollWidth) - w.innerWidth;
|
|
87
|
+
});
|
|
88
|
+
const ok = overflow <= 1;
|
|
89
|
+
if (!ok) failures++;
|
|
90
|
+
console.log(` ${ok ? '✅' : '❌'} ${vpName}/${name.padEnd(11)} overflow=${overflow}px`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
65
93
|
console.log(`\n${failures === 0 ? '✅ UI 스모크 테스트 통과' : `❌ 실패 ${failures}건`}`);
|
|
66
94
|
console.log(failures === 0 ? 'UI_VERDICT PASS' : `UI_VERDICT FAIL ${failures}`);
|
|
67
95
|
|
package/dist/install-page/.omc/state/agent-replay-55a58c0d-bb6e-43ea-8a05-4b6c93b80b34.jsonl
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
{"t":0,"agent":"a10f819","agent_type":"workflow-subagent","event":"agent_start","parent_mode":"none"}
|
|
2
|
-
{"t":0,"agent":"a10f819","agent_type":"workflow-subagent","event":"agent_stop","success":true,"duration_ms":143102}
|
|
3
|
-
{"t":0,"agent":"aca9cea","agent_type":"workflow-subagent","event":"agent_start","parent_mode":"none"}
|
|
4
|
-
{"t":0,"agent":"a4768e2","agent_type":"workflow-subagent","event":"agent_start","parent_mode":"none"}
|
|
5
|
-
{"t":0,"agent":"a349ced","agent_type":"workflow-subagent","event":"agent_start","parent_mode":"none"}
|
|
6
|
-
{"t":0,"agent":"a349ced","agent_type":"workflow-subagent","event":"agent_stop","success":true,"duration_ms":60038}
|
|
7
|
-
{"t":0,"agent":"a4768e2","agent_type":"workflow-subagent","event":"agent_stop","success":true,"duration_ms":114215}
|
|
8
|
-
{"t":0,"agent":"aca9cea","agent_type":"workflow-subagent","event":"agent_stop","success":true,"duration_ms":121159}
|
|
9
|
-
{"t":0,"agent":"acc5653","agent_type":"unknown","event":"agent_stop","success":true}
|
|
10
|
-
{"t":0,"agent":"a0edd5d","agent_type":"unknown","event":"agent_stop","success":true}
|
|
11
|
-
{"t":0,"agent":"a245f43","agent_type":"unknown","event":"agent_stop","success":true}
|
|
12
|
-
{"t":0,"agent":"aefa1c0","agent_type":"unknown","event":"agent_stop","success":true}
|