Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b01b9fb413 | |||
| 7ee188f270 | |||
| b359b39b50 | |||
| 92fc6e5d68 | |||
| 6a5ce586eb | |||
| 5cd30ef5f2 | |||
| f1f9a94c02 | |||
| 4f05e0fd5d | |||
| 599821d25a | |||
| 392daff8cc | |||
| 9296aff106 | |||
| 8bf8784d52 |
47
input/AGENTS.md
Normal file
47
input/AGENTS.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# Input Agent Guide
|
||||
|
||||
## Mission
|
||||
Automate the upstream resume customization workflow. Monitor the job-description inbox, clean up messy recruiter-sourced text, combine the normalized description with the base resume and prompt templates, invoke the Codex CLI, and deposit the generated Markdown in a timestamped outbox for human review.
|
||||
|
||||
## Directory Responsibilities
|
||||
- `resume/` – contains exactly one Markdown resume. Any other count is a fatal configuration error.
|
||||
- `ForCustomizing/inbox/` – drop one plain-text or Markdown job description at a time to trigger processing.
|
||||
- `ForCustomizing/outbox/YYYY/MM/DD/HHMM/` – timestamped folders containing Codex output Markdown (`<company>-<jobtitle>.md`) along with the exact prompt and the cleaned job description that fed Codex.
|
||||
- `ForCustomizing/processed/YYYY/MM/DD/HHMM/` – archives of job descriptions that Codex processed successfully.
|
||||
- `ForCustomizing/failed/` – captures job descriptions when Codex errors or a recoverable issue occurs. Fatal configuration errors still exit the container.
|
||||
- `Docker/JobDescriptionNormalizerPrompt.md` – baked normalizer instructions (modify the container build to customize).
|
||||
- `templates/ResumeCustomizerPrompt.md.example` – default resume-customization instructions; copy to `ResumeCustomizerPrompt.md` to override.
|
||||
|
||||
## Running the Input Processor
|
||||
Launch the stack with the wrapper so files inherit your UID/GID and your local Codex credentials mount in:
|
||||
|
||||
```bash
|
||||
cd input/Docker
|
||||
./run-input-processor.sh up -d
|
||||
```
|
||||
|
||||
- Environment variables you can pass before the command:
|
||||
- `POLL_INTERVAL_SECONDS` – watcher polling cadence (default `5`).
|
||||
- `CODEX_TIMEOUT_SECONDS` – hard timeout for Codex calls (default `600`).
|
||||
- `HOST_CODEX_DIR` – optional override for the host directory that should mount into `/home/codex/.codex`.
|
||||
|
||||
The container bundles the Codex CLI and expects the caller to mount their `~/.codex` directory for credentials.
|
||||
|
||||
Stop or inspect the stack with:
|
||||
|
||||
```bash
|
||||
cd input/Docker
|
||||
./run-input-processor.sh down
|
||||
./run-input-processor.sh logs -f
|
||||
```
|
||||
|
||||
## Guardrails
|
||||
- Ensure only one job description resides in `ForCustomizing/inbox/`. Multiple files cause the container to exit with a fatal error.
|
||||
- Keep exactly one resume Markdown in `resume/`. Missing or multiple resumes also terminate the watcher.
|
||||
- The container runs as a non-root user matching the caller’s UID/GID. Avoid changing permissions manually inside the mounted directories.
|
||||
- Do not edit anything under `output/` from this agent session; treat the downstream pipeline as read-only reference material.
|
||||
|
||||
## Troubleshooting
|
||||
- If Codex CLI fails, the job description moves to `ForCustomizing/failed/`. Check container logs, adjust the Markdown, then requeue it.
|
||||
- Fatal errors (multiple resumes, multiple job descriptions, missing template, or missing Codex binary) stop the container. Resolve the issue and restart via the wrapper.
|
||||
- The watcher logs the exact `codex exec - --output-last-message … --skip-git-repo-check --sandbox read-only` command before each invocation; inspect container logs if troubleshooting is needed.
|
||||
31
input/Docker/Dockerfile
Normal file
31
input/Docker/Dockerfile
Normal file
@@ -0,0 +1,31 @@
|
||||
FROM debian:bookworm
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
CODEX_HOME=/home/codex
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install --yes --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
nodejs \
|
||||
npm \
|
||||
python3 \
|
||||
python3-venv \
|
||||
tzdata \
|
||||
&& npm install --location=global @openai/codex \
|
||||
&& groupadd --gid 1000 codex \
|
||||
&& useradd --uid 1000 --gid 1000 --create-home --shell /bin/bash codex \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY watch_and_customize.py entrypoint.sh JobDescriptionNormalizerPrompt.md ./
|
||||
|
||||
RUN chmod +x /app/watch_and_customize.py /app/entrypoint.sh
|
||||
|
||||
USER codex
|
||||
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
28
input/Docker/JobDescriptionNormalizerPrompt.md
Normal file
28
input/Docker/JobDescriptionNormalizerPrompt.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Default instructions that clean messy job descriptions before resume customization.
|
||||
# Customize only by modifying the container build.
|
||||
|
||||
You are an expert technical recruiter.
|
||||
|
||||
Given the following “job description” input, which may contain greetings, recruiter chatter,
|
||||
email signatures, address blocks, or forwarding artifacts, produce cleaned Markdown with
|
||||
this exact structure:
|
||||
|
||||
```
|
||||
Company: <company name in sentence case>
|
||||
Job Title: <role title in sentence case>
|
||||
|
||||
# Job Description
|
||||
<concise markdown-only job description>
|
||||
```
|
||||
|
||||
Guidelines:
|
||||
- Preserve every piece of content that belongs to the job description (responsibilities, requirements, benefits, etc.). Do **not** summarize, condense, or delete substantive details.
|
||||
- Only remove extraneous recruiter chatter such as greetings, signatures, contact blocks, forwarding notes, or metadata unrelated to the role itself.
|
||||
- If multiple companies or roles appear, pick the primary one the candidate should target.
|
||||
- Normalize whitespace, headings, and bullet lists in the Markdown output.
|
||||
- Omit personal identifiers for recruiters or candidates unless they are essential job facts.
|
||||
- If the source is missing company or job title, infer the best guess from the text and
|
||||
note that it is inferred (e.g., “<Company> (inferred)”).
|
||||
- Never add commentary outside the format shown above.
|
||||
|
||||
The raw content follows verbatim. Retain all job-related text.
|
||||
23
input/Docker/docker-compose.yml
Normal file
23
input/Docker/docker-compose.yml
Normal file
@@ -0,0 +1,23 @@
|
||||
name: RCEO-AI-ResumeCustomizer-Input
|
||||
|
||||
services:
|
||||
rceo-ai-resumecustomizer-inputprocessor:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: RCEO-AI-ResumeCustomizer-InputProcessor
|
||||
restart: "no"
|
||||
environment:
|
||||
POLL_INTERVAL_SECONDS: "${POLL_INTERVAL_SECONDS:-5}"
|
||||
CODEX_TIMEOUT_SECONDS: "${CODEX_TIMEOUT_SECONDS:-600}"
|
||||
volumes:
|
||||
- ../ForCustomizing/inbox:/workspace/inbox
|
||||
- ../ForCustomizing/outbox:/workspace/outbox
|
||||
- ../ForCustomizing/processed:/workspace/processed
|
||||
- ../ForCustomizing/failed:/workspace/failed
|
||||
- ../resume:/workspace/resume:ro
|
||||
- ../templates:/templates:ro
|
||||
- ${HOST_CODEX_DIR:-${HOME}/.codex}:/home/codex/.codex
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- /etc/machine-id:/etc/machine-id:ro
|
||||
- /var/lib/dbus/machine-id:/var/lib/dbus/machine-id:ro
|
||||
14
input/Docker/entrypoint.sh
Executable file
14
input/Docker/entrypoint.sh
Executable file
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
USER_NAME=${USER_NAME:-codex}
|
||||
USER_HOME=$(eval echo "~${USER_NAME}")
|
||||
|
||||
mkdir -p /workspace/inbox /workspace/outbox /workspace/processed /workspace/failed
|
||||
mkdir -p "${USER_HOME}/.codex"
|
||||
|
||||
export HOME="${USER_HOME}"
|
||||
export XDG_CACHE_HOME="${USER_HOME}/.cache"
|
||||
mkdir -p "${XDG_CACHE_HOME}"
|
||||
|
||||
exec python3 /app/watch_and_customize.py
|
||||
28
input/Docker/run-input-processor.sh
Executable file
28
input/Docker/run-input-processor.sh
Executable file
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# Wrapper to run docker compose with the caller's UID/GID so generated files stay writable.
|
||||
set -euo pipefail
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "Error: docker is not installed or not on PATH." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
COMPOSE_CMD=(docker compose)
|
||||
elif command -dv docker-compose >/dev/null 2>&1; then
|
||||
COMPOSE_CMD=(docker-compose)
|
||||
else
|
||||
echo "Error: docker compose plugin or docker-compose binary is required." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
HOST_CODEX_DIR=${HOST_CODEX_DIR:-${HOME}/.codex}
|
||||
|
||||
mkdir -p "${HOST_CODEX_DIR}"
|
||||
|
||||
(
|
||||
cd "${SCRIPT_DIR}"
|
||||
HOST_CODEX_DIR="${HOST_CODEX_DIR}" \
|
||||
"${COMPOSE_CMD[@]}" "$@"
|
||||
)
|
||||
442
input/Docker/watch_and_customize.py
Normal file
442
input/Docker/watch_and_customize.py
Normal file
@@ -0,0 +1,442 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Monitor the customization inbox, normalize messy job descriptions, and run the Codex CLI
|
||||
to produce tailored resumes.
|
||||
|
||||
The watcher expects exactly one base resume Markdown file and processes one job file at a
|
||||
time. After Codex succeeds, the generated resume is written into a timestamped outbox
|
||||
folder using the pattern <company>-<jobtitle>.md, while the original job file is archived
|
||||
under processed/. Failures move the job description into failed/.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import Sequence
|
||||
|
||||
INBOX = Path("/workspace/inbox")
|
||||
OUTBOX = Path("/workspace/outbox")
|
||||
PROCESSED = Path("/workspace/processed")
|
||||
FAILED = Path("/workspace/failed")
|
||||
RESUME_DIR = Path("/workspace/resume")
|
||||
TEMPLATES_DIR = Path("/templates")
|
||||
TEMPLATE_CACHE = Path("/tmp/templates")
|
||||
PROMPT_TEMPLATE = TEMPLATES_DIR / "ResumeCustomizerPrompt.md"
|
||||
PROMPT_TEMPLATE_EXAMPLE = TEMPLATES_DIR / "ResumeCustomizerPrompt.md.example"
|
||||
NORMALIZER_TEMPLATE = Path("/app/JobDescriptionNormalizerPrompt.md")
|
||||
|
||||
POLL_INTERVAL_SECONDS = int(os.environ.get("POLL_INTERVAL_SECONDS", "5"))
|
||||
CODEX_TIMEOUT_SECONDS = int(os.environ.get("CODEX_TIMEOUT_SECONDS", "600"))
|
||||
|
||||
RESOLVED_PROMPT_TEMPLATE: Path | None = None
|
||||
RESOLVED_NORMALIZER_TEMPLATE: Path | None = None
|
||||
|
||||
|
||||
class FatalConfigurationError(RuntimeError):
|
||||
"""Raised when the watcher encounters a non-recoverable configuration problem."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NormalizedJobDescription:
|
||||
company: str
|
||||
job_title: str
|
||||
description_markdown: str
|
||||
|
||||
|
||||
def ensure_environment() -> None:
|
||||
"""Verify required directories and template assets exist."""
|
||||
global RESOLVED_PROMPT_TEMPLATE, RESOLVED_NORMALIZER_TEMPLATE
|
||||
|
||||
missing = [
|
||||
str(path)
|
||||
for path in (
|
||||
INBOX,
|
||||
OUTBOX,
|
||||
PROCESSED,
|
||||
FAILED,
|
||||
RESUME_DIR,
|
||||
TEMPLATES_DIR,
|
||||
)
|
||||
if not path.exists()
|
||||
]
|
||||
|
||||
if missing:
|
||||
raise FatalConfigurationError(
|
||||
"Input pipeline is missing required paths: " + ", ".join(missing)
|
||||
)
|
||||
|
||||
RESOLVED_PROMPT_TEMPLATE = resolve_template(
|
||||
PROMPT_TEMPLATE,
|
||||
PROMPT_TEMPLATE_EXAMPLE,
|
||||
TEMPLATE_CACHE,
|
||||
"Resume customization prompt",
|
||||
)
|
||||
RESOLVED_NORMALIZER_TEMPLATE = NORMALIZER_TEMPLATE
|
||||
if not RESOLVED_NORMALIZER_TEMPLATE.exists():
|
||||
raise FatalConfigurationError(
|
||||
f"Job description normalizer prompt missing at {RESOLVED_NORMALIZER_TEMPLATE}"
|
||||
)
|
||||
|
||||
|
||||
def resolve_template(
|
||||
primary: Path,
|
||||
example: Path,
|
||||
cache_dir: Path,
|
||||
description: str,
|
||||
) -> Path:
|
||||
"""Return the template path, copying the example if needed."""
|
||||
if primary.exists():
|
||||
return primary
|
||||
|
||||
if example.exists():
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
cached = cache_dir / primary.name
|
||||
shutil.copy(example, cached)
|
||||
return cached
|
||||
|
||||
raise FatalConfigurationError(
|
||||
f"{description} missing: {primary} (no example found at {example})"
|
||||
)
|
||||
|
||||
|
||||
def ensure_single_resume() -> Path:
|
||||
"""Return the single resume markdown file or raise if conditions are not met."""
|
||||
resumes = sorted(RESUME_DIR.glob("*.md"))
|
||||
if len(resumes) == 0:
|
||||
raise FatalConfigurationError(
|
||||
f"No resume Markdown file found in {RESUME_DIR}. Exactly one is required."
|
||||
)
|
||||
if len(resumes) > 1:
|
||||
raise FatalConfigurationError(
|
||||
f"Multiple resume Markdown files found in {RESUME_DIR}: "
|
||||
+ ", ".join(r.name for r in resumes)
|
||||
)
|
||||
|
||||
return resumes[0]
|
||||
|
||||
|
||||
def ensure_single_job(paths: Sequence[Path]) -> Path | None:
|
||||
"""Validate there is at most one job description file (any extension)."""
|
||||
visible = [path for path in paths if path.is_file() and not path.name.startswith(".")]
|
||||
if not visible:
|
||||
return None
|
||||
|
||||
if len(visible) > 1:
|
||||
names = ", ".join(p.name for p in visible)
|
||||
raise FatalConfigurationError(
|
||||
f"Multiple job description files detected in inbox: {names} "
|
||||
"— expected exactly one."
|
||||
)
|
||||
|
||||
return visible[0]
|
||||
|
||||
|
||||
def build_prompt_text(resume: Path, job_markdown: str, prompt_template: Path) -> str:
|
||||
"""Return the combined prompt string fed to the Codex CLI."""
|
||||
resume_text = resume.read_text(encoding="utf-8").strip()
|
||||
instructions_text = prompt_template.read_text(encoding="utf-8").strip()
|
||||
|
||||
return (
|
||||
"# Resume Customization Request\n\n"
|
||||
"## Instructions\n"
|
||||
f"{instructions_text}\n\n"
|
||||
"---\n\n"
|
||||
"## Job Description\n"
|
||||
f"{job_markdown.strip()}\n\n"
|
||||
"---\n\n"
|
||||
"## Current Resume\n"
|
||||
f"{resume_text}\n"
|
||||
)
|
||||
|
||||
|
||||
def build_timestamp_dir(base: Path, timestamp: datetime) -> Path:
|
||||
"""Create (if missing) and return the timestamped directory path."""
|
||||
path = (
|
||||
base
|
||||
/ timestamp.strftime("%Y")
|
||||
/ timestamp.strftime("%m")
|
||||
/ timestamp.strftime("%d")
|
||||
/ timestamp.strftime("%H%M")
|
||||
)
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def sanitize_stem(stem: str) -> str:
|
||||
"""Replace characters that could interfere with filesystem operations."""
|
||||
return "".join(ch if ch.isalnum() else "_" for ch in stem) or "resume"
|
||||
|
||||
|
||||
def slugify(component: str) -> str:
|
||||
"""Turn a free-form string into a filesystem-friendly slug."""
|
||||
normalized = "".join(
|
||||
ch.lower() if ch.isalnum() else "-" for ch in component.strip()
|
||||
)
|
||||
parts = [part for part in normalized.split("-") if part]
|
||||
return "-".join(parts)
|
||||
|
||||
|
||||
def run_codex(prompt_text: str, output_path: Path) -> None:
|
||||
"""Execute the Codex CLI with the provided prompt text."""
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
command = [
|
||||
"codex",
|
||||
"exec",
|
||||
"-",
|
||||
"--output-last-message",
|
||||
str(output_path),
|
||||
"--skip-git-repo-check",
|
||||
"--sandbox",
|
||||
"read-only",
|
||||
]
|
||||
logging.info("Running Codex CLI command: %s", " ".join(command))
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
command,
|
||||
input=prompt_text.encode("utf-8"),
|
||||
check=True,
|
||||
timeout=CODEX_TIMEOUT_SECONDS,
|
||||
env=os.environ.copy(),
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise FatalConfigurationError(
|
||||
"Codex CLI executable 'codex' not found in PATH"
|
||||
) from exc
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise RuntimeError("Codex CLI timed out") from exc
|
||||
|
||||
if not output_path.exists():
|
||||
raise RuntimeError(
|
||||
f"Codex CLI completed but expected output file {output_path} is missing."
|
||||
)
|
||||
|
||||
|
||||
def build_normalizer_prompt(raw_text: str) -> str:
|
||||
"""Construct the prompt for normalizing the raw job description."""
|
||||
if RESOLVED_NORMALIZER_TEMPLATE is None:
|
||||
raise FatalConfigurationError("Normalizer template was not resolved during startup.")
|
||||
|
||||
instructions = RESOLVED_NORMALIZER_TEMPLATE.read_text(encoding="utf-8").strip()
|
||||
return (
|
||||
f"{instructions}\n\n"
|
||||
"---\n\n"
|
||||
"## Raw Job Description\n"
|
||||
"```\n"
|
||||
f"{raw_text.strip()}\n"
|
||||
"```\n"
|
||||
)
|
||||
|
||||
|
||||
def parse_normalized_output(text: str) -> NormalizedJobDescription:
|
||||
"""Parse the Codex-normalized output into structured pieces."""
|
||||
lines = text.splitlines()
|
||||
idx = 0
|
||||
|
||||
def next_non_empty(start: int) -> tuple[int, str]:
|
||||
pos = start
|
||||
while pos < len(lines):
|
||||
content = lines[pos].strip()
|
||||
if content:
|
||||
return pos, content
|
||||
pos += 1
|
||||
raise RuntimeError("Normalized output is missing expected lines.")
|
||||
|
||||
idx, company_line = next_non_empty(idx)
|
||||
if not company_line.lower().startswith("company:"):
|
||||
raise RuntimeError(f"Expected 'Company:' line, found: {company_line!r}")
|
||||
company = company_line[len("company:") :].strip()
|
||||
|
||||
idx, job_title_line = next_non_empty(idx + 1)
|
||||
if not job_title_line.lower().startswith("job title:"):
|
||||
raise RuntimeError(f"Expected 'Job Title:' line, found: {job_title_line!r}")
|
||||
job_title = job_title_line[len("job title:") :].strip()
|
||||
|
||||
idx += 1
|
||||
while idx < len(lines) and lines[idx].strip():
|
||||
idx += 1
|
||||
|
||||
while idx < len(lines) and not lines[idx].strip():
|
||||
idx += 1
|
||||
|
||||
description_lines = lines[idx:]
|
||||
description = "\n".join(description_lines).strip()
|
||||
if not description:
|
||||
raise RuntimeError("Normalized output did not include a job description section.")
|
||||
|
||||
return NormalizedJobDescription(
|
||||
company=company or "Company",
|
||||
job_title=job_title or "Role",
|
||||
description_markdown=description,
|
||||
)
|
||||
|
||||
|
||||
def normalize_job_description(job_file: Path) -> NormalizedJobDescription:
|
||||
"""Use Codex to clean and extract metadata from the raw job description."""
|
||||
raw_text = job_file.read_text(encoding="utf-8", errors="ignore").strip()
|
||||
if not raw_text:
|
||||
raise RuntimeError(f"Job description file {job_file.name} is empty after trimming.")
|
||||
|
||||
prompt_text = build_normalizer_prompt(raw_text)
|
||||
|
||||
with TemporaryDirectory() as tmp_dir_str:
|
||||
tmp_dir = Path(tmp_dir_str)
|
||||
output_path = tmp_dir / "normalize_output.md"
|
||||
run_codex(prompt_text, output_path)
|
||||
normalized_text = output_path.read_text(encoding="utf-8").strip()
|
||||
|
||||
return parse_normalized_output(normalized_text)
|
||||
|
||||
|
||||
def move_with_unique_target(source: Path, destination_dir: Path) -> Path:
|
||||
"""Move source into destination_dir, avoiding collisions with numeric suffixes."""
|
||||
destination_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
target = destination_dir / source.name
|
||||
stem = source.stem
|
||||
suffix = source.suffix
|
||||
counter = 1
|
||||
|
||||
while target.exists():
|
||||
target = destination_dir / f"{stem}_{counter}{suffix}"
|
||||
counter += 1
|
||||
|
||||
shutil.move(str(source), target)
|
||||
return target
|
||||
|
||||
|
||||
def process_job(job_file: Path) -> None:
|
||||
"""Normalize the job description, run Codex, and archive outputs."""
|
||||
timestamp = datetime.now().astimezone()
|
||||
out_dir = build_timestamp_dir(OUTBOX, timestamp)
|
||||
processed_dir = build_timestamp_dir(PROCESSED, timestamp)
|
||||
|
||||
resume_path = ensure_single_resume()
|
||||
normalized = normalize_job_description(job_file)
|
||||
|
||||
if RESOLVED_PROMPT_TEMPLATE is None:
|
||||
raise FatalConfigurationError("Prompt template was not resolved during startup.")
|
||||
|
||||
prompt_text = build_prompt_text(
|
||||
resume_path,
|
||||
normalized.description_markdown,
|
||||
RESOLVED_PROMPT_TEMPLATE,
|
||||
)
|
||||
|
||||
safe_company = slugify(normalized.company)
|
||||
safe_title = slugify(normalized.job_title)
|
||||
if safe_company and safe_title:
|
||||
output_stem = f"{safe_company}-{safe_title}"
|
||||
else:
|
||||
output_stem = sanitize_stem(job_file.stem)
|
||||
output_filename = f"{output_stem}.md"
|
||||
|
||||
with TemporaryDirectory() as tmp_dir_str:
|
||||
tmp_dir = Path(tmp_dir_str)
|
||||
output_path = tmp_dir / "codex_output.md"
|
||||
run_codex(prompt_text, output_path)
|
||||
|
||||
generated_output = out_dir / output_filename
|
||||
counter = 1
|
||||
while generated_output.exists():
|
||||
generated_output = out_dir / f"{output_stem}_{counter}.md"
|
||||
counter += 1
|
||||
|
||||
shutil.move(str(output_path), generated_output)
|
||||
logging.info(
|
||||
"Generated customized resume for %s - %s at %s",
|
||||
normalized.company,
|
||||
normalized.job_title,
|
||||
generated_output,
|
||||
)
|
||||
|
||||
prompt_archive = out_dir / f"prompt-{generated_output.stem}.md"
|
||||
prompt_archive.write_text(prompt_text, encoding="utf-8")
|
||||
|
||||
normalized_archive = out_dir / f"job-description-{generated_output.stem}.md"
|
||||
normalized_archive.write_text(
|
||||
(
|
||||
f"Company: {normalized.company}\n"
|
||||
f"Job Title: {normalized.job_title}\n\n"
|
||||
"# Job Description\n"
|
||||
f"{normalized.description_markdown}\n"
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
processed_target = move_with_unique_target(job_file, processed_dir)
|
||||
logging.info(
|
||||
"Archived job description %s to %s",
|
||||
job_file.name,
|
||||
processed_target,
|
||||
)
|
||||
|
||||
|
||||
def move_job_to_failed(job_file: Path) -> None:
|
||||
"""Move the job description into the failed directory."""
|
||||
if not job_file.exists():
|
||||
return
|
||||
|
||||
failed_target = move_with_unique_target(job_file, FAILED)
|
||||
logging.info(
|
||||
"Moved job description %s into failed directory at %s",
|
||||
job_file.name,
|
||||
failed_target,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
)
|
||||
|
||||
try:
|
||||
ensure_environment()
|
||||
except FatalConfigurationError as exc:
|
||||
logging.error("Fatal configuration error: %s", exc)
|
||||
raise SystemExit(2) from exc
|
||||
|
||||
logging.info("Resume customizer watcher started")
|
||||
|
||||
while True:
|
||||
job_paths = sorted(INBOX.iterdir())
|
||||
|
||||
try:
|
||||
job_file = ensure_single_job(job_paths)
|
||||
except FatalConfigurationError as exc:
|
||||
logging.error("Fatal configuration error: %s", exc)
|
||||
raise SystemExit(2) from exc
|
||||
|
||||
if job_file is None:
|
||||
time.sleep(POLL_INTERVAL_SECONDS)
|
||||
continue
|
||||
|
||||
logging.info("Processing job description %s", job_file.name)
|
||||
try:
|
||||
process_job(job_file)
|
||||
except FatalConfigurationError as exc:
|
||||
logging.error("Fatal configuration error: %s", exc)
|
||||
move_job_to_failed(job_file)
|
||||
raise SystemExit(2) from exc
|
||||
except subprocess.CalledProcessError as exc:
|
||||
logging.error("Codex CLI failed with non-zero exit status: %s", exc)
|
||||
move_job_to_failed(job_file)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logging.exception("Unexpected error while processing %s: %s", job_file.name, exc)
|
||||
move_job_to_failed(job_file)
|
||||
|
||||
time.sleep(POLL_INTERVAL_SECONDS)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
3
input/ForCustomizing/failed/.gitignore
vendored
Normal file
3
input/ForCustomizing/failed/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
*
|
||||
!.gitignore
|
||||
!.gitkeep
|
||||
1
input/ForCustomizing/failed/.gitkeep
Normal file
1
input/ForCustomizing/failed/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
3
input/ForCustomizing/inbox/.gitignore
vendored
Normal file
3
input/ForCustomizing/inbox/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
*
|
||||
!.gitignore
|
||||
!.gitkeep
|
||||
1
input/ForCustomizing/inbox/.gitkeep
Normal file
1
input/ForCustomizing/inbox/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
119
input/ForCustomizing/jd-test.txt
Executable file
119
input/ForCustomizing/jd-test.txt
Executable file
@@ -0,0 +1,119 @@
|
||||
Subject:
|
||||
URGENT Job Opportunity in Senior IT Engineer in Richardson TX
|
||||
From:
|
||||
<mandeep@infotreeglobal.com>
|
||||
Date:
|
||||
10/15/2025, 9:04 AM
|
||||
To:
|
||||
<reachableceo@reachableceo.com>
|
||||
|
||||
|
||||
|
||||
Hi,
|
||||
|
||||
This side Mandeep from Infotree Global. I have a job opening for you as a Senior IT Engineer in Richardson TX. If you are looking for job change, please share your resume @ mandeep@infotreeglobal.com.
|
||||
|
||||
Job Title: Senior IT Engineer
|
||||
|
||||
Job Location: Richardson TX
|
||||
|
||||
Duration :- 6 months
|
||||
|
||||
|
||||
Responsible for architecting, deploying, and supporting information (IT) and operational technology (OT) solutions for one of our most mission critical manufacturing sites and growing businesses in Richardson, Texas.
|
||||
|
||||
Will work closely with cross-functional teams to design, develop, and deploy innovative infrastructure and software solutions to digitize and optimize semiconductor related manufacturing and business operations.
|
||||
|
||||
The Full Stack Engineer will be responsible for operational support of Server Infrastructure – both virtual and physical. You will work in VMware, Windows & Linux Server Architecture & Administration. You will be helping to deploy and support our initial Kubernetes Container-based application environments and to grow that technology base.
|
||||
|
||||
This site will be leveraging increasing AI based semiconductor centric automation, equipment integration, fault detection (FDC), and advanced process control (APC) solutions within manufacturing that will have this role deploying and supporting bleeding edge infrastructure.
|
||||
|
||||
|
||||
|
||||
Key Responsibilities
|
||||
|
||||
• Operational support of on-premises server stacks and technology. Working with Corporate IT teams to maintain as much to standards as possible but focused on the tier 1 24 X 7 mission critical needs of the site.
|
||||
|
||||
• Work with Hyper-Converged Infrastructure technology platforms such as Dell VXRail and newer.
|
||||
|
||||
• Troubleshoot performance issues related to virtualized and physical server workloads.
|
||||
|
||||
• Facilitate implementation of new technology stacks and migration of workloads from physical servers to virtualized instances wherever possible.
|
||||
|
||||
• Work with semiconductor process and equipment vendors to continually evolve infrastructure for the best performance and fault tolerance.
|
||||
|
||||
• Participate in calls / meetings with site and corporate team members to address and troubleshoot application and or infrastructure related issues.
|
||||
|
||||
• Participate in daily tier meetings with factory systems and manufacturing operations teams.
|
||||
|
||||
• Work with Corporate IT and Honeywell Global Security to maintain all infrastructure to corporate and semiconductor cyber security standards.
|
||||
|
||||
|
||||
|
||||
MUST HAVE
|
||||
|
||||
• Bachelor’s Degree
|
||||
|
||||
• 5+ years of experience in operational support of enterprise server ecosystems
|
||||
|
||||
• 5+ years of experience with Windows Server OS and RHEL LINUX 5-9.X
|
||||
|
||||
• 3+ years of experience with ServiceNow or another similar formal Service Management platform and/or methodology
|
||||
|
||||
• 3+ years of experience with virtualization platforms such as Vmware,, vSAN, Vsphere, and VCF/NSX
|
||||
|
||||
• 1+ years of experience with Docker and Kubernetes for container orchestration.
|
||||
|
||||
• Experience with one or more of the following: Server automation activities or Infrastructure as code - Tanium, CHEF, JSON, Powershell, Ruby, or other scripting languages.
|
||||
|
||||
• Experience with network fundamentals such as - Firewall configuration, VPN setup, VLANs, subnetting, IP addressing, DNS, DHCP, NAT, routing, and switching.
|
||||
|
||||
• Experience with backup, disaster recovery, and technology resilience strategies / processes.
|
||||
|
||||
• Must demonstrate proficiency in documenting infrastructure, automation scripts, and system configurations to ensure clarity, maintainability, and operational continuity.
|
||||
|
||||
|
||||
|
||||
VALUED
|
||||
|
||||
• Advanced Degree preferably in Information Technology, Computer Science, or Computer Engineering.
|
||||
|
||||
• Experience with Windows patching tools such as Tanium and Windows security standards & OS Hardening concepts
|
||||
|
||||
• Experience in all aspects of the software development lifecycle including requirements gathering, design, coding, testing and production support
|
||||
|
||||
• Experience with support of Network infrastructure such as Cisco Nexus & Catalyst switches
|
||||
|
||||
• Experience with support of contemporary data recovery products such as Rubrik
|
||||
|
||||
• Experience with support of IT/OT systems and applications, including network implementation & segmentation, equipment integration and automation, and manufacturing execution systems (MES)
|
||||
|
||||
• Understanding of supervisory control and data acquisition (SCADA), manufacturing focused Windows-based PC’s and servers, and industrial network architectures and how to effectively support these technologies.
|
||||
|
||||
• The ability to be a change agent and promote positive change in the organization
|
||||
|
||||
• Excellent oral, written and collaborative communication skills.
|
||||
|
||||
• Skilled in partnering with internal customers at all levels to define problems, identify solutions, and facilitate change.
|
||||
|
||||
• A self-starting, self-driven individual, working to remain motivated and efficient with minimum oversight..
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Kind Regards,
|
||||
|
||||
Mandeep Singh
|
||||
|
||||
Phone: 734-228-4349 ext. – 1105 FAX: 734-927-9390
|
||||
|
||||
EMAIL: Mandeep@infotreeglobal.com globalwww.infotreeglobal.com
|
||||
|
||||
Mailing Address: 215 Ann Arbor Rd. Suite 304 Plymouth, MI 48170
|
||||
|
||||
logo_3
|
||||
|
||||
Follow Us On:
|
||||
|
||||
Please consider the environment before printing this e-mail.
|
||||
3
input/ForCustomizing/outbox/.gitignore
vendored
Normal file
3
input/ForCustomizing/outbox/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
*
|
||||
!.gitignore
|
||||
!.gitkeep
|
||||
1
input/ForCustomizing/outbox/.gitkeep
Normal file
1
input/ForCustomizing/outbox/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
3
input/ForCustomizing/processed/.gitignore
vendored
Normal file
3
input/ForCustomizing/processed/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
*
|
||||
!.gitignore
|
||||
!.gitkeep
|
||||
1
input/ForCustomizing/processed/.gitkeep
Normal file
1
input/ForCustomizing/processed/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
45
input/README.md
Normal file
45
input/README.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Input Pipeline Overview
|
||||
|
||||
The input side of ResumeCustomizer prepares job-specific Markdown resumes by stitching together the base resume, a job-description Markdown file, and the shared instruction prompt, then invoking the Codex CLI inside a containerized watcher.
|
||||
|
||||
## Workflow Recap
|
||||
1. Ensure `input/resume/` contains exactly one Markdown resume.
|
||||
2. Drop a single job-description file (plain text or Markdown) into `input/ForCustomizing/inbox/`.
|
||||
3. Start the watcher stack (`input/Docker/run-input-processor.sh up -d`).
|
||||
4. The watcher normalizes the messy job description via Codex (using the baked-in normalizer prompt), stripping recruiter chatter while preserving every job-related detail, then combines the cleaned Markdown, the base resume, and the resolved customization prompt into a second Codex run that writes the generated resume to `ForCustomizing/outbox/YYYY/MM/DD/HHMM/<company>-<jobtitle>.md`.
|
||||
5. Successful runs archive the job description under `ForCustomizing/processed/`, copy both the prompt and the cleaned job description into the same outbox folder, and leave the Codex output for human review. Failures move the job description into `ForCustomizing/failed/`.
|
||||
|
||||
The human operator reviews the Codex output Markdown, makes any edits, and then manually hands it off to the output pipeline for document rendering.
|
||||
|
||||
## Container Stack
|
||||
The watcher lives in `input/Docker/`:
|
||||
- `Dockerfile` – builds a Node/Python base image, installs the Codex CLI, and prepares a non-root `codex` user.
|
||||
- `watch_and_customize.py` – polls the inbox, validates preconditions, resolves both templates (normalizer and customization), cleans the job description, builds prompts, runs Codex twice, and routes files.
|
||||
- `entrypoint.sh` – maps the container user to the caller’s UID/GID and ensures shared directories exist.
|
||||
- `run-input-processor.sh` – wrapper around `docker compose` that resolves your Codex config directory (`${HOST_CODEX_DIR:-${HOME}/.codex}`) and forwards CLI arguments.
|
||||
- `docker-compose.yml` – defines the container, volumes (including machine-id bindings required by the Codex CLI), environment variables, and restart policy (`no` so fatal errors halt the stack).
|
||||
|
||||
### Templates
|
||||
- baked normalizer prompt (`input/Docker/JobDescriptionNormalizerPrompt.md`) ships with the container; customize by editing the image.
|
||||
- `templates/ResumeCustomizerPrompt.md.example` ships with default resume-customization instructions. Copy it to `ResumeCustomizerPrompt.md` to override.
|
||||
- The `.gitignore` in `templates/` keeps local overrides out of version control.
|
||||
|
||||
### Codex CLI
|
||||
- The container image installs the official Codex CLI and expects credentials/configuration from the mounted `~/.codex` directory on the host.
|
||||
- The watcher pipes prompts directly to `codex exec - --output-last-message <path> --skip-git-repo-check --sandbox read-only`, so no additional command-line customization is required from operators.
|
||||
|
||||
### Key Environment Variables
|
||||
- `POLL_INTERVAL_SECONDS` – watch loop delay (defaults to 5).
|
||||
- `CODEX_TIMEOUT_SECONDS` – wall-clock timeout for each Codex call (defaults to 600).
|
||||
- `HOST_CODEX_DIR` – host path that mounts into `/home/codex/.codex` (defaults to `${HOME}/.codex` via the wrapper).
|
||||
|
||||
### Prerequisites
|
||||
- Docker Engine with the Compose plugin (`docker compose`) or the standalone `docker-compose` binary.
|
||||
- A working Codex CLI and credentials in `~/.codex`. The Docker build attempts `npm install --location=global codex-cli`; override or update as needed if packages change.
|
||||
|
||||
## Failure Modes
|
||||
- **Multiple resumes or job descriptions** – watcher exits immediately with a fatal configuration error. Fix the files and restart.
|
||||
- **Codex CLI missing or failing** – job description moves to `ForCustomizing/failed/`; inspect container logs, resolve, and requeue.
|
||||
- **Timeouts** – treat as failures; adjust `CODEX_TIMEOUT_SECONDS` if Codex regularly needs more time.
|
||||
|
||||
For day-to-day operating guidelines, see `input/AGENTS.md`.
|
||||
2
input/templates/.gitignore
vendored
Normal file
2
input/templates/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
ResumeCustomizerPrompt.md
|
||||
!ResumeCustomizerPrompt.md.example
|
||||
@@ -1,3 +1,7 @@
|
||||
# Default instructions for Codex-driven resume customization. Copy this file to
|
||||
# `ResumeCustomizerPrompt.md` (without the `.example` suffix) and edit it to
|
||||
# override the defaults for your workflow.
|
||||
|
||||
I need you to customize my resume for this job description:
|
||||
|
||||
. “Act like a recruiter in information technology. What’s missing from this résumé that would stop you from reaching out?”
|
||||
@@ -7,6 +11,3 @@ I need you to customize my resume for this job description:
|
||||
. “Add ATS keywords from this job post without sounding robotic.”
|
||||
. “Format this résumé so it’s easy to scan, clean to read, and works on any system.”
|
||||
. “Write a short, punchy message I can DM a hiring manager. No desperation, just value.”
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user