careermate 0.0.6 → 0.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +5 -3
- package/README.md +30 -10
- package/THIRD_PARTY_NOTICES.md +2 -1
- package/dist/career-os/README.md +4 -3
- package/dist/career-os/eop/career-description.md +3 -0
- package/dist/career-os/eop/cover-letter.md +8 -4
- package/dist/career-os/eop/fit-analysis.md +1 -1
- package/dist/career-os/eop/job-analysis.md +1 -1
- package/dist/career-os/knowledge/cover-letter.md +4 -3
- package/dist/career-os/knowledge/fit-matching.md +5 -3
- package/dist/career-os/knowledge/human-writing.md +2 -0
- package/dist/career-os/knowledge/verifiers/ai-detection-risk.md +57 -0
- package/dist/career-os/knowledge/verifiers/ats-compat.md +1 -1
- package/dist/career-os/knowledge/verifiers/human-voice.md +2 -0
- package/dist/career-os/knowledge/verifiers/responsiveness-on-target.md +1 -1
- package/dist/career-os/knowledge/verifiers/truthfulness.md +1 -0
- package/dist/doctor.mjs +19012 -809
- package/dist/mcp.mjs +19027 -824
- 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 +18548 -649
- package/docs/MCP_TOOLS.md +19 -10
- package/docs/START_WORKFLOW.md +1 -1
- package/docs/career-os/README.md +4 -3
- package/docs/career-os/eop/career-description.md +3 -0
- package/docs/career-os/eop/cover-letter.md +8 -4
- package/docs/career-os/eop/fit-analysis.md +1 -1
- package/docs/career-os/eop/job-analysis.md +1 -1
- package/docs/career-os/knowledge/cover-letter.md +4 -3
- package/docs/career-os/knowledge/fit-matching.md +5 -3
- package/docs/career-os/knowledge/human-writing.md +2 -0
- package/docs/career-os/knowledge/verifiers/ai-detection-risk.md +57 -0
- package/docs/career-os/knowledge/verifiers/ats-compat.md +1 -1
- package/docs/career-os/knowledge/verifiers/human-voice.md +2 -0
- package/docs/career-os/knowledge/verifiers/responsiveness-on-target.md +1 -1
- 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 +17 -5
- package/packages/mcp-tools/src/tools.ts +181 -59
- package/packages/prompts/src/system.ts +1 -0
- package/packages/shared/src/schemas.ts +64 -0
- package/scripts/init.ts +5 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Job search import — pull postings from key-free public job-board endpoints by
|
|
3
|
+
* keyword, so the AI can offer real listings the user can save into CareerMate.
|
|
4
|
+
*
|
|
5
|
+
* Scope & boundaries (deliberate):
|
|
6
|
+
* - INBOUND only. We fetch publicly visible postings; we send only the search
|
|
7
|
+
* keyword, never the user's stored data. (Privacy = no user data leaves; this
|
|
8
|
+
* does not violate it.)
|
|
9
|
+
* - No WAF bypass, no auth keys. These providers' own frontends call these JSON
|
|
10
|
+
* endpoints unauthenticated; a plain fetch suffices. We do NOT bundle a
|
|
11
|
+
* scraper/anti-bot evasion (that's the legally dangerous part — see
|
|
12
|
+
* .claude/work/research/research_0004). Sites requiring keys (Saramin oapi,
|
|
13
|
+
* 고용24) or that forbid it in robots.txt (JobKorea search) are excluded here.
|
|
14
|
+
* - Read-only w.r.t. the DB. This returns candidates; the AI shows them and the
|
|
15
|
+
* user picks which to save via save_job_posting (no auto-dumping into the DB).
|
|
16
|
+
*
|
|
17
|
+
* Providers are unofficial public endpoints — if a response shape changes, only
|
|
18
|
+
* the affected provider's parser needs updating (others keep working).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface JobSearchResult {
|
|
22
|
+
/** Provider id, e.g. 'wanted' | 'jumpit'. */
|
|
23
|
+
source: string;
|
|
24
|
+
company: string;
|
|
25
|
+
position: string;
|
|
26
|
+
/** Canonical posting URL the user can open. */
|
|
27
|
+
url: string;
|
|
28
|
+
location?: string;
|
|
29
|
+
/** Required experience, human-readable (e.g. '신입', '5~20년'). */
|
|
30
|
+
experience?: string;
|
|
31
|
+
/** Tech stack / tags when the provider exposes them. */
|
|
32
|
+
keywords?: string[];
|
|
33
|
+
/** Application deadline as YYYY-MM-DD when known. */
|
|
34
|
+
deadline?: string;
|
|
35
|
+
/** Provider-native id (for dedup / detail fetch). */
|
|
36
|
+
external_id: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ProviderOutcome {
|
|
40
|
+
source: string;
|
|
41
|
+
ok: boolean;
|
|
42
|
+
count: number;
|
|
43
|
+
error?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface JobSearchResponse {
|
|
47
|
+
query: string;
|
|
48
|
+
results: JobSearchResult[];
|
|
49
|
+
providers: ProviderOutcome[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface JobSearchOptions {
|
|
53
|
+
/** Which providers to query. Defaults to all registered. */
|
|
54
|
+
sources?: string[];
|
|
55
|
+
/** Max results per provider (1–50). Default 10. */
|
|
56
|
+
limit?: number;
|
|
57
|
+
/** Per-request timeout in ms. Default 10000. */
|
|
58
|
+
timeoutMs?: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const UA =
|
|
62
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36';
|
|
63
|
+
|
|
64
|
+
/** Slice an ISO timestamp / date string to YYYY-MM-DD; undefined if not a date. */
|
|
65
|
+
function toYmd(v: unknown): string | undefined {
|
|
66
|
+
if (typeof v !== 'string' || v.length < 10) return undefined;
|
|
67
|
+
const m = v.match(/^(\d{4}-\d{2}-\d{2})/);
|
|
68
|
+
return m ? m[1] : undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function getJson(url: string, timeoutMs: number): Promise<any> {
|
|
72
|
+
const ctrl = new AbortController();
|
|
73
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
74
|
+
try {
|
|
75
|
+
const res = await fetch(url, {
|
|
76
|
+
headers: { accept: 'application/json', 'user-agent': UA, 'accept-language': 'ko,en;q=0.8' },
|
|
77
|
+
signal: ctrl.signal,
|
|
78
|
+
});
|
|
79
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
80
|
+
return await res.json();
|
|
81
|
+
} finally {
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface Provider {
|
|
87
|
+
id: string;
|
|
88
|
+
label: string;
|
|
89
|
+
search(query: string, limit: number, timeoutMs: number): Promise<JobSearchResult[]>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Wanted (wanted.co.kr) — public v4 jobs JSON, no auth. IT/startup roles. */
|
|
93
|
+
const wanted: Provider = {
|
|
94
|
+
id: 'wanted',
|
|
95
|
+
label: '원티드',
|
|
96
|
+
async search(query, limit, timeoutMs) {
|
|
97
|
+
const url =
|
|
98
|
+
`https://www.wanted.co.kr/api/v4/jobs?country=kr&locations=all&years=-1` +
|
|
99
|
+
`&job_sort=job.latest_order&limit=${limit}&offset=0&query=${encodeURIComponent(query)}`;
|
|
100
|
+
const j = await getJson(url, timeoutMs);
|
|
101
|
+
const rows: any[] = Array.isArray(j?.data) ? j.data : [];
|
|
102
|
+
return rows.map((r) => {
|
|
103
|
+
const annual =
|
|
104
|
+
r.annual_from || r.annual_to
|
|
105
|
+
? `${r.annual_from ?? 0}~${r.annual_to ?? '?'}년`
|
|
106
|
+
: undefined;
|
|
107
|
+
return {
|
|
108
|
+
source: 'wanted',
|
|
109
|
+
company: r.company?.name ?? '',
|
|
110
|
+
position: r.position ?? '',
|
|
111
|
+
url: `https://www.wanted.co.kr/wd/${r.id}`,
|
|
112
|
+
location: r.address?.location ?? r.address?.full_location ?? undefined,
|
|
113
|
+
experience: annual,
|
|
114
|
+
deadline: toYmd(r.due_time),
|
|
115
|
+
external_id: String(r.id),
|
|
116
|
+
} as JobSearchResult;
|
|
117
|
+
});
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/** Jumpit (jumpit.co.kr) — public positions JSON, no auth. Dev roles, tech stacks. */
|
|
122
|
+
const jumpit: Provider = {
|
|
123
|
+
id: 'jumpit',
|
|
124
|
+
label: '점핏',
|
|
125
|
+
async search(query, limit, timeoutMs) {
|
|
126
|
+
const url =
|
|
127
|
+
`https://api.jumpit.co.kr/api/positions?sort=rsp_rate&page=1&keyword=${encodeURIComponent(query)}`;
|
|
128
|
+
const j = await getJson(url, timeoutMs);
|
|
129
|
+
const rows: any[] = Array.isArray(j?.result?.positions) ? j.result.positions : [];
|
|
130
|
+
return rows.slice(0, limit).map((p) => ({
|
|
131
|
+
source: 'jumpit',
|
|
132
|
+
company: p.companyName ?? '',
|
|
133
|
+
position: p.title ?? '',
|
|
134
|
+
url: `https://www.jumpit.co.kr/position/${p.id}`,
|
|
135
|
+
location: Array.isArray(p.locations) ? p.locations[0] : undefined,
|
|
136
|
+
experience: p.newcomer
|
|
137
|
+
? '신입'
|
|
138
|
+
: p.minCareer != null
|
|
139
|
+
? `${p.minCareer}~${p.maxCareer ?? p.minCareer}년`
|
|
140
|
+
: undefined,
|
|
141
|
+
keywords: Array.isArray(p.techStacks) ? p.techStacks : undefined,
|
|
142
|
+
deadline: toYmd(p.closedAt),
|
|
143
|
+
external_id: String(p.id),
|
|
144
|
+
} as JobSearchResult));
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const PROVIDERS: Provider[] = [wanted, jumpit];
|
|
149
|
+
|
|
150
|
+
/** Registered provider ids (key-free, no WAF bypass). */
|
|
151
|
+
export const JOB_SEARCH_SOURCES = PROVIDERS.map((p) => p.id);
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Search key-free public job boards by keyword. Each provider runs independently;
|
|
155
|
+
* one failing (network/timeout/shape change) does not sink the others. Results
|
|
156
|
+
* are returned for the AI to present — saving is a separate explicit step.
|
|
157
|
+
*/
|
|
158
|
+
export async function searchJobs(query: string, opts: JobSearchOptions = {}): Promise<JobSearchResponse> {
|
|
159
|
+
const q = (query ?? '').trim();
|
|
160
|
+
if (!q) return { query: '', results: [], providers: [] };
|
|
161
|
+
const limit = Math.min(50, Math.max(1, opts.limit ?? 10));
|
|
162
|
+
const timeoutMs = opts.timeoutMs ?? 10000;
|
|
163
|
+
const chosen = opts.sources?.length
|
|
164
|
+
? PROVIDERS.filter((p) => opts.sources!.includes(p.id))
|
|
165
|
+
: PROVIDERS;
|
|
166
|
+
|
|
167
|
+
const settled = await Promise.all(
|
|
168
|
+
chosen.map(async (p): Promise<{ outcome: ProviderOutcome; results: JobSearchResult[] }> => {
|
|
169
|
+
try {
|
|
170
|
+
const results = await p.search(q, limit, timeoutMs);
|
|
171
|
+
return { outcome: { source: p.id, ok: true, count: results.length }, results };
|
|
172
|
+
} catch (e) {
|
|
173
|
+
return {
|
|
174
|
+
outcome: { source: p.id, ok: false, count: 0, error: e instanceof Error ? e.message : String(e) },
|
|
175
|
+
results: [],
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
}),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
query: q,
|
|
183
|
+
results: settled.flatMap((s) => s.results),
|
|
184
|
+
providers: settled.map((s) => s.outcome),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -78,6 +78,22 @@ export function collectVerifyCorpus(jobId?: string | null): VerifyCorpus {
|
|
|
78
78
|
for (const s of skillRepo.list()) {
|
|
79
79
|
structured.push([s.name, s.level, s.years != null ? `${s.years}년` : ''].filter(Boolean).join(' '));
|
|
80
80
|
}
|
|
81
|
+
// Profile credentials (학점·어학·자격·수상) — so a cited GPA/score (3.8, TOEIC 920)
|
|
82
|
+
// traces to stored data and isn't hard-blocked as fabricated. Structured-only ⇒ advisory.
|
|
83
|
+
// Provenance matching is unit-keyed, and a purely numeric score is cited both bare
|
|
84
|
+
// ("920") and with the 점 counter ("920점"); emit both forms so neither false-fires.
|
|
85
|
+
const scoreForms = (s?: string): string =>
|
|
86
|
+
s && /^\d+(\.\d+)?$/.test(s.trim()) ? `${s.trim()} ${s.trim()}점` : (s ?? '');
|
|
87
|
+
const prof = profileRepo.get();
|
|
88
|
+
if (prof) {
|
|
89
|
+
for (const ed of prof.education) {
|
|
90
|
+
const gpa = ed.gpa ? `학점 ${ed.gpa}${ed.gpa_scale ? `/${ed.gpa_scale}` : ''}` : '';
|
|
91
|
+
structured.push([ed.school, ed.degree, ed.major, gpa, ed.status].filter(Boolean).join(' '));
|
|
92
|
+
}
|
|
93
|
+
for (const c of prof.certifications) structured.push([c.name, c.issuer, scoreForms(c.score)].filter(Boolean).join(' '));
|
|
94
|
+
for (const l of prof.language_scores) structured.push(`${l.test} ${scoreForms(l.score)}`);
|
|
95
|
+
for (const a of prof.awards) structured.push([a.title, a.issuer, a.description].filter(Boolean).join(' '));
|
|
96
|
+
}
|
|
81
97
|
const job = jobId ? jobRepo.get(jobId) : null;
|
|
82
98
|
const jobText = job ? [job.description, ...(job.requirements ?? []), ...(job.keywords ?? [])].filter(Boolean).join('\n') : '';
|
|
83
99
|
return { documents: documents.join('\n'), structured: structured.join('\n'), job: jobText };
|
|
@@ -280,7 +296,11 @@ export function saveCoverLetterVersion(input: CoverLetterVersionInput & { force?
|
|
|
280
296
|
const { coverLetter, version } = coverLetterRepo.addVersion({
|
|
281
297
|
cover_letter_id: input.cover_letter_id,
|
|
282
298
|
title: input.title,
|
|
283
|
-
|
|
299
|
+
// Pass job_id through verbatim — do NOT coerce undefined→null here. addVersion
|
|
300
|
+
// treats `undefined` as "leave the link alone" and `null` as "clear it"; the old
|
|
301
|
+
// `?? null` wiped a cover letter's job link whenever a new version was saved
|
|
302
|
+
// without re-specifying job_id (e.g. editing an existing letter).
|
|
303
|
+
job_id: input.job_id,
|
|
284
304
|
content: input.content,
|
|
285
305
|
note: input.note,
|
|
286
306
|
source: input.source ?? 'ai',
|
|
@@ -295,7 +315,9 @@ export function saveCoverLetterVersion(input: CoverLetterVersionInput & { force?
|
|
|
295
315
|
}
|
|
296
316
|
activityRepo.log(
|
|
297
317
|
'cover_letter_version_saved',
|
|
298
|
-
|
|
318
|
+
// Title already names the document (often ends in "자기소개서") — don't append the
|
|
319
|
+
// word again or it doubles ("…자기소개서 자기소개서 v2"). Title + version is enough.
|
|
320
|
+
`${coverLetter.title} v${version.version_no}를 저장했습니다.`,
|
|
299
321
|
'cover_letter',
|
|
300
322
|
coverLetter.id,
|
|
301
323
|
);
|
|
@@ -18,6 +18,10 @@ import {
|
|
|
18
18
|
interviewRepo,
|
|
19
19
|
fitRepo,
|
|
20
20
|
activityRepo,
|
|
21
|
+
skillRepo,
|
|
22
|
+
experienceRepo,
|
|
23
|
+
projectRepo,
|
|
24
|
+
timelineRepo,
|
|
21
25
|
} from '@careermate/db';
|
|
22
26
|
import { getOnboardingStatus } from './onboarding.ts';
|
|
23
27
|
|
|
@@ -160,3 +164,195 @@ export function getHomeSummary(now: Date = new Date()): HomeSummary {
|
|
|
160
164
|
export function listRecentActivity(limit = 20): ActivityRecord[] {
|
|
161
165
|
return activityRepo.recent(limit);
|
|
162
166
|
}
|
|
167
|
+
|
|
168
|
+
/* ----------------------------------------------------------- action digest */
|
|
169
|
+
|
|
170
|
+
// Follow-up is the #1 thing trackers drop (community research): applications go
|
|
171
|
+
// stale and the user forgets to nudge. The dashboard Home already surfaces this,
|
|
172
|
+
// but the AI agent — the primary interface — couldn't see it. getActionDigest
|
|
173
|
+
// exposes the same "what needs my attention now" intelligence to MCP, broadened
|
|
174
|
+
// past 'applied' to the other waiting states (post-pass scheduling, post-interview
|
|
175
|
+
// result). Read-only; thresholds are advisory nudges, not hard rules.
|
|
176
|
+
const POST_PASS_STALE_DAYS = 7; // document_passed → interview scheduling wait
|
|
177
|
+
const POST_INTERVIEW_STALE_DAYS = 7; // interview → result wait
|
|
178
|
+
|
|
179
|
+
export interface FollowupItem {
|
|
180
|
+
job: JobRecord;
|
|
181
|
+
status: ApplicationStatus;
|
|
182
|
+
status_label: string;
|
|
183
|
+
days_since: number;
|
|
184
|
+
reason: string;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface ActionDigest {
|
|
188
|
+
/** Server's today (YYYY-MM-DD) — the AI can't reliably know the current date. */
|
|
189
|
+
today: string;
|
|
190
|
+
followups: FollowupItem[];
|
|
191
|
+
deadlines: { job: JobRecord; status: ApplicationStatus; status_label: string; days_left: number }[];
|
|
192
|
+
interview_todo: { job: JobRecord; status_label: string }[];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* "What needs attention now" for the AI agent: stale applications awaiting a
|
|
197
|
+
* response (so the user can decide whether to follow up), upcoming/overdue
|
|
198
|
+
* deadlines, and passed stages still missing interview prep. All read-only.
|
|
199
|
+
*/
|
|
200
|
+
export function getActionDigest(now: Date = new Date()): ActionDigest {
|
|
201
|
+
const applications = applicationRepo.list();
|
|
202
|
+
const jobs = jobRepo.list();
|
|
203
|
+
const statusByJob = new Map(applications.map((a) => [a.job_id, a]));
|
|
204
|
+
|
|
205
|
+
const followups: FollowupItem[] = [];
|
|
206
|
+
for (const a of applications) {
|
|
207
|
+
const job = jobRepo.get(a.job_id);
|
|
208
|
+
if (!job) continue;
|
|
209
|
+
if (a.status === 'applied') {
|
|
210
|
+
const days = daysSinceTimestamp(a.applied_at, now);
|
|
211
|
+
if (days != null && days >= FOLLOWUP_STALE_DAYS)
|
|
212
|
+
followups.push({ job, status: a.status, status_label: APPLICATION_STATUS_LABELS[a.status], days_since: days, reason: `지원 후 ${days}일째 응답 없음 — 정중한 후속 문의를 고려할 시점` });
|
|
213
|
+
} else if (a.status === 'document_passed') {
|
|
214
|
+
const days = daysSinceTimestamp(a.updated_at, now);
|
|
215
|
+
if (days != null && days >= POST_PASS_STALE_DAYS)
|
|
216
|
+
followups.push({ job, status: a.status, status_label: APPLICATION_STATUS_LABELS[a.status], days_since: days, reason: `서류 합격 후 ${days}일째 면접 일정 대기` });
|
|
217
|
+
} else if (a.status === 'interview') {
|
|
218
|
+
const days = daysSinceTimestamp(a.updated_at, now);
|
|
219
|
+
if (days != null && days >= POST_INTERVIEW_STALE_DAYS)
|
|
220
|
+
followups.push({ job, status: a.status, status_label: APPLICATION_STATUS_LABELS[a.status], days_since: days, reason: `면접 후 ${days}일째 결과 대기` });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
followups.sort((a, b) => b.days_since - a.days_since);
|
|
224
|
+
|
|
225
|
+
const deadlines = jobs
|
|
226
|
+
.map((job) => {
|
|
227
|
+
const status = statusByJob.get(job.id)?.status ?? 'draft';
|
|
228
|
+
if (!DEADLINE_ACTIONABLE.includes(status)) return null;
|
|
229
|
+
const days_left = daysUntilDate(job.deadline, now);
|
|
230
|
+
if (days_left == null || days_left > DEADLINE_SOON_DAYS) return null;
|
|
231
|
+
return { job, status, status_label: APPLICATION_STATUS_LABELS[status], days_left };
|
|
232
|
+
})
|
|
233
|
+
.filter((x): x is { job: JobRecord; status: ApplicationStatus; status_label: string; days_left: number } => x !== null)
|
|
234
|
+
.sort((a, b) => a.days_left - b.days_left);
|
|
235
|
+
|
|
236
|
+
const interview_todo = applications
|
|
237
|
+
.filter((a) => INTERVIEW_UNLOCK_STATUSES.includes(a.status) && !interviewRepo.getByJob(a.job_id))
|
|
238
|
+
.map((a) => {
|
|
239
|
+
const job = jobRepo.get(a.job_id);
|
|
240
|
+
return job ? { job, status_label: APPLICATION_STATUS_LABELS[a.status] } : null;
|
|
241
|
+
})
|
|
242
|
+
.filter((x): x is { job: JobRecord; status_label: string } => x !== null);
|
|
243
|
+
|
|
244
|
+
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
|
245
|
+
return { today, followups, deadlines, interview_todo };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/* ---------------------------------------------------------- job pre-screen */
|
|
249
|
+
|
|
250
|
+
// "Should I even bother reading this JD?" — community research U3: ~70% of postings
|
|
251
|
+
// prove unsuitable after reading. This is a CHEAP, DETERMINISTIC keyword-overlap
|
|
252
|
+
// signal (same philosophy as charcount: count, don't judge). The real fit analysis
|
|
253
|
+
// is still the AI's job via save_fit_analysis — this only pre-filters by which job
|
|
254
|
+
// keywords the user's stored skills/tech already cover.
|
|
255
|
+
|
|
256
|
+
const normToken = (s: string): string =>
|
|
257
|
+
s.toLowerCase().trim().replace(/^[.,/()\s]+|[.,/()\s]+$/g, '');
|
|
258
|
+
|
|
259
|
+
export interface JobPrescreen {
|
|
260
|
+
job_id: string;
|
|
261
|
+
job_keywords: string[];
|
|
262
|
+
matched: string[];
|
|
263
|
+
missing: string[];
|
|
264
|
+
/** matched / total keywords (0..1); null when the job has no keywords to screen. */
|
|
265
|
+
coverage: number | null;
|
|
266
|
+
user_skill_count: number;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function prescreenJob(jobId: string): JobPrescreen | null {
|
|
270
|
+
const job = jobRepo.get(jobId);
|
|
271
|
+
if (!job) return null;
|
|
272
|
+
const userTokens = new Set<string>();
|
|
273
|
+
for (const s of skillRepo.list()) if (s.name) userTokens.add(normToken(s.name));
|
|
274
|
+
for (const e of experienceRepo.list()) for (const t of e.tech ?? []) userTokens.add(normToken(t));
|
|
275
|
+
for (const p of projectRepo.list()) for (const t of p.tech ?? []) userTokens.add(normToken(t));
|
|
276
|
+
userTokens.delete('');
|
|
277
|
+
|
|
278
|
+
const keywords = (job.keywords ?? []).filter((k) => k && k.trim());
|
|
279
|
+
const matched: string[] = [];
|
|
280
|
+
const missing: string[] = [];
|
|
281
|
+
for (const kw of keywords) {
|
|
282
|
+
const n = normToken(kw);
|
|
283
|
+
const hit =
|
|
284
|
+
userTokens.has(n) ||
|
|
285
|
+
// substring match only for tokens ≥3 chars (avoid "c"/"r" matching everything).
|
|
286
|
+
(n.length >= 3 && [...userTokens].some((u) => u.length >= 3 && (u.includes(n) || n.includes(u))));
|
|
287
|
+
(hit ? matched : missing).push(kw);
|
|
288
|
+
}
|
|
289
|
+
const coverage = keywords.length ? matched.length / keywords.length : null;
|
|
290
|
+
return { job_id: jobId, job_keywords: keywords, matched, missing, coverage, user_skill_count: userTokens.size };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/* ----------------------------------------------------------- pipeline stats */
|
|
294
|
+
|
|
295
|
+
// Application analytics (community U1: "what's my response rate?"). A TRUE
|
|
296
|
+
// ever-reached funnel, not a current-status snapshot: status transitions are
|
|
297
|
+
// logged to the timeline (application_status_changed events), so a job currently
|
|
298
|
+
// 'rejected' still counts toward the furthest stage it reached. Descriptive only
|
|
299
|
+
// — with small N these rates are not predictive; the tool surfaces that caveat.
|
|
300
|
+
const PROGRESSION: ApplicationStatus[] = ['applied', 'document_passed', 'interview', 'final_passed'];
|
|
301
|
+
|
|
302
|
+
export interface PipelineStats {
|
|
303
|
+
total: number;
|
|
304
|
+
/** Current-status snapshot (where each application sits right now). */
|
|
305
|
+
by_status: { status: ApplicationStatus; label: string; count: number }[];
|
|
306
|
+
/** Ever-reached counts (reconstructed from the status-change timeline). */
|
|
307
|
+
funnel: { applied: number; document_passed: number; interview: number; final_passed: number };
|
|
308
|
+
/** Pass-through rates relative to "ever applied" (percent, 1 d.p.); null when denominator is 0. */
|
|
309
|
+
rates: { document_pass: number | null; interview: number | null; offer: number | null };
|
|
310
|
+
outcomes: { final_passed: number; rejected: number; in_progress: number; on_hold: number };
|
|
311
|
+
note: string;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function getPipelineStats(): PipelineStats {
|
|
315
|
+
const applications = applicationRepo.list();
|
|
316
|
+
const by_status = (Object.keys(APPLICATION_STATUS_LABELS) as ApplicationStatus[]).map((status) => ({
|
|
317
|
+
status,
|
|
318
|
+
label: APPLICATION_STATUS_LABELS[status],
|
|
319
|
+
count: applications.filter((a) => a.status === status).length,
|
|
320
|
+
}));
|
|
321
|
+
|
|
322
|
+
let appliedN = 0, docN = 0, intN = 0, offerN = 0;
|
|
323
|
+
for (const a of applications) {
|
|
324
|
+
const reached = new Set<string>([a.status]);
|
|
325
|
+
if (a.applied_at) reached.add('applied');
|
|
326
|
+
for (const e of timelineRepo.listByJob(a.job_id)) {
|
|
327
|
+
if (e.type !== 'application_status_changed') continue;
|
|
328
|
+
const p = (e.payload ?? {}) as { status?: string; from_status?: string };
|
|
329
|
+
if (p.status) reached.add(p.status);
|
|
330
|
+
if (p.from_status) reached.add(p.from_status);
|
|
331
|
+
}
|
|
332
|
+
let maxStage = -1;
|
|
333
|
+
for (const s of reached) maxStage = Math.max(maxStage, PROGRESSION.indexOf(s as ApplicationStatus));
|
|
334
|
+
if (maxStage >= 0) appliedN++;
|
|
335
|
+
if (maxStage >= 1) docN++;
|
|
336
|
+
if (maxStage >= 2) intN++;
|
|
337
|
+
if (maxStage >= 3) offerN++;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const pct = (n: number, d: number): number | null => (d > 0 ? Math.round((n / d) * 1000) / 10 : null);
|
|
341
|
+
const cur = (s: ApplicationStatus) => applications.filter((a) => a.status === s).length;
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
total: applications.length,
|
|
345
|
+
by_status,
|
|
346
|
+
funnel: { applied: appliedN, document_passed: docN, interview: intN, final_passed: offerN },
|
|
347
|
+
rates: { document_pass: pct(docN, appliedN), interview: pct(intN, appliedN), offer: pct(offerN, appliedN) },
|
|
348
|
+
outcomes: {
|
|
349
|
+
final_passed: cur('final_passed'),
|
|
350
|
+
rejected: cur('rejected'),
|
|
351
|
+
in_progress: cur('applied') + cur('document_passed') + cur('interview'),
|
|
352
|
+
on_hold: cur('on_hold'),
|
|
353
|
+
},
|
|
354
|
+
note:
|
|
355
|
+
'단계별 도달 깔때기는 상태 변경 이력(타임라인)으로 복원한 실측치입니다(현재 rejected여도 거쳐간 최고 단계까지 집계). ' +
|
|
356
|
+
'비율은 설명용이며 표본이 작으면 예측력이 낮습니다 — 추세 참고로만 쓰세요.',
|
|
357
|
+
};
|
|
358
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic character counting for Korean cover letters — the thing a chatbot
|
|
3
|
+
* cannot do reliably. Korean 문항형 자소서 grade on per-question length limits, and
|
|
4
|
+
* the counting rule (with-space / no-space / byte) varies by company; getting it
|
|
5
|
+
* wrong is the single most common disqualification. Pure, LLM-free.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type CountMode = 'with_space' | 'no_space' | 'byte';
|
|
9
|
+
|
|
10
|
+
export interface CharCounts {
|
|
11
|
+
/** Code points incl. all whitespace. Uses [...text] so surrogate pairs (emoji) count as 1. */
|
|
12
|
+
withSpace: number;
|
|
13
|
+
/** Code points with every whitespace char (space/tab/newline) removed. */
|
|
14
|
+
noSpace: number;
|
|
15
|
+
/**
|
|
16
|
+
* UTF-8 byte length. NOTE: some legacy Korean forms count 2 bytes per Hangul
|
|
17
|
+
* syllable (EUC-KR/KS X 1001); modern web forms and most "byte" limits today are
|
|
18
|
+
* UTF-8 (3 bytes per Hangul). We report UTF-8 — the AI should confirm the posting's
|
|
19
|
+
* convention when a byte limit is given.
|
|
20
|
+
*/
|
|
21
|
+
byte: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CharLimitCheck {
|
|
25
|
+
mode: CountMode;
|
|
26
|
+
/** The count under the chosen mode. */
|
|
27
|
+
count: number;
|
|
28
|
+
min?: number;
|
|
29
|
+
max?: number;
|
|
30
|
+
/** 'no_limit' when neither min nor max is given. */
|
|
31
|
+
status: 'under' | 'ok' | 'over' | 'no_limit';
|
|
32
|
+
/** over: count−max ; under: min−count ; otherwise undefined. */
|
|
33
|
+
delta?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const utf8Bytes = (text: string): number => {
|
|
37
|
+
// Avoid a Node Buffer dependency in this pure module — count UTF-8 bytes directly.
|
|
38
|
+
let bytes = 0;
|
|
39
|
+
for (const ch of text) {
|
|
40
|
+
const cp = ch.codePointAt(0)!;
|
|
41
|
+
if (cp <= 0x7f) bytes += 1;
|
|
42
|
+
else if (cp <= 0x7ff) bytes += 2;
|
|
43
|
+
else if (cp <= 0xffff) bytes += 3;
|
|
44
|
+
else bytes += 4;
|
|
45
|
+
}
|
|
46
|
+
return bytes;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Count a string three ways. Deterministic, encoding-explicit. */
|
|
50
|
+
export function countChars(text: string): CharCounts {
|
|
51
|
+
const cps = [...text];
|
|
52
|
+
return {
|
|
53
|
+
withSpace: cps.length,
|
|
54
|
+
noSpace: cps.filter((c) => !/\s/.test(c)).length,
|
|
55
|
+
byte: utf8Bytes(text),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Pick the count for a mode (default with_space — AGENTS.md: 미지정 시 공백포함). */
|
|
60
|
+
export function countForMode(text: string, mode: CountMode = 'with_space'): number {
|
|
61
|
+
const c = countChars(text);
|
|
62
|
+
return mode === 'no_space' ? c.noSpace : mode === 'byte' ? c.byte : c.withSpace;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Check a text against an optional min/max under one counting mode. Advisory —
|
|
67
|
+
* the caller decides what to do with `over`/`under` (this never throws or blocks).
|
|
68
|
+
*/
|
|
69
|
+
export function checkCharLimit(
|
|
70
|
+
text: string,
|
|
71
|
+
opts: { min?: number; max?: number; mode?: CountMode } = {},
|
|
72
|
+
): CharLimitCheck {
|
|
73
|
+
const mode = opts.mode ?? 'with_space';
|
|
74
|
+
const count = countForMode(text, mode);
|
|
75
|
+
const hasMin = typeof opts.min === 'number';
|
|
76
|
+
const hasMax = typeof opts.max === 'number';
|
|
77
|
+
if (!hasMin && !hasMax) {
|
|
78
|
+
return { mode, count, status: 'no_limit' };
|
|
79
|
+
}
|
|
80
|
+
if (hasMax && count > opts.max!) {
|
|
81
|
+
return { mode, count, min: opts.min, max: opts.max, status: 'over', delta: count - opts.max! };
|
|
82
|
+
}
|
|
83
|
+
if (hasMin && count < opts.min!) {
|
|
84
|
+
return { mode, count, min: opts.min, max: opts.max, status: 'under', delta: opts.min! - count };
|
|
85
|
+
}
|
|
86
|
+
return { mode, count, min: opts.min, max: opts.max, status: 'ok' };
|
|
87
|
+
}
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
type NumberToken,
|
|
25
25
|
} from './normalize.ts';
|
|
26
26
|
import { STYLE_LEXICONS, countHits } from './lexicons.ts';
|
|
27
|
+
import { countChars, type CharCounts } from './charcount.ts';
|
|
27
28
|
|
|
28
29
|
export type Severity = 'critical' | 'warn' | 'info';
|
|
29
30
|
|
|
@@ -66,7 +67,10 @@ export interface ProvenanceResult {
|
|
|
66
67
|
|
|
67
68
|
export interface LintReport {
|
|
68
69
|
kind: 'cover_letter' | 'fit' | 'interview' | 'resume';
|
|
70
|
+
/** Normalized code-point length (kept for backward compat). For form length use charCounts. */
|
|
69
71
|
charCount: number;
|
|
72
|
+
/** Deterministic counts on the RAW text (what a submission form counts), three Korean conventions. */
|
|
73
|
+
charCounts: CharCounts;
|
|
70
74
|
/** Advisory style signals (never block). */
|
|
71
75
|
signals: Signal[];
|
|
72
76
|
provenance: ProvenanceResult;
|
|
@@ -225,6 +229,7 @@ export function lintArtifact(
|
|
|
225
229
|
return {
|
|
226
230
|
kind,
|
|
227
231
|
charCount: [...norm].length,
|
|
232
|
+
charCounts: countChars(text),
|
|
228
233
|
signals,
|
|
229
234
|
provenance,
|
|
230
235
|
blocking,
|