openings 0.1.0
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/.codex-plugin/plugin.json +23 -0
- package/.mcp.json +8 -0
- package/LICENSE +21 -0
- package/README.md +110 -0
- package/data/companies.json +2550 -0
- package/docs/job-seeker-quickstart.md +109 -0
- package/package.json +42 -0
- package/skills/openings/SKILL.md +28 -0
- package/src/artifact-path.ts +48 -0
- package/src/atomic-file.ts +13 -0
- package/src/candidate-profile.ts +273 -0
- package/src/career-tracing.ts +181 -0
- package/src/catalog.ts +382 -0
- package/src/cli.ts +601 -0
- package/src/common-crawl-discovery.ts +137 -0
- package/src/company-seeds.ts +43 -0
- package/src/country-coverage.ts +203 -0
- package/src/crawl-reporting.ts +56 -0
- package/src/crawler.ts +146 -0
- package/src/enrichment-registry.ts +137 -0
- package/src/file-lock.ts +85 -0
- package/src/index.ts +37 -0
- package/src/intent-validation.ts +28 -0
- package/src/job-coverage.ts +73 -0
- package/src/job-fit-analysis.ts +247 -0
- package/src/job-matching.ts +445 -0
- package/src/job-recommendations.ts +193 -0
- package/src/job-search-preparation.ts +116 -0
- package/src/jobposting-probe.ts +167 -0
- package/src/local-jobs.ts +113 -0
- package/src/locations.ts +180 -0
- package/src/mcp.ts +98 -0
- package/src/package-mcp.ts +8 -0
- package/src/recruitee-round.ts +114 -0
- package/src/report-meta.ts +17 -0
- package/src/requirement-vocabulary.ts +111 -0
- package/src/resume-optimization.ts +118 -0
- package/src/runtime.ts +51 -0
- package/src/safe-get.ts +88 -0
- package/src/safe-head.ts +79 -0
- package/src/screening-requirements.ts +99 -0
- package/src/selected-job-lookup.ts +14 -0
- package/src/snapshot-catalog.ts +21 -0
- package/src/snapshot-export.ts +66 -0
- package/src/snapshot-store.ts +31 -0
- package/src/source-discovery-pipeline.ts +26 -0
- package/src/source-discovery.ts +231 -0
- package/src/source-enrichment.ts +92 -0
- package/src/source-pipeline.ts +245 -0
- package/src/source-verification.ts +297 -0
- package/src/tools.ts +194 -0
- package/src/types.ts +136 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Openings for job seekers
|
|
2
|
+
|
|
3
|
+
Openings helps an AI agent find real jobs from public company career systems and explain why each job does or does not fit your resume. Its useful difference is **hidden-job discovery**: it can surface relevant roles whose titles you might not have searched for yourself.
|
|
4
|
+
|
|
5
|
+
It is application-safe. It cannot apply for a job, fill a form, or submit your resume. It has no account system and does not store your resume or extracted profile. It writes only its private local job index.
|
|
6
|
+
|
|
7
|
+
## Before sharing your resume
|
|
8
|
+
|
|
9
|
+
Ask the agent to prepare and check coverage for your target country:
|
|
10
|
+
|
|
11
|
+
> Prepare Openings for India. Before I share my resume, tell me how many current jobs, employers, and indexed sources it covers.
|
|
12
|
+
|
|
13
|
+
Coverage is job-level, not a marketing estimate. A company counts only when the local snapshot contains a job classified as eligible for the requested country. If coverage is thin, the agent should say so before asking for personal information.
|
|
14
|
+
|
|
15
|
+
## Find jobs
|
|
16
|
+
|
|
17
|
+
Openings currently accepts resume content as text or Markdown. If your resume is a PDF or DOCX, ask your MCP host to extract it to text first.
|
|
18
|
+
|
|
19
|
+
> Use this resume to find backend or platform roles in India. I prefer remote or Bengaluru, and I do not want people-management roles. Rank by evidence. Include hidden opportunities and explain supported requirements, gaps, assumptions, and whether the job data was refreshed.
|
|
20
|
+
|
|
21
|
+
You can choose either ranking mode:
|
|
22
|
+
|
|
23
|
+
- **Evidence score** asks how much of the job's detected requirements the resume supports with exact, traceable facts. Use this by default when trust and fit matter most.
|
|
24
|
+
- **Keyword score** asks how much relevant language overlaps. It can help discover adjacent roles, but overlap is not proof of competence.
|
|
25
|
+
|
|
26
|
+
Both scores are returned. Changing the ranking mode changes ordering, not the underlying resume evidence.
|
|
27
|
+
|
|
28
|
+
## Understand one result
|
|
29
|
+
|
|
30
|
+
> Analyze this job against my resume. Separate direct support, transferable evidence, unsupported requirements, screening risks, and interview preparation gaps. Do not infer work authorization or experience from silence.
|
|
31
|
+
|
|
32
|
+
For a hidden result, the response should name the title expansion that found it and whether that expansion came from your explicit intent or validated resume facts.
|
|
33
|
+
|
|
34
|
+
## Improve the resume truthfully
|
|
35
|
+
|
|
36
|
+
> Suggest a job-specific resume revision. Use only claims already supported by my resume, cite the supporting facts, and leave unsupported job requirements as gaps.
|
|
37
|
+
|
|
38
|
+
Openings can return reviewable suggestions, an additive unified diff, or revised Markdown. It never overwrites the original and never inserts a skill merely because the job asks for it.
|
|
39
|
+
|
|
40
|
+
## Example conversation
|
|
41
|
+
|
|
42
|
+
**Candidate:** What does Openings cover in India?
|
|
43
|
+
|
|
44
|
+
**Agent:** Calls `prepare_job_search` and follows `nextAction`: it passes the returned opaque `continuation` into the next call while the result says `call_again`, stops and explains failures on `retry_later`, and presents the returned source, employer, and job counts without recalculating them when ready.
|
|
45
|
+
|
|
46
|
+
**Candidate:** That is useful. Here is my Markdown resume. Find backend roles, but exclude engineering-manager jobs.
|
|
47
|
+
|
|
48
|
+
**Agent:** Calls `recommend_jobs` with the explicit constraints and evidence ranking. It presents direct, hidden, and stretch results separately, including both percentages and material gaps.
|
|
49
|
+
|
|
50
|
+
**Candidate:** Why is the hidden platform role a fit?
|
|
51
|
+
|
|
52
|
+
**Agent:** Calls `analyze_job_fit` for that stable job ID and presents only the returned evidence and risks.
|
|
53
|
+
|
|
54
|
+
**Candidate:** Help me tailor my resume for it.
|
|
55
|
+
|
|
56
|
+
**Agent:** Calls `optimize_resume`, shows the evidence-linked changes, and asks the candidate to verify them.
|
|
57
|
+
|
|
58
|
+
## Common limitations and recovery
|
|
59
|
+
|
|
60
|
+
- **`unsupported_resume_format`**: native PDF/DOCX parsing is not implemented. Extract the document to text or Markdown in the host and retry.
|
|
61
|
+
- **Thin coverage**: coverage differs substantially by country. Treat a small result as a corpus limitation, not proof that no matching job exists.
|
|
62
|
+
- **Thin resume**: add explicit, truthful projects, internships, technologies, outcomes, and target roles. Openings deliberately avoids inventing signals that are absent.
|
|
63
|
+
- **Work authorization**: state hard country and sponsorship needs explicitly. Openings does not infer authorization from a location or resume silence.
|
|
64
|
+
- **Evidence disagreement**: inspect the cited resume span and job text. Keyword overlap is never presented as demonstrated experience.
|
|
65
|
+
|
|
66
|
+
## Connect the MCP server
|
|
67
|
+
|
|
68
|
+
Install Bun 1.3 or newer, then install Openings with Bun, npm, or Yarn. Bun must stay on your `PATH`; it is the runtime the server runs on.
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
bun add --global openings
|
|
72
|
+
# or: npm install --global openings
|
|
73
|
+
# or: yarn global add openings
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
In a client that accepts MCP configuration, add this stdio server:
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
{
|
|
80
|
+
"mcpServers": {
|
|
81
|
+
"openings": {
|
|
82
|
+
"command": "openings-mcp",
|
|
83
|
+
"args": []
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The first `prepare_job_search` call starts a private index under `~/.openings` from verified structured job sources. Each call handles at most ten missing or stale sources, gives each source one bounded 90-second attempt, and returns `nextAction`, allowing the agent to continue across MCP requests. It crawls every currently missing verified source because a source's country eligibility is known only after its jobs are indexed; the requested countries control the coverage returned to the candidate, not which employers are assumed to belong to a country. Successful batches are cached, failures are reported, and later calls rotate past failed sources so the rest of the catalog can progress. Partitions older than 14 days are refreshed. Once all sources are fresh, the setup call makes no network request.
|
|
90
|
+
|
|
91
|
+
For development from a source checkout, point the server at the absolute `src/mcp.ts` path and set `OPENINGS_DATA_DIR` to the repository's absolute `.openings` directory:
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
{
|
|
95
|
+
"mcpServers": {
|
|
96
|
+
"openings": {
|
|
97
|
+
"command": "bun",
|
|
98
|
+
"args": ["run", "/absolute/path/to/hiring-agent/src/mcp.ts"],
|
|
99
|
+
"env": {
|
|
100
|
+
"OPENINGS_DATA_DIR": "/absolute/path/to/hiring-agent/.openings"
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
When `OPENINGS_AGGREGATOR_URL` is set, first-time setup downloads the aggregator's published index before crawling, and every source you crawl is reported back so other installs benefit; only public job data is shared, never your resume. Leave it unset to keep crawls private. The packaged commands use `~/.openings` by default. The source entrypoint uses `.openings` under the MCP process's working directory unless `OPENINGS_DATA_DIR` is set. The included `.mcp.json` provides the repository-local configuration when this repository is installed as a Codex plugin.
|
|
108
|
+
|
|
109
|
+
The MCP interface exposes setup, coverage, recommendation, fit analysis, resume optimization, search, and job-detail tools. There is no application-submission tool.
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "openings",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A free, candidate-safe job-search substrate for AI agents",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": { "type": "git", "url": "git+https://github.com/abhay-avagama/hiring-agent.git" },
|
|
7
|
+
"homepage": "https://github.com/abhay-avagama/hiring-agent#readme",
|
|
8
|
+
"bugs": { "url": "https://github.com/abhay-avagama/hiring-agent/issues" },
|
|
9
|
+
"keywords": ["jobs", "resume", "matching", "mcp", "greenhouse", "lever", "ashby", "workday", "recruitee"],
|
|
10
|
+
"type": "module",
|
|
11
|
+
"bin": { "openings-mcp": "./src/package-mcp.ts" },
|
|
12
|
+
"exports": "./src/index.ts",
|
|
13
|
+
"files": [
|
|
14
|
+
"src/",
|
|
15
|
+
"data/companies.json",
|
|
16
|
+
"skills/openings/",
|
|
17
|
+
".codex-plugin/plugin.json",
|
|
18
|
+
".mcp.json",
|
|
19
|
+
"README.md",
|
|
20
|
+
"docs/job-seeker-quickstart.md",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
23
|
+
"engines": {
|
|
24
|
+
"bun": ">=1.3.0"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "bun test",
|
|
31
|
+
"test:live": "OPENINGS_LIVE_TESTS=1 bun test test/live-contract.test.ts",
|
|
32
|
+
"typecheck": "bunx tsc --noEmit",
|
|
33
|
+
"start": "bun run src/cli.ts",
|
|
34
|
+
"mcp": "bun run src/mcp.ts",
|
|
35
|
+
"expand:corpus": "bun run scripts/expand-corpus.ts",
|
|
36
|
+
"release:pack": "bun pm pack"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/bun": "latest",
|
|
40
|
+
"typescript": "^5.9.2"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: openings
|
|
3
|
+
description: Use MCP-native tools to find, analyze, and truthfully optimize a resume for evidence-grounded jobs.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Openings
|
|
7
|
+
|
|
8
|
+
Do not reproduce parsing, matching, fit classification, evidence validation, or resume rewriting in this skill. The MCP modules are the sole enforceable implementation.
|
|
9
|
+
|
|
10
|
+
Use `prepare_job_search` to bootstrap the private local index and set honest country-level expectations before requesting a resume, `get_job_coverage` for later no-network coverage checks, `recommend_jobs` for resume-based discovery, `analyze_job_fit` for evidence-grounded analysis of a selected job, `optimize_resume` for a proposed revision, and `get_job` for its complete description. Keep `search_jobs` for direct lower-level searches that do not need resume matching. These tools must never be used to submit an application; the only local mutation is the job index maintained by setup and refresh.
|
|
11
|
+
|
|
12
|
+
## Search
|
|
13
|
+
|
|
14
|
+
1. Ask for the target countries and call `prepare_job_search` before requesting a resume. Explain that first-time setup runs in resumable batches and uses the network only to build a private local job index. While `nextAction` is `call_again`, call the tool again with the same countries and the returned opaque `continuation`; do not ask for a resume between batches. On `retry_later`, disclose failures and offer to continue with the indexed coverage or retry later without the old continuation. On `ready`, present its job-level source, job, and employer counts exactly as returned; never substitute discovery-cohort counts or recalculate coverage.
|
|
15
|
+
2. Ask only for missing constraints that materially change the recommendation.
|
|
16
|
+
3. Supply the user's resume content, explicit intent, and their chosen `ranking.mode` (`evidence` by default or `keyword`) to `recommend_jobs`; never pass an arbitrary filesystem path. Use `ranking.minimumPercent` only when the user requests a cutoff.
|
|
17
|
+
4. Present the returned coverage summary alongside the recommendations without recalculating it.
|
|
18
|
+
5. Present direct, hidden title-family, and stretch results separately. For a hidden result, quote the returned title expansions and whether each came from explicit intent or validated resume fact IDs; never invent or broaden an alias in the skill.
|
|
19
|
+
6. Present both returned percentages, the selected score, assumptions, evidence, gaps, refresh status, and failures without recalculating fit. Never describe keyword overlap as demonstrated competence.
|
|
20
|
+
7. Call `analyze_job_fit` before explaining a selected role's fit; present its evidence and gaps without independently recalculating them.
|
|
21
|
+
8. Call `get_job` before discussing the complete description of a selected role.
|
|
22
|
+
|
|
23
|
+
## Tailor an application
|
|
24
|
+
|
|
25
|
+
1. Supply the selected job ID and resume content to `optimize_resume`; never pass an arbitrary filesystem path.
|
|
26
|
+
2. Use `suggestions` for review, `unified_diff` for a patch, or `revised_markdown` for complete proposed content.
|
|
27
|
+
3. Present the tool's evidence references and unsupported gaps without adding claims or independently rewriting its output.
|
|
28
|
+
4. Ask the user to verify every proposed change. Never navigate to a submission flow, fill an application form, or submit an application.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { lstat, mkdir, realpath } from "node:fs/promises";
|
|
2
|
+
import { dirname, parse, relative, resolve, sep } from "node:path";
|
|
3
|
+
|
|
4
|
+
export function openingsRootFor(path: string): string {
|
|
5
|
+
const absolute = resolve(path);
|
|
6
|
+
const parts = absolute.slice(parse(absolute).root.length).split(sep);
|
|
7
|
+
const index = parts.indexOf(".openings");
|
|
8
|
+
if (index < 0) throw new Error("Round 5 artifacts must be files under .openings");
|
|
9
|
+
return resolve(parse(absolute).root, ...parts.slice(0, index + 1));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function assertArtifactFile(path: string, root = openingsRootFor(path)): Promise<void> {
|
|
13
|
+
const expectedRoot = resolve(root);
|
|
14
|
+
const target = resolve(path);
|
|
15
|
+
const lexical = relative(expectedRoot, target);
|
|
16
|
+
if (!lexical || lexical.startsWith("..") || lexical.split(sep).includes("..")) throw new Error("Round 5 artifacts must be files under .openings");
|
|
17
|
+
|
|
18
|
+
await ensureDirectory(expectedRoot);
|
|
19
|
+
const actualRoot = await realpath(expectedRoot);
|
|
20
|
+
let current = expectedRoot;
|
|
21
|
+
const parentParts = relative(expectedRoot, dirname(target)).split(sep).filter(Boolean);
|
|
22
|
+
for (const component of parentParts) {
|
|
23
|
+
current = resolve(current, component);
|
|
24
|
+
await ensureDirectory(current);
|
|
25
|
+
const actual = await realpath(current);
|
|
26
|
+
if (relative(actualRoot, actual).startsWith("..")) throw new Error("Round 5 artifacts must be files under .openings");
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const status = await lstat(target);
|
|
30
|
+
if (status.isSymbolicLink()) throw new Error("Round 5 artifact files must not be symbolic links");
|
|
31
|
+
if (!status.isFile()) throw new Error("Round 5 artifact target must be a regular file");
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function ensureDirectory(path: string): Promise<void> {
|
|
38
|
+
try {
|
|
39
|
+
const status = await lstat(path);
|
|
40
|
+
if (status.isSymbolicLink()) throw new Error("Round 5 artifact directories must not be symbolic links");
|
|
41
|
+
if (!status.isDirectory()) throw new Error("Round 5 artifact path component must be a directory");
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
|
|
44
|
+
await mkdir(path);
|
|
45
|
+
const status = await lstat(path);
|
|
46
|
+
if (status.isSymbolicLink() || !status.isDirectory()) throw new Error("Round 5 artifact directory was replaced during creation");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { mkdir, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
export async function atomicWrite(path: string, content: string): Promise<void> {
|
|
5
|
+
await mkdir(dirname(path), { recursive: true });
|
|
6
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
7
|
+
await writeFile(temporary, content, "utf8");
|
|
8
|
+
await rename(temporary, path);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function atomicJson(path: string, value: unknown): Promise<void> {
|
|
12
|
+
return atomicWrite(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
13
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
export type ResumeFormat = "text" | "markdown" | "pdf_base64" | "docx_base64";
|
|
2
|
+
|
|
3
|
+
export interface ResumeInput {
|
|
4
|
+
content: string;
|
|
5
|
+
format: ResumeFormat;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface NormalizedResume {
|
|
9
|
+
text: string;
|
|
10
|
+
normalization: "openings-resume:v1";
|
|
11
|
+
offsetEncoding: "utf16_code_units";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CandidateProfile {
|
|
15
|
+
normalizedResume: NormalizedResume;
|
|
16
|
+
facts: CandidateFact[];
|
|
17
|
+
inferences: CandidateInference[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface EvidenceSpan { start: number; end: number; quote: string }
|
|
21
|
+
export type CandidateFactKind = "skill" | "role" | "employer" | "date" | "outcome" | "experience_statement" | "project" | "project_statement" | "education" | "certification";
|
|
22
|
+
export interface CandidateFact { id: string; kind: CandidateFactKind; value: string; evidence: EvidenceSpan[] }
|
|
23
|
+
export type CandidateInference =
|
|
24
|
+
| { kind: "role_family"; value: "backend" | "frontend" | "data" | "infrastructure"; derivedFromFactIds: string[] }
|
|
25
|
+
| { kind: "seniority"; value: "intern" | "junior" | "mid" | "senior" | "lead" | "staff" | "principal" | "manager"; derivedFromFactIds: string[] }
|
|
26
|
+
| { kind: "transferable_skill"; value: "backend_programming" | "frontend_programming" | "data_engineering" | "cloud_infrastructure"; derivedFromFactIds: string[] }
|
|
27
|
+
| { kind: "approximate_experience_years"; value: number; derivedFromFactIds: string[] };
|
|
28
|
+
export interface EvidenceValidationResult { valid: boolean; errors: string[] }
|
|
29
|
+
|
|
30
|
+
export class ResumeInputError extends Error {
|
|
31
|
+
constructor(
|
|
32
|
+
readonly code: "unsupported_resume_format" | "invalid_resume_input",
|
|
33
|
+
message: string,
|
|
34
|
+
readonly format?: ResumeFormat,
|
|
35
|
+
readonly supportedFormats?: Array<"text" | "markdown">,
|
|
36
|
+
) { super(message); }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function parseCandidateProfile(input: unknown): CandidateProfile {
|
|
40
|
+
if (!isRecord(input) || typeof input.format !== "string" || typeof input.content !== "string") {
|
|
41
|
+
throw new ResumeInputError("invalid_resume_input", "Resume must contain text or Markdown content");
|
|
42
|
+
}
|
|
43
|
+
if (input.format === "pdf_base64" || input.format === "docx_base64") {
|
|
44
|
+
throw new ResumeInputError("unsupported_resume_format", `Resume format ${input.format} is not supported yet`, input.format, ["text", "markdown"]);
|
|
45
|
+
}
|
|
46
|
+
if (!(["text", "markdown"] as string[]).includes(input.format)) {
|
|
47
|
+
throw new ResumeInputError("invalid_resume_input", "Resume must contain text or Markdown content");
|
|
48
|
+
}
|
|
49
|
+
const text = normalizeResumeText(input.content);
|
|
50
|
+
if (!text.trim()) throw new ResumeInputError("invalid_resume_input", "Resume content must not be empty");
|
|
51
|
+
const facts = extractFacts(text);
|
|
52
|
+
return {
|
|
53
|
+
normalizedResume: { text, normalization: "openings-resume:v1", offsetEncoding: "utf16_code_units" },
|
|
54
|
+
facts,
|
|
55
|
+
inferences: deriveInferences(facts),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function validateCandidateProfileEvidence(value: unknown): EvidenceValidationResult {
|
|
60
|
+
const errors: string[] = [];
|
|
61
|
+
if (!isRecord(value)) return { valid: false, errors: ["candidate profile must be an object"] };
|
|
62
|
+
const normalized = value.normalizedResume;
|
|
63
|
+
if (!isRecord(normalized) || typeof normalized.text !== "string" || normalized.normalization !== "openings-resume:v1" || normalized.offsetEncoding !== "utf16_code_units") {
|
|
64
|
+
errors.push("candidate profile has invalid normalized resume metadata");
|
|
65
|
+
}
|
|
66
|
+
if (!Array.isArray(value.facts)) errors.push("candidate profile facts must be an array");
|
|
67
|
+
if (!Array.isArray(value.inferences)) errors.push("candidate profile inferences must be an array");
|
|
68
|
+
if (errors.length) return { valid: false, errors };
|
|
69
|
+
const profile = value as unknown as CandidateProfile;
|
|
70
|
+
const validFactKinds = new Set<CandidateFactKind>(["skill", "role", "employer", "date", "outcome", "experience_statement", "project", "project_statement", "education", "certification"]);
|
|
71
|
+
for (const [index, fact] of profile.facts.entries()) {
|
|
72
|
+
if (!isRecord(fact) || typeof fact.id !== "string" || !validFactKinds.has(fact.kind as CandidateFactKind) || typeof fact.value !== "string" || !Array.isArray(fact.evidence)) {
|
|
73
|
+
errors.push(`fact ${index}: invalid fact shape`);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
for (const evidence of fact.evidence) {
|
|
77
|
+
if (!isRecord(evidence) || !Number.isInteger(evidence.start) || !Number.isInteger(evidence.end) || typeof evidence.quote !== "string") errors.push(`${fact.id}: invalid evidence shape`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
for (const [index, inference] of profile.inferences.entries()) {
|
|
81
|
+
if (!isValidInference(inference)) errors.push(`inference ${index}: invalid inference shape`);
|
|
82
|
+
}
|
|
83
|
+
if (errors.length) return { valid: false, errors };
|
|
84
|
+
const ids = new Set<string>();
|
|
85
|
+
for (const fact of profile.facts) {
|
|
86
|
+
if (ids.has(fact.id)) errors.push(`${fact.id}: duplicate fact id`);
|
|
87
|
+
ids.add(fact.id);
|
|
88
|
+
if (!fact.evidence.length) errors.push(`${fact.id}: evidence is required`);
|
|
89
|
+
if (!fact.evidence.some((evidence) => evidence.quote === fact.value)) errors.push(`${fact.id}: fact value is not identical to its evidence`);
|
|
90
|
+
for (const evidence of fact.evidence) {
|
|
91
|
+
const validOffsets = Number.isInteger(evidence.start) && Number.isInteger(evidence.end) && evidence.start >= 0 && evidence.end > evidence.start && evidence.end <= profile.normalizedResume.text.length;
|
|
92
|
+
if (!validOffsets || profile.normalizedResume.text.slice(evidence.start, evidence.end) !== evidence.quote) errors.push(`${fact.id}: evidence span does not match normalized resume`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
for (const inference of profile.inferences) {
|
|
96
|
+
for (const factId of inference.derivedFromFactIds) if (!ids.has(factId)) errors.push(`${inference.kind}:${inference.value}: inference references missing fact ${factId}`);
|
|
97
|
+
}
|
|
98
|
+
if (JSON.stringify(profile.inferences) !== JSON.stringify(deriveInferences(profile.facts))) errors.push("candidate inferences do not match current facts");
|
|
99
|
+
return { valid: errors.length === 0, errors };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isValidInference(value: unknown): value is CandidateInference {
|
|
103
|
+
if (!isRecord(value) || !Array.isArray(value.derivedFromFactIds) || !value.derivedFromFactIds.every((id) => typeof id === "string")) return false;
|
|
104
|
+
if (value.kind === "role_family") return ["backend", "frontend", "data", "infrastructure"].includes(value.value as string);
|
|
105
|
+
if (value.kind === "seniority") return ["intern", "junior", "mid", "senior", "lead", "staff", "principal", "manager"].includes(value.value as string);
|
|
106
|
+
if (value.kind === "transferable_skill") return ["backend_programming", "frontend_programming", "data_engineering", "cloud_infrastructure"].includes(value.value as string);
|
|
107
|
+
return value.kind === "approximate_experience_years" && typeof value.value === "number" && Number.isFinite(value.value) && value.value >= 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
type RoleFamily = Extract<CandidateInference, { kind: "role_family" }>["value"];
|
|
111
|
+
const roleSkills: Record<RoleFamily, Set<string>> = {
|
|
112
|
+
backend: new Set(["java", "go", "golang", "python", "node.js", "nodejs", "postgresql", "postgres", "sql", "spring", "django"]),
|
|
113
|
+
frontend: new Set(["javascript", "typescript", "react", "vue", "angular", "html", "css"]),
|
|
114
|
+
data: new Set(["pandas", "spark", "hadoop", "dbt", "airflow", "snowflake", "machine learning"]),
|
|
115
|
+
infrastructure: new Set(["aws", "azure", "gcp", "kubernetes", "docker", "terraform", "ansible"]),
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
function deriveInferences(facts: CandidateFact[]): CandidateInference[] {
|
|
119
|
+
const skills = facts.filter((fact) => fact.kind === "skill");
|
|
120
|
+
const inferences: CandidateInference[] = (Object.entries(roleSkills) as Array<[RoleFamily, Set<string>]>).flatMap(([value, vocabulary]) => {
|
|
121
|
+
const supporting = skills.filter((fact) => vocabulary.has(fact.value.toLowerCase()));
|
|
122
|
+
return supporting.length ? [{ kind: "role_family", value, derivedFromFactIds: supporting.map((fact) => fact.id) }] : [];
|
|
123
|
+
});
|
|
124
|
+
const roles = facts.filter((fact) => fact.kind === "role");
|
|
125
|
+
const seniorityOrder = ["manager", "principal", "staff", "lead", "senior", "junior", "intern"] as const;
|
|
126
|
+
for (const role of roles) {
|
|
127
|
+
const seniority = seniorityOrder.find((value) => new RegExp(`\\b${value}\\b`, "i").test(role.value));
|
|
128
|
+
if (seniority) inferences.push({ kind: "seniority", value: seniority, derivedFromFactIds: [role.id] });
|
|
129
|
+
}
|
|
130
|
+
const transferable: Array<[RoleFamily, Extract<CandidateInference, { kind: "transferable_skill" }>["value"]]> = [
|
|
131
|
+
["backend", "backend_programming"], ["frontend", "frontend_programming"], ["data", "data_engineering"], ["infrastructure", "cloud_infrastructure"],
|
|
132
|
+
];
|
|
133
|
+
for (const [family, value] of transferable) {
|
|
134
|
+
const supporting = skills.filter((fact) => roleSkills[family].has(fact.value.toLowerCase()));
|
|
135
|
+
if (supporting.length) inferences.push({ kind: "transferable_skill", value, derivedFromFactIds: supporting.map((fact) => fact.id) });
|
|
136
|
+
}
|
|
137
|
+
const dateFacts = facts.filter((fact) => fact.kind === "date");
|
|
138
|
+
const hasOpenInterval = dateFacts.some((fact) => /\b(?:present|current)\b/i.test(fact.value));
|
|
139
|
+
const intervals = dateFacts.flatMap((fact) => {
|
|
140
|
+
const years = [...fact.value.matchAll(/\b(?:19|20)\d{2}\b/g)].map((match) => Number(match[0]));
|
|
141
|
+
return years.length >= 2 && years[1]! >= years[0]! ? [{ start: years[0]!, end: years[1]!, fact }] : [];
|
|
142
|
+
}).sort((left, right) => left.start - right.start);
|
|
143
|
+
const merged: Array<{ start: number; end: number; facts: CandidateFact[] }> = [];
|
|
144
|
+
for (const interval of intervals) {
|
|
145
|
+
const previous = merged.at(-1);
|
|
146
|
+
if (previous && interval.start <= previous.end) {
|
|
147
|
+
previous.end = Math.max(previous.end, interval.end);
|
|
148
|
+
previous.facts.push(interval.fact);
|
|
149
|
+
} else merged.push({ start: interval.start, end: interval.end, facts: [interval.fact] });
|
|
150
|
+
}
|
|
151
|
+
if (merged.length && !hasOpenInterval) {
|
|
152
|
+
inferences.push({
|
|
153
|
+
kind: "approximate_experience_years",
|
|
154
|
+
value: merged.reduce((total, interval) => total + interval.end - interval.start, 0),
|
|
155
|
+
derivedFromFactIds: merged.flatMap((interval) => interval.facts.map((fact) => fact.id)),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
return inferences;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
type ResumeSection = "skills" | "experience" | "projects" | "education" | "certifications";
|
|
162
|
+
const sectionNames = new Map<string, ResumeSection>([
|
|
163
|
+
["skills", "skills"], ["technical skills", "skills"], ["core skills", "skills"], ["key skills", "skills"], ["professional skills", "skills"],
|
|
164
|
+
["experience", "experience"], ["work experience", "experience"], ["professional experience", "experience"], ["relevant experience", "experience"],
|
|
165
|
+
["employment", "experience"], ["employment history", "experience"], ["work history", "experience"], ["career history", "experience"], ["professional history", "experience"],
|
|
166
|
+
["projects", "projects"], ["selected projects", "projects"], ["key projects", "projects"], ["key development projects", "projects"],
|
|
167
|
+
["relevant projects", "projects"], ["personal projects", "projects"], ["technical projects", "projects"], ["academic projects", "projects"],
|
|
168
|
+
["education", "education"],
|
|
169
|
+
["certifications", "certifications"], ["certificates", "certifications"],
|
|
170
|
+
]);
|
|
171
|
+
const plainTextBoundaries = new Set([
|
|
172
|
+
"summary", "professional summary", "profile", "objective", "contact", "interests", "hobbies", "awards", "publications", "languages", "references",
|
|
173
|
+
"volunteer experience", "volunteering", "additional information", "personal information", "achievements", "activities",
|
|
174
|
+
]);
|
|
175
|
+
const skillCategoryLabels = new Set([
|
|
176
|
+
"languages", "programming languages", "frameworks", "libraries", "frameworks and libraries", "libraries and frameworks",
|
|
177
|
+
"databases", "tools", "cloud", "platforms", "technologies", "frontend", "front end", "front-end", "frontend technologies",
|
|
178
|
+
"backend", "back end", "back-end", "backend technologies", "devops", "devops tools", "testing", "testing tools",
|
|
179
|
+
"operating systems", "version control", "build tools", "messaging",
|
|
180
|
+
]);
|
|
181
|
+
// Standalone lines are ambiguous with real skills, so retain only the historically
|
|
182
|
+
// supported, low-ambiguity headings here. The broader set is safe after a colon.
|
|
183
|
+
const skillSubheadings = new Set(["languages", "programming languages", "frameworks", "databases", "tools", "cloud", "platforms", "technologies"]);
|
|
184
|
+
|
|
185
|
+
function extractFacts(text: string): CandidateFact[] {
|
|
186
|
+
const facts: CandidateFact[] = [];
|
|
187
|
+
let section: ResumeSection | undefined;
|
|
188
|
+
let sectionLevel = 0;
|
|
189
|
+
let lineStart = 0;
|
|
190
|
+
for (const line of text.split("\n")) {
|
|
191
|
+
const trimmed = line.trim();
|
|
192
|
+
const markdownHeading = /^(#{1,6})\s+(.+)$/.exec(trimmed);
|
|
193
|
+
const heading = (markdownHeading?.[2] ?? trimmed).replace(/:$/, "").trim().toLowerCase();
|
|
194
|
+
const nextSection = sectionNames.get(heading);
|
|
195
|
+
if (nextSection) { section = nextSection; sectionLevel = markdownHeading?.[1]?.length ?? 0; }
|
|
196
|
+
else if (section === "skills" && skillSubheadings.has(heading)) { /* category label, not a fact */ }
|
|
197
|
+
else if (markdownHeading && section === "skills") section = undefined;
|
|
198
|
+
else if (markdownHeading && section && (sectionLevel === 0 || markdownHeading[1]!.length <= sectionLevel)) section = undefined;
|
|
199
|
+
else if (!markdownHeading && plainTextBoundaries.has(heading)) section = undefined;
|
|
200
|
+
else if (section && trimmed) facts.push(...factsFromLine(section, line, lineStart, Boolean(markdownHeading && markdownHeading[1]!.length > sectionLevel)));
|
|
201
|
+
lineStart += line.length + 1;
|
|
202
|
+
}
|
|
203
|
+
return facts;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function factsFromLine(section: ResumeSection, line: string, lineStart: number, isNestedHeading: boolean): CandidateFact[] {
|
|
207
|
+
const withoutPrefix = line.trimStart().replace(/^(?:#{1,6}\s+|[-*+]\s+|\d+[.)]\s+)/, "");
|
|
208
|
+
if (/^(?:ignore (?:all |any )?(?:previous |prior )?instructions?|do not|don't|never|pretend|claim|add|use)\b/i.test(withoutPrefix)) return [];
|
|
209
|
+
if (/\b(?:did not|was not|is not|expired|lapsed|revoked)\b/i.test(withoutPrefix)) return [];
|
|
210
|
+
if (section === "certifications" && /\b(?:inactive|pending|scheduled|planned|in progress|not yet)\b/i.test(withoutPrefix)) return [];
|
|
211
|
+
if (section === "skills" && /\b(?:no|not|without)\b/i.test(withoutPrefix)) return [];
|
|
212
|
+
const skillLabel = /^([^,:|]{1,30}):\s*/.exec(withoutPrefix);
|
|
213
|
+
const content = section === "skills" && skillLabel && skillCategoryLabels.has(skillLabel[1]!.trim().toLowerCase()) ? withoutPrefix.slice(skillLabel[0].length) : withoutPrefix;
|
|
214
|
+
const dateRange = /\b(?:(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\.?\s+)?(?:19|20)\d{2}\s*(?:—|–|-|to)\s*(?:(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\.?\s+)?(?:(?:19|20)\d{2}|present|current)\b/i.exec(content);
|
|
215
|
+
const dateFacts = section === "experience" && dateRange ? [factFromValue("date", dateRange[0], line, lineStart)] : [];
|
|
216
|
+
const roleContent = dateRange ? content.slice(0, dateRange.index).trimEnd() : content;
|
|
217
|
+
const roleSeparator = /\s+(?:—|–|-|\bat\b|\|)\s+/i.exec(roleContent);
|
|
218
|
+
const looksLikeRole = /\b(?:engineer|developer|architect|manager|analyst|scientist|designer|consultant|specialist|administrator|lead|director|intern)\b/i.test(roleContent);
|
|
219
|
+
const standaloneRoleWords = roleContent.trim().split(/\s+/);
|
|
220
|
+
const standaloneRole = standaloneRoleWords.length <= 7
|
|
221
|
+
&& standaloneRoleWords.every((word) => /^(?:of|and|&)$/i.test(word) || /^[A-Z][A-Za-z0-9+.#/-]*$/.test(word))
|
|
222
|
+
&& /\b(?:engineer|developer|architect|manager|analyst|scientist|designer|consultant|specialist|administrator|lead|director|intern)$/i.test(roleContent.trim());
|
|
223
|
+
if (section === "experience" && (isNestedHeading || standaloneRole || (roleSeparator && looksLikeRole))) {
|
|
224
|
+
if (roleSeparator) return [
|
|
225
|
+
...optionalFact("role", roleContent.slice(0, roleSeparator.index).trim(), line, lineStart),
|
|
226
|
+
...optionalFact("employer", roleContent.slice(roleSeparator.index + roleSeparator[0].length).trim(), line, lineStart),
|
|
227
|
+
...dateFacts,
|
|
228
|
+
];
|
|
229
|
+
return [factFromValue("role", roleContent, line, lineStart), ...dateFacts];
|
|
230
|
+
}
|
|
231
|
+
if (section === "projects" && isNestedHeading) return [factFromValue("project", content, line, lineStart)];
|
|
232
|
+
if (dateFacts.length) return dateFacts;
|
|
233
|
+
const measurable = /(?:\b\d+(?:\.\d+)?%|[$€£]\s?\d|\b\d+(?:\.\d+)?x\b|\b\d+\s?(?:ms|seconds?|minutes?|hours?|users?|requests?|transactions?)\b)/i.test(content);
|
|
234
|
+
if (section === "experience") return [factFromValue(measurable ? "outcome" : "experience_statement", content, line, lineStart)];
|
|
235
|
+
if (section === "projects") return [factFromValue(measurable ? "outcome" : "project_statement", content, line, lineStart)];
|
|
236
|
+
if (section === "education") return [factFromValue("education", content, line, lineStart)];
|
|
237
|
+
if (section === "certifications") return [factFromValue("certification", content, line, lineStart)];
|
|
238
|
+
const values = content.split(/[,;|]/).map((value) => value.trim()).filter(Boolean);
|
|
239
|
+
const kind: CandidateFactKind = "skill";
|
|
240
|
+
let searchFrom = 0;
|
|
241
|
+
return values.flatMap((value) => {
|
|
242
|
+
const relativeStart = line.indexOf(value, searchFrom);
|
|
243
|
+
if (relativeStart < 0) return [];
|
|
244
|
+
searchFrom = relativeStart + value.length;
|
|
245
|
+
const start = lineStart + relativeStart;
|
|
246
|
+
const end = start + value.length;
|
|
247
|
+
return [{ id: `fact_${kind}_${start}_${end}`, kind, value, evidence: [{ start, end, quote: value }] }];
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function factFromValue(kind: CandidateFactKind, value: string, line: string, lineStart: number): CandidateFact {
|
|
252
|
+
const relativeStart = line.indexOf(value);
|
|
253
|
+
const start = lineStart + relativeStart;
|
|
254
|
+
const end = start + value.length;
|
|
255
|
+
return { id: `fact_${kind}_${start}_${end}`, kind, value, evidence: [{ start, end, quote: value }] };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function optionalFact(kind: CandidateFactKind, value: string, line: string, lineStart: number): CandidateFact[] {
|
|
259
|
+
return value ? [factFromValue(kind, value, line, lineStart)] : [];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
|
|
263
|
+
|
|
264
|
+
function normalizeResumeText(content: string): string {
|
|
265
|
+
return content
|
|
266
|
+
.replace(/^\uFEFF/, "")
|
|
267
|
+
.normalize("NFC")
|
|
268
|
+
.replace(/\r\n?/g, "\n")
|
|
269
|
+
.split("\n")
|
|
270
|
+
.map((line) => line.replace(/[\t ]+$/g, ""))
|
|
271
|
+
.join("\n")
|
|
272
|
+
.replace(/^\n+|\n+$/g, "");
|
|
273
|
+
}
|