careermate 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/INSTALL.md +12 -8
  2. package/README.md +14 -8
  3. package/dist/career-os/knowledge/interview-behavioral.md +9 -1
  4. package/dist/career-os/knowledge/offer-evaluation-decision.md +2 -2
  5. package/dist/career-os/knowledge/onboarding-first-90-days.md +1 -1
  6. package/dist/career-os/knowledge/verifiers/human-voice.md +20 -0
  7. package/dist/doctor.mjs +421 -11
  8. package/dist/mcp.mjs +421 -11
  9. package/dist/migrate.mjs +3 -1
  10. package/dist/public/index.html +1 -0
  11. package/dist/public/pages/settings.js +45 -0
  12. package/dist/public/styles.css +2 -0
  13. package/dist/site/index.html +19 -12
  14. package/dist/site/llms-install.txt +11 -8
  15. package/dist/site/start.html +16 -9
  16. package/dist/site/style.css +9 -0
  17. package/dist/web.mjs +351 -6
  18. package/docs/SUPPORTED_AI_APPS.md +6 -6
  19. package/docs/career-os/knowledge/interview-behavioral.md +9 -1
  20. package/docs/career-os/knowledge/offer-evaluation-decision.md +2 -2
  21. package/docs/career-os/knowledge/onboarding-first-90-days.md +1 -1
  22. package/docs/career-os/knowledge/verifiers/human-voice.md +20 -0
  23. package/package.json +1 -1
  24. package/packages/core/src/index.ts +1 -0
  25. package/packages/core/src/services.ts +61 -2
  26. package/packages/core/src/verify/index.ts +11 -0
  27. package/packages/core/src/verify/lexicons.ts +149 -0
  28. package/packages/core/src/verify/lint.ts +248 -0
  29. package/packages/core/src/verify/normalize.ts +112 -0
  30. package/packages/db/src/index.ts +1 -0
  31. package/packages/db/src/settings.ts +41 -0
  32. package/packages/mcp-tools/src/tools.ts +80 -6
  33. package/packages/shared/src/schemas.ts +8 -0
@@ -41,7 +41,50 @@ import {
41
41
  projectRepo,
42
42
  skillRepo,
43
43
  activityRepo,
44
+ getVerifyStrict,
44
45
  } from '@careermate/db';
46
+ import { lintArtifact, type VerifyCorpus, type LintReport } from './verify/index.ts';
47
+
48
+ /**
49
+ * Collect the user's stored facts (zero-ai) in THREE separate scopes so the
50
+ * verify engine can tier a claim's provenance:
51
+ * - documents: the user's actual résumé/document TEXT (excl. AI-generated docs)
52
+ * — the strongest "user provided this" anchor → full support.
53
+ * - structured: experiences/projects/skills. CareerMate is MCP-first and the AI
54
+ * is the sole writer of these, so a number that lives ONLY here
55
+ * (and not in the document text) can't be verified → advisory.
56
+ * This de-silences the "fabricate a record then cite it" bypass.
57
+ * - job: the posting (can't license an applicant's performance claim).
58
+ */
59
+ export function collectVerifyCorpus(jobId?: string | null): VerifyCorpus {
60
+ const documents: string[] = [];
61
+ for (const d of documentRepo.list()) {
62
+ if (d.source !== 'ai') documents.push(d.content); // exclude AI-written docs
63
+ }
64
+ const structured: string[] = [];
65
+ for (const e of experienceRepo.list()) {
66
+ structured.push([e.company, e.role, e.description, ...(e.achievements ?? []), ...(e.tech ?? [])].filter(Boolean).join('\n'));
67
+ }
68
+ for (const p of projectRepo.list()) {
69
+ structured.push([p.name, p.description, ...(p.highlights ?? []), ...(p.tech ?? [])].filter(Boolean).join('\n'));
70
+ }
71
+ for (const s of skillRepo.list()) {
72
+ structured.push([s.name, s.level, s.years != null ? `${s.years}년` : ''].filter(Boolean).join(' '));
73
+ }
74
+ const job = jobId ? jobRepo.get(jobId) : null;
75
+ const jobText = job ? [job.description, ...(job.requirements ?? []), ...(job.keywords ?? [])].filter(Boolean).join('\n') : '';
76
+ return { documents: documents.join('\n'), structured: structured.join('\n'), job: jobText };
77
+ }
78
+
79
+ /**
80
+ * Read-only preview of the deterministic cover-letter checks (no save).
81
+ * `strict` overrides the persisted mode for this one call (one-shot strict).
82
+ */
83
+ export function previewCoverLetter(text: string, jobId?: string | null, strict?: boolean): LintReport {
84
+ return lintArtifact('cover_letter', text, collectVerifyCorpus(jobId), {
85
+ strict: strict ?? getVerifyStrict(),
86
+ });
87
+ }
45
88
 
