careermate 0.0.8 → 0.0.9
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 +2 -2
- package/LICENSE +1 -1
- package/README.md +78 -81
- package/bin/careermate.mjs +11 -2
- package/dist/career-os/eop/cover-letter.md +10 -4
- package/dist/career-os/eop/interview-prep.md +1 -0
- package/dist/career-os/knowledge/cover-letter.md +4 -0
- package/dist/career-os/knowledge/verifiers/human-voice.md +7 -2
- package/dist/doctor.mjs +888 -340
- package/dist/mcp.mjs +904 -350
- package/dist/migrate.mjs +208 -16
- package/dist/public/i18n/en/applications.js +19 -0
- package/dist/public/i18n/en/common.js +6 -0
- package/dist/public/i18n/en/jobs.js +17 -0
- package/dist/public/i18n/en/settings.js +5 -2
- package/dist/public/i18n/ko/applications.js +19 -0
- package/dist/public/i18n/ko/common.js +5 -0
- package/dist/public/i18n/ko/jobs.js +17 -0
- package/dist/public/i18n/ko/settings.js +5 -2
- package/dist/public/lib.js +29 -0
- package/dist/public/pages/applications.js +86 -10
- package/dist/public/pages/documents.js +16 -7
- package/dist/public/pages/home.js +12 -0
- package/dist/public/pages/interview.js +11 -4
- package/dist/public/pages/jobs.js +71 -1
- package/dist/public/pages/settings.js +2 -2
- package/dist/public/styles.css +13 -0
- package/dist/web.mjs +544 -199
- package/docs/FAQ.md +13 -3
- package/docs/MCP_TOOLS.md +22 -9
- package/docs/career-os/eop/cover-letter.md +10 -4
- package/docs/career-os/eop/interview-prep.md +1 -0
- package/docs/career-os/knowledge/cover-letter.md +4 -0
- package/docs/career-os/knowledge/verifiers/human-voice.md +7 -2
- package/package.json +3 -1
- package/packages/core/src/index.ts +1 -0
- package/packages/core/src/jobsearch.ts +6 -2
- package/packages/core/src/offers.ts +41 -0
- package/packages/core/src/services.ts +5 -1
- package/packages/core/src/summary.ts +245 -19
- package/packages/db/src/connection.ts +70 -2
- package/packages/db/src/index.ts +1 -0
- package/packages/db/src/repositories.ts +140 -8
- package/packages/db/src/schema.ts +140 -8
- package/packages/mcp-tools/src/result.ts +9 -0
- package/packages/mcp-tools/src/tools.ts +143 -20
- package/packages/parsers/src/documents.ts +7 -1
- package/packages/prompts/src/humanize.ts +4 -0
- package/packages/shared/src/schemas.ts +134 -0
- package/scripts/init.ts +2 -0
|
@@ -21,11 +21,14 @@ import {
|
|
|
21
21
|
type CoverLetterVersionRecord,
|
|
22
22
|
type JobInput,
|
|
23
23
|
type JobRecord,
|
|
24
|
+
type OfferInput,
|
|
25
|
+
type OfferRecord,
|
|
24
26
|
type FitAnalysisInput,
|
|
25
27
|
type FitAnalysisRecord,
|
|
26
28
|
type ApplicationInput,
|
|
27
29
|
type ApplicationRecord,
|
|
28
30
|
type ApplicationStatus,
|
|
31
|
+
type RejectedStage,
|
|
29
32
|
type InterviewPrepInput,
|
|
30
33
|
type InterviewPrepRecord,
|
|
31
34
|
type ActivityRecord,
|
|
@@ -659,6 +662,9 @@ function mapJob(r: any): JobRecord {
|
|
|
659
662
|
company_overview: r.company_overview ?? null,
|
|
660
663
|
talent_profile: r.talent_profile ?? null,
|
|
661
664
|
core_values: fromJson(r.core_values, []),
|
|
665
|
+
reputation: r.reputation ?? null,
|
|
666
|
+
questions: fromJson(r.questions, []),
|
|
667
|
+
schedule: fromJson(r.schedule, []),
|
|
662
668
|
created_at: r.created_at,
|
|
663
669
|
updated_at: r.updated_at,
|
|
664
670
|
};
|
|
@@ -691,7 +697,7 @@ export const jobRepo = {
|
|
|
691
697
|
if (existing) {
|
|
692
698
|
const m = { ...existing, ...input } as JobRecord;
|
|
693
699
|
db.prepare(
|
|
694
|
-
`UPDATE jobs SET company=?,position=?,url=?,location=?,employment_type=?,description=?,requirements=?,keywords=?,deadline=?,source=?,company_overview=?,talent_profile=?,core_values=?,updated_at=? WHERE id=?`,
|
|
700
|
+
`UPDATE jobs SET company=?,position=?,url=?,location=?,employment_type=?,description=?,requirements=?,keywords=?,deadline=?,source=?,company_overview=?,talent_profile=?,core_values=?,reputation=?,questions=?,schedule=?,updated_at=? WHERE id=?`,
|
|
695
701
|
).run(
|
|
696
702
|
m.company,
|
|
697
703
|
m.position,
|
|
@@ -706,6 +712,12 @@ export const jobRepo = {
|
|
|
706
712
|
m.company_overview ?? null,
|
|
707
713
|
m.talent_profile ?? null,
|
|
708
714
|
toJson(m.core_values ?? []),
|
|
715
|
+
m.reputation ?? null,
|
|
716
|
+
// questions omitted from input → m keeps existing.questions (preserved);
|
|
717
|
+
// provided → replaced. Same merge semantics as the other JSON fields.
|
|
718
|
+
toJson(m.questions ?? []),
|
|
719
|
+
// schedule follows the same preserve-on-omit merge (v11 전형 일정).
|
|
720
|
+
toJson(m.schedule ?? []),
|
|
709
721
|
ts,
|
|
710
722
|
existing.id,
|
|
711
723
|
);
|
|
@@ -713,8 +725,8 @@ export const jobRepo = {
|
|
|
713
725
|
}
|
|
714
726
|
const newJobId = id ?? newId('job_');
|
|
715
727
|
db.prepare(
|
|
716
|
-
`INSERT INTO jobs (id,company,position,url,location,employment_type,description,requirements,keywords,deadline,source,company_overview,talent_profile,core_values,created_at,updated_at)
|
|
717
|
-
VALUES (
|
|
728
|
+
`INSERT INTO jobs (id,company,position,url,location,employment_type,description,requirements,keywords,deadline,source,company_overview,talent_profile,core_values,reputation,questions,schedule,created_at,updated_at)
|
|
729
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
718
730
|
).run(
|
|
719
731
|
newJobId,
|
|
720
732
|
input.company,
|
|
@@ -730,6 +742,9 @@ export const jobRepo = {
|
|
|
730
742
|
input.company_overview ?? null,
|
|
731
743
|
input.talent_profile ?? null,
|
|
732
744
|
toJson(input.core_values ?? []),
|
|
745
|
+
input.reputation ?? null,
|
|
746
|
+
toJson(input.questions ?? []),
|
|
747
|
+
toJson(input.schedule ?? []),
|
|
733
748
|
ts,
|
|
734
749
|
ts,
|
|
735
750
|
);
|
|
@@ -744,6 +759,9 @@ export const jobRepo = {
|
|
|
744
759
|
db.prepare(`DELETE FROM applications WHERE job_id=?`).run(id);
|
|
745
760
|
db.prepare(`DELETE FROM interview_preps WHERE job_id=?`).run(id);
|
|
746
761
|
db.prepare(`DELETE FROM application_timeline_events WHERE job_id=?`).run(id);
|
|
762
|
+
// offers has ON DELETE CASCADE, but delete explicitly like the siblings above
|
|
763
|
+
// (consistent pattern + safe even if PRAGMA foreign_keys is ever off).
|
|
764
|
+
db.prepare(`DELETE FROM offers WHERE job_id=?`).run(id);
|
|
747
765
|
return db.prepare(`DELETE FROM jobs WHERE id=?`).run(id).changes > 0;
|
|
748
766
|
});
|
|
749
767
|
},
|
|
@@ -827,6 +845,8 @@ function mapApplication(r: any): ApplicationRecord {
|
|
|
827
845
|
cover_letter_id: r.cover_letter_id,
|
|
828
846
|
applied_at: r.applied_at,
|
|
829
847
|
notes: r.notes,
|
|
848
|
+
// 탈락 회고: 탈락이 아니거나 미기록이면 NULL(v10 이전 DB는 컬럼이 없어 undefined → null 정규화).
|
|
849
|
+
rejected_stage: r.rejected_stage ?? null,
|
|
830
850
|
created_at: r.created_at,
|
|
831
851
|
updated_at: r.updated_at,
|
|
832
852
|
};
|
|
@@ -869,25 +889,39 @@ export const applicationRepo = {
|
|
|
869
889
|
// A status change through upsert must respect the same lifecycle gate as setStatus.
|
|
870
890
|
if (input.status && input.status !== app.status) assertStatusTransition(app.status, input.status);
|
|
871
891
|
const m = { ...app, ...input } as ApplicationRecord;
|
|
892
|
+
// rejected_stage is only meaningful while status is 'rejected' (same invariant as
|
|
893
|
+
// setStatus): clear it on any transition back out, preserve it when staying
|
|
894
|
+
// rejected. ApplicationInput carries no rejected_stage, so m keeps the stored value.
|
|
895
|
+
const rejectedStageValue = m.status === 'rejected' ? m.rejected_stage ?? null : null;
|
|
872
896
|
getDb()
|
|
873
897
|
.prepare(
|
|
874
|
-
`UPDATE applications SET status=?,resume_id=?,cover_letter_id=?,applied_at=?,notes=?,updated_at=? WHERE id=?`,
|
|
898
|
+
`UPDATE applications SET status=?,resume_id=?,cover_letter_id=?,applied_at=?,notes=?,rejected_stage=?,updated_at=? WHERE id=?`,
|
|
875
899
|
)
|
|
876
900
|
// Coerce undefined → null: node:sqlite rejects undefined bindings, and a
|
|
877
901
|
// caller that spreads explicit `undefined` fields (e.g. updateApplicationStatus
|
|
878
902
|
// with a doc-less submission) would otherwise crash here mid-write.
|
|
879
|
-
.run(m.status, m.resume_id ?? null, m.cover_letter_id ?? null, m.applied_at ?? null, m.notes ?? null, now(), app.id);
|
|
903
|
+
.run(m.status, m.resume_id ?? null, m.cover_letter_id ?? null, m.applied_at ?? null, m.notes ?? null, rejectedStageValue, now(), app.id);
|
|
880
904
|
return this.get(app.id)!;
|
|
881
905
|
},
|
|
882
|
-
setStatus(
|
|
906
|
+
setStatus(
|
|
907
|
+
jobId: string,
|
|
908
|
+
status: ApplicationStatus,
|
|
909
|
+
note?: string,
|
|
910
|
+
rejectedStage?: RejectedStage,
|
|
911
|
+
): ApplicationRecord {
|
|
883
912
|
const app = this.ensure(jobId);
|
|
884
913
|
assertStatusTransition(app.status, status);
|
|
885
914
|
const appliedAt =
|
|
886
915
|
status === 'applied' && !app.applied_at ? now() : app.applied_at;
|
|
887
916
|
const notes = note ? [app.notes, note].filter(Boolean).join('\n') : app.notes;
|
|
917
|
+
// 탈락 회고: 'rejected'일 때만 단계를 저장한다. 명시 값이 오면 갱신하고, 없으면 기존 값을
|
|
918
|
+
// 보존해(같은 rejected에 메모만 추가하는 등) 이미 남긴 회고를 지우지 않는다. rejected에서 다른
|
|
919
|
+
// 상태로 되살아나면 NULL로 초기화한다(정합성 — 탈락이 아닌데 단계가 남아 있으면 안 됨).
|
|
920
|
+
const rejectedStageValue =
|
|
921
|
+
status === 'rejected' ? rejectedStage ?? app.rejected_stage ?? null : null;
|
|
888
922
|
getDb()
|
|
889
|
-
.prepare(`UPDATE applications SET status=?, applied_at=?, notes=?, updated_at=? WHERE id=?`)
|
|
890
|
-
.run(status, appliedAt, notes, now(), app.id);
|
|
923
|
+
.prepare(`UPDATE applications SET status=?, applied_at=?, notes=?, rejected_stage=?, updated_at=? WHERE id=?`)
|
|
924
|
+
.run(status, appliedAt, notes, rejectedStageValue, now(), app.id);
|
|
891
925
|
return this.get(app.id)!;
|
|
892
926
|
},
|
|
893
927
|
};
|
|
@@ -1035,6 +1069,102 @@ export const timelineRepo = {
|
|
|
1035
1069
|
},
|
|
1036
1070
|
};
|
|
1037
1071
|
|
|
1072
|
+
/* -------------------------------------------------------------------- Offers */
|
|
1073
|
+
|
|
1074
|
+
function mapOffer(r: any): OfferRecord {
|
|
1075
|
+
const base = r.base_salary ?? null;
|
|
1076
|
+
const bonus = r.bonus_amount ?? null;
|
|
1077
|
+
const welfare = r.welfare_amount ?? null;
|
|
1078
|
+
// Annual cash estimate = base + bonus + welfare (signing is one-time → excluded).
|
|
1079
|
+
// null when no numeric cash piece was provided (can't estimate).
|
|
1080
|
+
const hasAny = base != null || bonus != null || welfare != null;
|
|
1081
|
+
const total = hasAny ? (base ?? 0) + (bonus ?? 0) + (welfare ?? 0) : null;
|
|
1082
|
+
return {
|
|
1083
|
+
id: r.id,
|
|
1084
|
+
job_id: r.job_id,
|
|
1085
|
+
offer_status: r.offer_status ?? 'open',
|
|
1086
|
+
base_salary: base,
|
|
1087
|
+
bonus_amount: bonus,
|
|
1088
|
+
welfare_amount: welfare,
|
|
1089
|
+
signing_amount: r.signing_amount ?? null,
|
|
1090
|
+
equity_note: r.equity_note ?? null,
|
|
1091
|
+
comp_note: r.comp_note ?? null,
|
|
1092
|
+
work_arrangement: r.work_arrangement ?? null,
|
|
1093
|
+
contract_type: r.contract_type ?? null,
|
|
1094
|
+
accept_deadline: r.accept_deadline ?? null,
|
|
1095
|
+
ai_score: r.ai_score ?? null,
|
|
1096
|
+
verdict: r.verdict ?? null,
|
|
1097
|
+
notes: r.notes ?? null,
|
|
1098
|
+
total_comp_annual_est: total,
|
|
1099
|
+
created_at: r.created_at,
|
|
1100
|
+
updated_at: r.updated_at,
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
export const offerRepo = {
|
|
1105
|
+
getByJob(jobId: string): OfferRecord | null {
|
|
1106
|
+
const r = getDb().prepare(`SELECT * FROM offers WHERE job_id=?`).get(jobId);
|
|
1107
|
+
return r ? mapOffer(r) : null;
|
|
1108
|
+
},
|
|
1109
|
+
list(): OfferRecord[] {
|
|
1110
|
+
return (getDb().prepare(`SELECT * FROM offers ORDER BY ai_score IS NULL, ai_score DESC, updated_at DESC`).all() as any[]).map(mapOffer);
|
|
1111
|
+
},
|
|
1112
|
+
/** Upsert by job_id (one offer per job). Missing fields are preserved on update (partial-safe). */
|
|
1113
|
+
save(input: OfferInput): OfferRecord {
|
|
1114
|
+
const db = getDb();
|
|
1115
|
+
const existing = this.getByJob(input.job_id);
|
|
1116
|
+
const ts = now();
|
|
1117
|
+
const n = (v: number | undefined, prev: number | null | undefined) => (v ?? prev ?? null);
|
|
1118
|
+
const s = (v: string | undefined, prev: string | null | undefined) => (v ?? prev ?? null);
|
|
1119
|
+
if (existing) {
|
|
1120
|
+
db.prepare(
|
|
1121
|
+
`UPDATE offers SET offer_status=?,base_salary=?,bonus_amount=?,welfare_amount=?,signing_amount=?,equity_note=?,comp_note=?,work_arrangement=?,contract_type=?,accept_deadline=?,ai_score=?,verdict=?,notes=?,updated_at=? WHERE id=?`,
|
|
1122
|
+
).run(
|
|
1123
|
+
input.offer_status ?? existing.offer_status,
|
|
1124
|
+
n(input.base_salary, existing.base_salary),
|
|
1125
|
+
n(input.bonus_amount, existing.bonus_amount),
|
|
1126
|
+
n(input.welfare_amount, existing.welfare_amount),
|
|
1127
|
+
n(input.signing_amount, existing.signing_amount),
|
|
1128
|
+
s(input.equity_note, existing.equity_note),
|
|
1129
|
+
s(input.comp_note, existing.comp_note),
|
|
1130
|
+
s(input.work_arrangement, existing.work_arrangement),
|
|
1131
|
+
s(input.contract_type, existing.contract_type),
|
|
1132
|
+
s(input.accept_deadline, existing.accept_deadline),
|
|
1133
|
+
n(input.ai_score, existing.ai_score),
|
|
1134
|
+
s(input.verdict, existing.verdict),
|
|
1135
|
+
s(input.notes, existing.notes),
|
|
1136
|
+
ts,
|
|
1137
|
+
existing.id,
|
|
1138
|
+
);
|
|
1139
|
+
return this.getByJob(input.job_id)!;
|
|
1140
|
+
}
|
|
1141
|
+
const id = newId('ofr_');
|
|
1142
|
+
db.prepare(
|
|
1143
|
+
`INSERT INTO offers (id,job_id,offer_status,base_salary,bonus_amount,welfare_amount,signing_amount,equity_note,comp_note,work_arrangement,contract_type,accept_deadline,ai_score,verdict,notes,created_at,updated_at)
|
|
1144
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
1145
|
+
).run(
|
|
1146
|
+
id,
|
|
1147
|
+
input.job_id,
|
|
1148
|
+
input.offer_status ?? 'open',
|
|
1149
|
+
input.base_salary ?? null,
|
|
1150
|
+
input.bonus_amount ?? null,
|
|
1151
|
+
input.welfare_amount ?? null,
|
|
1152
|
+
input.signing_amount ?? null,
|
|
1153
|
+
input.equity_note ?? null,
|
|
1154
|
+
input.comp_note ?? null,
|
|
1155
|
+
input.work_arrangement ?? null,
|
|
1156
|
+
input.contract_type ?? null,
|
|
1157
|
+
input.accept_deadline ?? null,
|
|
1158
|
+
input.ai_score ?? null,
|
|
1159
|
+
input.verdict ?? null,
|
|
1160
|
+
input.notes ?? null,
|
|
1161
|
+
ts,
|
|
1162
|
+
ts,
|
|
1163
|
+
);
|
|
1164
|
+
return this.getByJob(input.job_id)!;
|
|
1165
|
+
},
|
|
1166
|
+
};
|
|
1167
|
+
|
|
1038
1168
|
/* ------------------------------------------------------------------ Counts */
|
|
1039
1169
|
|
|
1040
1170
|
/**
|
|
@@ -1058,6 +1188,7 @@ export function getEntityCounts(): Record<string, number> {
|
|
|
1058
1188
|
'applications',
|
|
1059
1189
|
'interview_preps',
|
|
1060
1190
|
'application_timeline_events',
|
|
1191
|
+
'offers',
|
|
1061
1192
|
]);
|
|
1062
1193
|
const c = (t: string): number => {
|
|
1063
1194
|
if (!ALLOWED_TABLES.has(t)) throw new Error(`getEntityCounts: 허용되지 않은 테이블 이름: ${t}`);
|
|
@@ -1074,5 +1205,6 @@ export function getEntityCounts(): Record<string, number> {
|
|
|
1074
1205
|
applications: c('applications'),
|
|
1075
1206
|
interview_preps: c('interview_preps'),
|
|
1076
1207
|
application_timeline_events: c('application_timeline_events'),
|
|
1208
|
+
offers: c('offers'),
|
|
1077
1209
|
};
|
|
1078
1210
|
}
|
|
@@ -5,6 +5,16 @@
|
|
|
5
5
|
* in `_meta`. Applying is idempotent and safe to run on every process start, so
|
|
6
6
|
* both the web server and the MCP server can call `migrate()` on boot and share
|
|
7
7
|
* the same database file without coordination.
|
|
8
|
+
*
|
|
9
|
+
* Crash safety (this DB is the user's only copy):
|
|
10
|
+
* - Each migration runs in its own transaction and `schema_version` is stamped
|
|
11
|
+
* inside that same transaction, so a step either fully applies (including its
|
|
12
|
+
* version bump) or fully rolls back — a process death mid-upgrade can never
|
|
13
|
+
* leave "applied but unrecorded" work that re-throws on the next boot.
|
|
14
|
+
* - `ALTER TABLE … ADD COLUMN` statements are skipped when the column already
|
|
15
|
+
* exists (SQLite has no IF NOT EXISTS for columns), so a DB damaged by the
|
|
16
|
+
* pre-hardening runner self-heals instead of crash-looping on boot.
|
|
17
|
+
* - A DB stamped by a NEWER CareerMate is left untouched (never stamped down).
|
|
8
18
|
*/
|
|
9
19
|
import type { DatabaseSync } from 'node:sqlite';
|
|
10
20
|
|
|
@@ -329,6 +339,67 @@ export const MIGRATIONS: string[] = [
|
|
|
329
339
|
`
|
|
330
340
|
ALTER TABLE interview_preps ADD COLUMN debrief TEXT NOT NULL DEFAULT '{}';
|
|
331
341
|
`,
|
|
342
|
+
|
|
343
|
+
// v7 — offer comparison (한국 총보상 필드) + company reputation research field on jobs.
|
|
344
|
+
// offers: one per job (UNIQUE job_id, mirrors interview_preps). Cash pieces are numeric (만원) so
|
|
345
|
+
// the server can deterministically sum an annual estimate; equity/other stay free-text (comp is
|
|
346
|
+
// messy). ai_score/verdict are written by the connected AI at save time (no LLM here) and rendered
|
|
347
|
+
// by the dashboard, so it never needs to call an LLM. jobs.reputation: AI-researched company
|
|
348
|
+
// reputation summary (same pattern as company_overview) — feeds offer verdicts + offer-evaluation.
|
|
349
|
+
`
|
|
350
|
+
CREATE TABLE IF NOT EXISTS offers (
|
|
351
|
+
id TEXT PRIMARY KEY,
|
|
352
|
+
job_id TEXT NOT NULL UNIQUE REFERENCES jobs(id) ON DELETE CASCADE,
|
|
353
|
+
base_salary INTEGER,
|
|
354
|
+
bonus_amount INTEGER,
|
|
355
|
+
welfare_amount INTEGER,
|
|
356
|
+
signing_amount INTEGER,
|
|
357
|
+
equity_note TEXT,
|
|
358
|
+
comp_note TEXT,
|
|
359
|
+
work_arrangement TEXT,
|
|
360
|
+
contract_type TEXT,
|
|
361
|
+
accept_deadline TEXT,
|
|
362
|
+
ai_score INTEGER,
|
|
363
|
+
verdict TEXT,
|
|
364
|
+
notes TEXT,
|
|
365
|
+
created_at TEXT NOT NULL,
|
|
366
|
+
updated_at TEXT NOT NULL
|
|
367
|
+
);
|
|
368
|
+
CREATE INDEX IF NOT EXISTS idx_offers_job ON offers(job_id);
|
|
369
|
+
ALTER TABLE jobs ADD COLUMN reputation TEXT;
|
|
370
|
+
`,
|
|
371
|
+
|
|
372
|
+
// v8 — offer lifecycle status. 지원 상태(전형 깔때기 = 회사의 프로세스)와 직교하는
|
|
373
|
+
// "내 응답" 축: open(진행)/accepted(수락)/declined(거절). declined여도 행을 지우지
|
|
374
|
+
// 않는다 — 조건(연봉·처우) 히스토리는 다음 협상의 자산이라, 대시보드가 접힌
|
|
375
|
+
// '지난 오퍼'로 강등해 보여줄 뿐이다. Additive ALTER (runner가 멱등 처리).
|
|
376
|
+
`
|
|
377
|
+
ALTER TABLE offers ADD COLUMN offer_status TEXT NOT NULL DEFAULT 'open';
|
|
378
|
+
`,
|
|
379
|
+
|
|
380
|
+
// v9 — 한국 문항형 자소서: 회사가 지정한 문항·글자수를 공고에 구조화 보관. 한국 자소서는
|
|
381
|
+
// 회사별 문항형(항목·글자수 제한)이 지배적이라, AI가 문항별로 답변을 쓰고 글자수를 점검할 때
|
|
382
|
+
// 참조할 원본을 공고 옆에 둔다(별도 엔티티가 아니라 job 컨텍스트에 함께 실린다). JSON 배열을
|
|
383
|
+
// TEXT에 저장(다른 JSON 필드와 동일). Additive ALTER (runner가 멱등 처리).
|
|
384
|
+
`
|
|
385
|
+
ALTER TABLE jobs ADD COLUMN questions TEXT NOT NULL DEFAULT '[]';
|
|
386
|
+
`,
|
|
387
|
+
|
|
388
|
+
// v10 — 탈락 회고: 어느 전형 단계에서 떨어졌는지 자가 기록. 탈락 사유를 고지하는 기업은 6%뿐이고
|
|
389
|
+
// 지원자 83%가 알고 싶어하므로, 이 자가 기록이 "서류는 붙는데 면접에서 떨어진다" 같은 패턴을 로컬
|
|
390
|
+
// 데이터로 드러내는 유일한 신호다. 탈락이 아니거나 미기록이면 NULL. Additive ALTER (runner가 멱등 처리).
|
|
391
|
+
`
|
|
392
|
+
ALTER TABLE applications ADD COLUMN rejected_stage TEXT;
|
|
393
|
+
`,
|
|
394
|
+
|
|
395
|
+
// v11 — 전형 일정: 서류마감→인적성·AI역검→1·2차 면접→발표로 이어지는 단계별 날짜를 공고에
|
|
396
|
+
// 구조화 보관. 일정을 jobs에 두는 이유: 서류마감(deadline)이 이미 jobs에 있고, save_job_posting
|
|
397
|
+
// 업서트를 그대로 재사용해 도구 신설 없이 갱신할 수 있다. 면접초대 메일·발표 공지를 사용자가
|
|
398
|
+
// 붙여넣으면 AI가 날짜를 추출해 저장하고, 서버가 D-day·같은 날짜 충돌을 결정론으로 계산한다.
|
|
399
|
+
// JSON 배열을 TEXT에 저장(다른 JSON 필드와 동일). Additive ALTER (runner가 멱등 처리).
|
|
400
|
+
`
|
|
401
|
+
ALTER TABLE jobs ADD COLUMN schedule TEXT NOT NULL DEFAULT '[]';
|
|
402
|
+
`,
|
|
332
403
|
];
|
|
333
404
|
|
|
334
405
|
export function migrate(db: DatabaseSync): { from: number; to: number } {
|
|
@@ -338,16 +409,77 @@ export function migrate(db: DatabaseSync): { from: number; to: number } {
|
|
|
338
409
|
| undefined;
|
|
339
410
|
const from = row ? Number(row.value) : 0;
|
|
340
411
|
|
|
412
|
+
// DB stamped by a newer CareerMate than this code: leave it alone. Stamping it
|
|
413
|
+
// back down would make the newer app re-run migrations it already applied.
|
|
414
|
+
if (from >= MIGRATIONS.length) return { from, to: from };
|
|
415
|
+
|
|
341
416
|
for (let v = from; v < MIGRATIONS.length; v++) {
|
|
342
|
-
db
|
|
417
|
+
applyMigration(db, v);
|
|
418
|
+
}
|
|
419
|
+
return { from, to: MIGRATIONS.length };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function stampVersion(db: DatabaseSync, version: number): void {
|
|
423
|
+
db.prepare(
|
|
424
|
+
`INSERT INTO _meta (key, value) VALUES ('schema_version', ?)
|
|
425
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
|
426
|
+
).run(String(version));
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function columnExists(db: DatabaseSync, table: string, column: string): boolean {
|
|
430
|
+
const rows = db
|
|
431
|
+
.prepare(`PRAGMA table_info("${table.replaceAll('"', '""')}")`)
|
|
432
|
+
.all() as Array<{ name: string }>;
|
|
433
|
+
return rows.some((r) => r.name === column);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Migrations that manage their own BEGIN/COMMIT, by zero-based index into MIGRATIONS.
|
|
437
|
+
// Only v2 (index 1) does: its FK rebuild toggles PRAGMA foreign_keys, a no-op inside
|
|
438
|
+
// an open transaction, so the runner must NOT wrap it. Register any future
|
|
439
|
+
// self-transactional migration here (a plain `sql.includes('BEGIN;')` sniff would
|
|
440
|
+
// misfire if a normal migration ever contained the substring, e.g. in a comment).
|
|
441
|
+
const SELF_TRANSACTIONAL_MIGRATIONS = new Set<number>([1]);
|
|
442
|
+
|
|
443
|
+
// Style contract for column-adding migrations: every `ALTER TABLE <t> ADD COLUMN <c>
|
|
444
|
+
// …;` MUST be a single statement on its own line so this regex can find it and the
|
|
445
|
+
// idempotent-runner can drop it when the column already exists. A multi-column or
|
|
446
|
+
// inline-with-other-DDL ALTER would slip past skipExistingColumns and crash a
|
|
447
|
+
// partial-migration re-run. scripts/test-migrate.ts guards this with a count check
|
|
448
|
+
// (loose ALTER…ADD COLUMN scan === strict per-line matches). Keep new ALTERs one-per-line.
|
|
449
|
+
const ALTER_ADD_COLUMN_RE = /^[ \t]*ALTER TABLE\s+(\w+)\s+ADD COLUMN\s+(\w+)[^;]*;[ \t]*$/gm;
|
|
450
|
+
export { ALTER_ADD_COLUMN_RE };
|
|
451
|
+
|
|
452
|
+
/** Drop ADD COLUMN statements whose column already exists (idempotent re-run). */
|
|
453
|
+
function skipExistingColumns(db: DatabaseSync, sql: string): string {
|
|
454
|
+
return sql.replace(ALTER_ADD_COLUMN_RE, (stmt, table: string, column: string) =>
|
|
455
|
+
columnExists(db, table, column) ? '' : stmt,
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function applyMigration(db: DatabaseSync, v: number): void {
|
|
460
|
+
const sql = MIGRATIONS[v]!;
|
|
461
|
+
|
|
462
|
+
if (SELF_TRANSACTIONAL_MIGRATIONS.has(v)) {
|
|
463
|
+
// Self-transactional migration (v2 toggles PRAGMA foreign_keys, which is a
|
|
464
|
+
// no-op inside an open transaction, so it must manage BEGIN/COMMIT itself).
|
|
465
|
+
// Stamp right after; if the process dies in the tiny window between the two
|
|
466
|
+
// statements, re-running the rebuild is safe (it copies the data again).
|
|
467
|
+
db.exec(sql);
|
|
468
|
+
stampVersion(db, v + 1);
|
|
469
|
+
return;
|
|
343
470
|
}
|
|
344
471
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
db.
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
472
|
+
db.exec('BEGIN');
|
|
473
|
+
try {
|
|
474
|
+
db.exec(skipExistingColumns(db, sql));
|
|
475
|
+
stampVersion(db, v + 1);
|
|
476
|
+
db.exec('COMMIT');
|
|
477
|
+
} catch (err) {
|
|
478
|
+
try {
|
|
479
|
+
db.exec('ROLLBACK');
|
|
480
|
+
} catch {
|
|
481
|
+
/* surface the original error */
|
|
482
|
+
}
|
|
483
|
+
throw err;
|
|
351
484
|
}
|
|
352
|
-
return { from, to };
|
|
353
485
|
}
|
|
@@ -22,6 +22,15 @@ export interface ToolDef {
|
|
|
22
22
|
inputSchema: ZodRawShape;
|
|
23
23
|
/** Hint flags for clients (readOnly tools don't mutate). */
|
|
24
24
|
readOnly?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Tool reaches outside the user's machine (job-board fetch, npm registry).
|
|
27
|
+
* Must be honest — the privacy story depends on these hints being true.
|
|
28
|
+
*/
|
|
29
|
+
openWorld?: boolean;
|
|
30
|
+
/** Irreversible effect (hard delete, package update). Default: non-destructive upsert. */
|
|
31
|
+
destructive?: boolean;
|
|
32
|
+
/** Repeating the same call with the same args has no additional effect (documented upserts). */
|
|
33
|
+
idempotent?: boolean;
|
|
25
34
|
handler: (args: any) => ToolResult | Promise<ToolResult>;
|
|
26
35
|
}
|
|
27
36
|
|