46
89
  /* ----------------------------------------------------------------- Profile */
47
90
 
@@ -107,7 +150,23 @@ export function saveFitAnalysis(input: FitAnalysisInput): FitAnalysisRecord {
107
150
 
108
151
  /* ----------------------------------------------------------- Cover letters */
109
152
 
110
- export function saveCoverLetterVersion(input: CoverLetterVersionInput) {
153
+ export function saveCoverLetterVersion(input: CoverLetterVersionInput & { force?: boolean; strict?: boolean }) {
154
+ // Deterministic save-gate: block suspected fabricated quantified claims (numbers
155
+ // that don't trace to the user's stored facts) unless the caller forces it.
156
+ // Style slop / job-sourced numbers are advisory only — they never block.
157
+ // Strict mode (persisted, or one-shot via input.strict) also blocks numbers
158
+ // backed only by structured records, not the user's résumé document.
159
+ const strict = input.strict ?? getVerifyStrict();
160
+ const verification = lintArtifact('cover_letter', input.content, collectVerifyCorpus(input.job_id), { strict });
161
+ const forced = verification.blocking.length > 0 && input.force === true;
162
+ if (verification.blocking.length > 0 && !forced) {
163
+ const detail = verification.blocking.map((b) => `${b.label}: ${b.detail}`).join('; ');
164
+ const strictHit = verification.blocking.some((b) => b.id === 'unverified_numbers_strict');
165
+ const tail = strictHit
166
+ ? `엄격 모드가 켜져 있어, 올린 이력서 본문에 없는 수치는 막힙니다. 이력서를 올리거나 엄격 모드를 끄거나, 의도한 값이면 force: true로 저장하세요.`
167
+ : `이 수치는 저장된 경력·이력서·프로젝트에 없습니다. 원본 데이터의 실제 수치로 고치거나, 의도한 값이 맞다면 먼저 경력/프로젝트에 그 수치를 추가한 뒤 다시 저장하세요(그대로 저장하려면 force: true).`;
168
+ throw new Error(`저장 전 자동 점검에서 막혔습니다 — ${detail}. ${tail}`);
169
+ }
111
170
  const { coverLetter, version } = coverLetterRepo.addVersion({
112
171
  cover_letter_id: input.cover_letter_id,
113
172
  title: input.title,
@@ -130,7 +189,7 @@ export function saveCoverLetterVersion(input: CoverLetterVersionInput) {
130
189
  'cover_letter',
131
190
  coverLetter.id,
132
191
  );
133
- return { coverLetter, version };
192
+ return { coverLetter, version, verification, forced };
134
193
  }
135
194
 
136
195
  /* ------------------------------------------------- Application status flow */
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @careermate/core/verify — deterministic, LLM-free verification primitives.
3
+ *
4
+ * The "machine check, not self-grade" layer: number/fact provenance (hard gate
5
+ * for suspected fabrication) + Korean style lint (advisory). Pure functions; the
6
+ * corpus is collected from the DB by services.ts so the connected AI can never
7
+ * inject support values (zero-ai invariant).
8
+ */
9
+ export * from './normalize.ts';
10
+ export * from './lexicons.ts';
11
+ export * from './lint.ts';
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Banned-pattern lexicons for the deterministic style lint (advisory only — never
3
+ * a hard gate). Sourced from the cover-letter (R2/R3/R11) and human-writing
4
+ * playbooks under docs/career-os/knowledge. Korean-first, because the real
5
+ * "AI-tell" in Korean is 번역투 *grammar*, not the English `delve` wordlist.
6
+ *
7
+ * Each entry: a stable id, a human label, and a compiled global regex. Patterns
8
+ * are literal alternations (no user input) → ReDoS-safe, compiled once.
9
+ */
10
+
11
+ export interface Lexicon {
12
+ id: string;
13
+ label: string;
14
+ regex: RegExp;
15
+ }
16
+
17
+ const G = (src: string, flags = 'g') => new RegExp(src, flags);
18
+
19
+ /** R2 — generic/cliché openers that make a letter swappable across companies. */
20
+ export const GENERIC_OPENERS: Lexicon = {
21
+ id: 'generic_opener',
22
+ label: '제너릭·상투적 도입(회사명 바꿔도 말이 되는 시작)',
23
+ regex: G(
24
+ [
25
+ 'i am writing to apply',
26
+ 'i have always been passionate',
27
+ 'as a recent graduate',
28
+ 'to whom it may concern',
29
+ '지원하게 되었습니다',
30
+ '지원합니다',
31
+ '어릴 ?적부터',
32
+ '보며 자랐습니다',
33
+ '저는 .{0,10}한 사람입니다',
34
+ '평소 .{0,14}관심이 많아',
35
+ '항상 .{0,12}믿어',
36
+ '데이터의 힘을 믿',
37
+ ].join('|'),
38
+ 'gi',
39
+ ),
40
+ };
41
+
42
+ /** R3 — empty self-describing adjectives / closers used without evidence. */
43
+ export const EMPTY_ADJECTIVES: Lexicon = {
44
+ id: 'empty_adjective',
45
+ label: '증거 없는 평가형 형용사·자기서술',
46
+ regex: G(
47
+ [
48
+ '성실(하|한|함)',
49
+ '책임감이 강',
50
+ '열정(을 가지|적)',
51
+ '소통을 잘',
52
+ '커뮤니케이션이 원활',
53
+ '꼼꼼(하|한|함)',
54
+ '끈기(있|가)',
55
+ '헌신적',
56
+ '주도적인',
57
+ '성장하는 인재',
58
+ '라고 생각합니다',
59
+ '많은 것을 배웠',
60
+ '통해 성장',
61
+ '기여하고 싶',
62
+ '최선을 다',
63
+ '보탬이 되겠',
64
+ 'team player',
65
+ 'hard-?working',
66
+ 'detail-?oriented',
67
+ 'motivated',
68
+ 'proactive',
69
+ 'dedicated',
70
+ 'diligent',
71
+ ].join('|'),
72
+ 'gi',
73
+ ),
74
+ };
75
+
76
+ /** Cliché phrases — empty buzz that appears in any letter. */
77
+ export const CLICHES: Lexicon = {
78
+ id: 'cliche',
79
+ label: 'AI 클리셰·공허한 상투어',
80
+ regex: G(
81
+ [
82
+ '급변하는 시대',
83
+ '4차 산업혁명',
84
+ '끊임없는 노력',
85
+ '귀사의 발전에 기여',
86
+ '귀사',
87
+ '소통과 협업',
88
+ '글로벌 인재',
89
+ '무한한 가능성',
90
+ '한 단계 더 성장',
91
+ ].join('|'),
92
+ ),
93
+ };
94
+
95
+ /** 번역투 — translationese grammar; the real Korean AI-tell that vf-human-voice missed. */
96
+ export const TRANSLATIONESE: Lexicon = {
97
+ id: 'translationese',
98
+ label: '번역투 문법(영어 직역체)',
99
+ regex: G(
100
+ [
101
+ '에 대한',
102
+ '을 통해',
103
+ '를 통해',
104
+ '에 있어서',
105
+ '라는 점에서',
106
+ '가지고 있습니다',
107
+ '되어졌',
108
+ '되어집니다',
109
+ '함으로써',
110
+ '에 다름 아니',
111
+ '진행하였습니다',
112
+ ].join('|'),
113
+ ),
114
+ };
115
+
116
+ /** Hype adverbs/adjectives — drop and let verbs/nouns carry. */
117
+ export const HYPE: Lexicon = {
118
+ id: 'hype',
119
+ label: '과장 수식어',
120
+ regex: G(['매우', '정말로', '탁월(한|하게)', '성공적으로', '완벽(한|하게)', '엄청난', '획기적'].join('|')),
121
+ };
122
+
123
+ /** R11 — blind-hiring guardrail: job-irrelevant personal info (NCS 공정채용). */
124
+ export const BLIND_PII: Lexicon = {
125
+ id: 'blind_pii',
126
+ label: '블라인드 채용 위반 소지(직무무관 개인정보)',
127
+ regex: G(['\\d{2,3}\\s?세', '사진\\s?첨부', '가족관계', '본적', '출신지', '생년월일'].join('|')),
128
+ };
129
+
130
+ /** All advisory style lexicons, in display order. */
131
+ export const STYLE_LEXICONS: Lexicon[] = [
132
+ GENERIC_OPENERS,
133
+ EMPTY_ADJECTIVES,
134
+ CLICHES,
135
+ TRANSLATIONESE,
136
+ HYPE,
137
+ BLIND_PII,
138
+ ];
139
+
140
+ /** Count regex hits in already-normalized text, returning bounded snippets. */
141
+ export function countHits(lex: Lexicon, text: string, maxHits = 8): { count: number; hits: string[] } {
142
+ const hits: string[] = [];
143
+ let count = 0;
144
+ for (const m of text.matchAll(lex.regex)) {
145
+ count++;
146
+ if (hits.length < maxHits) hits.push(m[0].trim());
147
+ }
148
+ return { count, hits };
149
+ }
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Deterministic verify engine — the "machine check, not self-grade" layer.
3
+ *
4
+ * NO LLM. NO DB. Pure functions over (output text, stored corpus). The caller
5
+ * (services.ts) collects the corpus from the DB so the AI can't inject support
6
+ * values (zero-ai). Two jobs:
7
+ *
8
+ * 1. Number/fact provenance (the hard gate): every refutable quantified claim
9
+ * in the output must be *supported* — present in, or server-derivable from,
10
+ * the user's stored facts (same segment + same unit only). Unsupported
11
+ * high-risk numbers = suspected fabrication → block unless force.
12
+ * 2. Style lint (advisory only): slop / 번역투 / generic openers / blind-PII.
13
+ *
14
+ * Source-scope rule (Codex review): a job posting's numbers must NOT support an
15
+ * applicant's *performance* claim ("5년 이상" in the JD can't license "5년 경험").
16
+ * Applicant claims are supported only by the applicant corpus; numbers that only
17
+ * match the job corpus are surfaced as an advisory, never as clean support.
18
+ */
19
+ import {
20
+ extractNumbers,
21
+ buildCorpus,
22
+ normalizeText,
23
+ type Corpus,
24
+ type NumberToken,
25
+ } from './normalize.ts';
26
+ import { STYLE_LEXICONS, countHits } from './lexicons.ts';
27
+
28
+ export type Severity = 'critical' | 'warn' | 'info';
29
+
30
+ export interface Signal {
31
+ id: string;
32
+ label: string;
33
+ severity: Severity;
34
+ count: number;
35
+ hits: string[];
36
+ }
37
+
38
+ export interface UnsupportedNumber {
39
+ raw: string;
40
+ value: number;
41
+ unit: string;
42
+ /**
43
+ * 'fabricated' — matches nothing the user stored (blocking).
44
+ * 'structured_only' — only in a structured record (experience/project/skill),
45
+ * NOT in the user's actual résumé/document text. The AI is
46
+ * the sole writer of structured records, so this can't be
47
+ * verified → advisory, surfaced (de-silences the
48
+ * fabricate-a-record-then-cite-it bypass).
49
+ * 'job_only' — only in the job posting (don't claim as your own).
50
+ */
51
+ reason: 'fabricated' | 'structured_only' | 'job_only';
52
+ }
53
+
54
+ export interface ProvenanceResult {
55
+ /** Count of risk-bearing (refutable) numbers checked. */
56
+ checked: number;
57
+ /** Traced to the user's actual résumé/document text (or derivable from it). */
58
+ supported: number;
59
+ /** Explained by nothing the user stored → suspected fabrication (blocking). */
60
+ fabricated: UnsupportedNumber[];
61
+ /** Only in a structured record, not in the résumé text → advisory (verify). */
62
+ unverified: UnsupportedNumber[];
63
+ /** Only in the job posting → advisory (don't claim as your own). */
64
+ jobSourced: UnsupportedNumber[];
65
+ }
66
+
67
+ export interface LintReport {
68
+ kind: 'cover_letter' | 'fit' | 'interview' | 'resume';
69
+ charCount: number;
70
+ /** Advisory style signals (never block). */
71
+ signals: Signal[];
72
+ provenance: ProvenanceResult;
73
+ /** Hard-gate violations (block save unless force). Empty = nothing to block. */
74
+ blocking: { id: string; label: string; detail: string }[];
75
+ /** Whether a stored corpus existed to verify against (else provenance is N/A). */
76
+ corpusAvailable: boolean;
77
+ /** Whether strict provenance mode was applied (structured-only numbers block). */
78
+ strict: boolean;
79
+ disclaimer: string;
80
+ }
81
+
82
+ export interface LintOptions {
83
+ /**
84
+ * Strict provenance: also block numbers that live ONLY in structured records
85
+ * (not the user's résumé document). Opt-in — raises "advisory" to "blocking".
86
+ */
87
+ strict?: boolean;
88
+ }
89
+
90
+ const within = (a: number, b: number) => Math.abs(a - b) <= Math.max(0.5, 0.02 * Math.max(Math.abs(a), Math.abs(b)));
91
+ const approx = (a: number, b: number) => Math.abs(a - b) <= Math.max(0.5, 0.01 * Math.max(Math.abs(a), Math.abs(b)));
92
+
93
+ /**
94
+ * Is `token` explained by the corpus — literally/rounded (same unit, anywhere),
95
+ * or by a bounded same-segment + same-unit derivation? Percent claims may be
96
+ * derived from a single same-unit pair within one segment (ratio / % change).
97
+ */
98
+ function isExplained(token: NumberToken, corpus: Corpus): boolean {
99
+ const v = token.value;
100
+ // 1) literal / rounding membership, unit-aware.
101
+ for (const s of corpus.byUnit.get(token.unit) ?? []) if (within(v, s)) return true;
102
+ // 2) sum / difference of two same-unit numbers within ONE segment.
103
+ for (const seg of corpus.segments) {
104
+ const vals = seg.get(token.unit);
105
+ if (vals && vals.length >= 2) {
106
+ for (let i = 0; i < vals.length; i++)
107
+ for (let j = 0; j < vals.length; j++) {
108
+ if (i === j) continue;
109
+ if (approx(v, vals[i] - vals[j]) || approx(v, vals[i] + vals[j])) return true;
110
+ }
111
+ }
112
+ }
113
+ // 3) percentages: ratio / %-change of a same-unit pair within one segment.
114
+ if (token.unit === '%') {
115
+ for (const seg of corpus.segments) {
116
+ for (const [u, vals] of seg) {
117
+ if (u === '%' || vals.length < 2) continue;
118
+ for (let i = 0; i < vals.length; i++)
119
+ for (let j = 0; j < vals.length; j++) {
120
+ if (i === j) continue;
121
+ const a = vals[i];
122
+ const b = vals[j];
123
+ if (b !== 0 && (approx(v, (a / b) * 100) || approx(v, ((a - b) / b) * 100) || approx(v, ((a - b) / a) * 100)))
124
+ return true;
125
+ if (a + b !== 0 && approx(v, (a / (a + b)) * 100)) return true;
126
+ }
127
+ }
128
+ }
129
+ }
130
+ return false;
131
+ }
132
+
133
+ export interface VerifyCorpus {
134
+ /** The user's actual résumé/document TEXT (the strongest "user-provided" anchor). */
135
+ documents: string;
136
+ /** Structured records (experiences/projects/skills) — AI-writable, so only advisory. */
137
+ structured: string;
138
+ /** Job posting text (description + requirements + keywords). */
139
+ job: string;
140
+ }
141
+
142
+ /**
143
+ * Number/fact provenance over three SEPARATE scopes (zero-ai). A claim's number
144
+ * is `supported` only if it traces to the user's actual document text; if it
145
+ * only traces to a structured record it is `unverified` (advisory — the AI could
146
+ * have written that record); job-only numbers are advisory; anything matching
147
+ * nothing is `fabricated` (blocking).
148
+ */
149
+ export function analyzeProvenance(outputText: string, corpus: VerifyCorpus): ProvenanceResult {
150
+ const documents = buildCorpus(corpus.documents);
151
+ const structured = buildCorpus(corpus.structured);
152
+ const job = buildCorpus(corpus.job);
153
+ const nums: NumberToken[] = extractNumbers(outputText).filter((n) => n.risk);
154
+ const res: ProvenanceResult = { checked: nums.length, supported: 0, fabricated: [], unverified: [], jobSourced: [] };
155
+ for (const n of nums) {
156
+ const tok = { raw: n.raw, value: n.value, unit: n.unit };
157
+ if (isExplained(n, documents)) res.supported++;
158
+ else if (isExplained(n, structured)) res.unverified.push({ ...tok, reason: 'structured_only' });
159
+ else if (isExplained(n, job)) res.jobSourced.push({ ...tok, reason: 'job_only' });
160
+ else res.fabricated.push({ ...tok, reason: 'fabricated' });
161
+ }
162
+ return res;
163
+ }
164
+
165
+ /** Advisory style signals over normalized text. */
166
+ export function styleSignals(text: string): Signal[] {
167
+ const t = normalizeText(text);
168
+ const signals: Signal[] = [];
169
+ for (const lex of STYLE_LEXICONS) {
170
+ const { count, hits } = countHits(lex, t);
171
+ if (count > 0) {
172
+ const severity: Severity =
173
+ lex.id === 'blind_pii' || lex.id === 'generic_opener' ? 'warn' : 'info';
174
+ signals.push({ id: lex.id, label: lex.label, severity, count, hits });
175
+ }
176
+ }
177
+ return signals;
178
+ }
179
+
180
+ const DISCLAIMER =
181
+ '이 점검은 LLM 없이 셀 수 있는 것만 봅니다(숫자 출처·문체 신호). 통과=합격이 아니며, ' +
182
+ '사실·동문서답·의미 판단은 당신(AI)이 직접 해야 합니다.';
183
+
184
+ /**
185
+ * Full lint for a cover letter / fit analysis. `corpus.applicant` empty ⇒ nothing
186
+ * to verify against ⇒ provenance reported but never blocking.
187
+ */
188
+ export function lintArtifact(
189
+ kind: LintReport['kind'],
190
+ text: string,
191
+ corpus: VerifyCorpus,
192
+ opts: LintOptions = {},
193
+ ): LintReport {
194
+ const norm = normalizeText(text);
195
+ const corpusAvailable = (corpus.documents + corpus.structured).trim().length > 0;
196
+ const provenance = analyzeProvenance(text, corpus);
197
+ const signals = styleSignals(text);
198
+ const strict = !!opts.strict;
199
+
200
+ const blocking: LintReport['blocking'] = [];
201
+ if (corpusAvailable && kind === 'cover_letter') {
202
+ // Always block suspected fabrication (numbers tracing to nothing).
203
+ if (provenance.fabricated.length > 0) {
204
+ blocking.push({
205
+ id: 'fabricated_numbers',
206
+ label: '근거 없는 수치(저장된 경력·이력서에 없음)',
207
+ detail: provenance.fabricated.map((f) => f.raw).join(', '),
208
+ });
209
+ }
210
+ // Strict mode: also block numbers backed only by structured records, not by
211
+ // the user's actual résumé document ("back your claims with your résumé").
212
+ if (strict && provenance.unverified.length > 0) {
213
+ blocking.push({
214
+ id: 'unverified_numbers_strict',
215
+ label: '엄격 모드: 이력서 본문에 근거 없는 수치(구조화 경력/프로젝트 항목에만 있음)',
216
+ detail: provenance.unverified.map((u) => u.raw).join(', '),
217
+ });
218
+ }
219
+ }
220
+
221
+ return {
222
+ kind,
223
+ charCount: [...norm].length,
224
+ signals,
225
+ provenance,
226
+ blocking,
227
+ corpusAvailable,
228
+ strict,
229
+ disclaimer: DISCLAIMER,
230
+ };
231
+ }
232
+
233
+ /** One-line Korean summary for the tool/text response. */
234
+ export function summarizeReport(r: LintReport): string {
235
+ const parts: string[] = [];
236
+ if (r.blocking.length)
237
+ parts.push(`⛔ 저장 차단: ${r.blocking.map((b) => `${b.label}(${b.detail})`).join('; ')}`);
238
+ if (r.provenance.unverified.length)
239
+ parts.push(
240
+ `미확인 수치 ${r.provenance.unverified.length}개(${r.provenance.unverified.map((u) => u.raw).join(', ')}) — 이력서 본문엔 없고 구조화 경력/프로젝트 항목에만 있음, 사실인지 확인`,
241
+ );
242
+ if (r.provenance.jobSourced.length)
243
+ parts.push(`공고 출처 수치 ${r.provenance.jobSourced.length}개(본인 성과로 쓰지 않았는지 확인)`);
244
+ const adv = r.signals.filter((s) => s.count > 0);
245
+ if (adv.length) parts.push('문체 신호: ' + adv.map((s) => `${s.label.split('(')[0]}×${s.count}`).join(', '));
246
+ if (!parts.length) parts.push('자동 점검 통과(셀 수 있는 항목 기준). 의미 판단은 직접 확인하세요.');
247
+ return parts.join(' · ');
248
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Deterministic text normalization + number extraction for the verify engine.
3
+ *
4
+ * NO LLM. Pure functions. Everything here runs identically on the stored corpus
5
+ * and on the AI's output so a number/fact "match" can't be dodged with
6
+ * fullwidth digits, zero-width joiners, or Korean 조사 suffixes.
7
+ *
8
+ * Hardening folded in from a Codex (gpt-5.5) adversarial review:
9
+ * - NFKC + zero-width strip + fullwidth-digit fold on BOTH sides.
10
+ * - "supported" (literal OR server-derivable) instead of "literally present".
11
+ * - Derivation restricted to the SAME segment + SAME unit (no spurious
12
+ * 5 = 12 − 7 across unrelated sentences).
13
+ * - Korean magnitude + trailing unit (`8만 건` → 80000 건, `1.2억` → money).
14
+ */
15
+
16
+ const ZERO_WIDTH = /[​-‍⁠]/g;
17
+
18
+ /** NFKC fold (folds 5→5, %→%, ㈜→(주)…) + strip zero-width chars. */
19
+ export function normalizeText(s: string): string {
20
+ return (s ?? '').normalize('NFKC').replace(ZERO_WIDTH, '');
21
+ }
22
+
23
+ const MAGNITUDE: Record<string, number> = { '억': 1e8, '만': 1e4, '천': 1e3 };
24
+
25
+ /** Units that mark a *refutable quantified claim* (gate-eligible when unsupported). */
26
+ export const RISK_UNITS = new Set([
27
+ '%', '배', '원', '건', '명', '점', '위', '년차', '년', '개월', '주', '시간', '회', '개',
28
+ ]);
29
+
30
+ export interface NumberToken {
31
+ /** Original surface form (post-normalize), e.g. "8만 건", "12건", "250%". */
32
+ raw: string;
33
+ /** Scaled numeric value for comparison (8만 → 80000, 1.2억 → 120000000, 250% → 250). */
34
+ value: number;
35
+ /** Normalized unit ('%','원','건','년',…) or '' when bare. */
36
+ unit: string;
37
+ /** True when this looks like a refutable quantified claim (gate-eligible). */
38
+ risk: boolean;
39
+ }
40
+
41
+ // digits (commas/decimals) + optional Korean magnitude (억/만/천) + optional unit.
42
+ // Trailing 조사 ("12건을", "5년차로") falls outside the match automatically.
43
+ const NUM_RE =
44
+ /(\d{1,3}(?:,\d{3})+|\d+)(?:\.(\d+))?\s*(억|만|천)?\s*(원|%p|%|%|퍼센트|배|건|명|점|위|년차|개월|년|시간|주|회|개)?/g;
45
+
46
+ const YEAR_RE = /^(?:19|20)\d{2}$/;
47
+
48
+ function normUnit(u: string): string {
49
+ if (u === '%' || u === '퍼센트' || u === '%p') return '%';
50
+ return u;
51
+ }
52
+
53
+ /** Extract comparable number tokens from text. Pure, deterministic. */
54
+ export function extractNumbers(text: string): NumberToken[] {
55
+ const t = normalizeText(text);
56
+ const out: NumberToken[] = [];
57
+ for (const m of t.matchAll(NUM_RE)) {
58
+ const intPart = m[1].replace(/,/g, '');
59
+ const frac = m[2] ? '.' + m[2] : '';
60
+ const base = Number(intPart + frac);
61
+ if (!Number.isFinite(base)) continue;
62
+ const mag = m[3] ?? '';
63
+ let unit = normUnit(m[4] ?? '');
64
+ // Year-like 4-digit numbers are dates, not claims ("2024", "2024년"); "7년" kept.
65
+ if (!mag && (!unit || unit === '년') && YEAR_RE.test(intPart) && !frac) continue;
66
+ const scale = MAGNITUDE[mag] ?? 1;
67
+ const value = base * scale;
68
+ // magnitude with no trailing unit ⇒ money (원); "8만 건" keeps 건.
69
+ if (mag && !unit) unit = '원';
70
+ const surface = m[0].trim();
71
+ if (!surface) continue;
72
+ const risk = RISK_UNITS.has(unit) || (!unit && value >= 1000) || (!!mag && !unit);
73
+ out.push({ raw: surface, value, unit, risk });
74
+ }
75
+ return out;
76
+ }
77
+
78
+ /** Split text into segments (sentence / line / bullet) for same-segment derivation. */
79
+ export function splitSegments(text: string): string[] {
80
+ return normalizeText(text)
81
+ .split(/[\n.。!?!?·••]|(?:\s-\s)/)
82
+ .map((s) => s.trim())
83
+ .filter(Boolean);
84
+ }
85
+
86
+ export interface Corpus {
87
+ /** unit → all values seen anywhere (for literal/rounding membership). */
88
+ byUnit: Map<string, number[]>;
89
+ /** per-segment: unit → values (for same-segment, same-unit derivation). */
90
+ segments: Array<Map<string, number[]>>;
91
+ }
92
+
93
+ function addToMap(map: Map<string, number[]>, unit: string, value: number): void {
94
+ const arr = map.get(unit);
95
+ if (arr) arr.push(value);
96
+ else map.set(unit, [value]);
97
+ }
98
+
99
+ /** Build a unit/segment-indexed corpus from raw text. */
100
+ export function buildCorpus(text: string): Corpus {
101
+ const byUnit = new Map<string, number[]>();
102
+ const segments: Array<Map<string, number[]>> = [];
103
+ for (const seg of splitSegments(text)) {
104
+ const segMap = new Map<string, number[]>();
105
+ for (const n of extractNumbers(seg)) {
106
+ addToMap(byUnit, n.unit, n.value);
107
+ addToMap(segMap, n.unit, n.value);
108
+ }
109
+ if (segMap.size) segments.push(segMap);
110
+ }
111
+ return { byUnit, segments };
112
+ }
@@ -6,6 +6,7 @@
6
6
  export * from './paths.ts';
7
7
  export * from './runtime.ts';
8
8
  export * from './connection.ts';
9
+ export * from './settings.ts';
9
10
  export { migrate, MIGRATIONS } from './schema.ts';
10
11
  export {
11
12
  profileRepo,
@@ -0,0 +1,41 @@
1
+ /**
2
+ * App settings — small key/value preferences stored in the existing `_meta`
3
+ * table (created by migrate()). No schema migration needed, so this adds zero
4
+ * boot-time ALTER risk. Currently holds one flag: verify_strict.
5
+ */
6
+ import { getDb } from './connection.ts';
7
+
8
+ const SETTING_PREFIX = 'setting.';
9
+
10
+ function getSetting(key: string): string | null {
11
+ const row = getDb()
12
+ .prepare(`SELECT value FROM _meta WHERE key = ?`)
13
+ .get(SETTING_PREFIX + key) as { value: string } | undefined;
14
+ return row ? row.value : null;
15
+ }
16
+
17
+ function setSetting(key: string, value: string): void {
18
+ getDb()
19
+ .prepare(
20
+ `INSERT INTO _meta (key, value) VALUES (?, ?)
21
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
22
+ )
23
+ .run(SETTING_PREFIX + key, value);
24
+ }
25
+
26
+ /**
27
+ * Strict provenance mode. When ON, the cover-letter gate also blocks numbers
28
+ * that live only in structured records (not the user's résumé document) — i.e.
29
+ * "back your claims with your actual résumé". OFF (default) only blocks numbers
30
+ * that trace to nothing. The user opts in via the dashboard toggle or by asking
31
+ * the AI ("엄격하게 봐줘"); the AI confirms before turning it ON, and must only
32
+ * turn it OFF on an explicit, unambiguous request (weakening the safety net).
33
+ */
34
+ export function getVerifyStrict(): boolean {
35
+ return getSetting('verify_strict') === '1';
36
+ }
37
+
38
+ export function setVerifyStrict(on: boolean): boolean {
39
+ setSetting('verify_strict', on ? '1' : '0');
40
+ return on;
41
+ }