feat(input): add codex automation stack

This commit is contained in:
2025-10-15 16:32:26 -05:00
parent e0816486cb
commit 8bf8784d52
10 changed files with 625 additions and 3 deletions

29
input/Docker/Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
FROM node:20-bookworm
ENV DEBIAN_FRONTEND=noninteractive \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PUID=1000 \
PGID=1000 \
CODEX_HOME=/home/codex
RUN apt-get update \
&& apt-get install --yes --no-install-recommends \
python3 \
python3-venv \
gosu \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN npm install --location=global codex-cli || true
RUN groupadd -r codex && \
useradd -r -m -g codex -s /bin/bash codex
WORKDIR /app
COPY watch_and_customize.py entrypoint.sh ./
RUN chmod +x /app/watch_and_customize.py /app/entrypoint.sh
ENTRYPOINT ["/app/entrypoint.sh"]

View 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:
PUID: "${LOCAL_UID:-1000}"
PGID: "${LOCAL_GID:-1000}"
POLL_INTERVAL_SECONDS: "${POLL_INTERVAL_SECONDS:-5}"
CODEX_TIMEOUT_SECONDS: "${CODEX_TIMEOUT_SECONDS:-600}"
CODEX_COMMAND_TEMPLATE: "${CODEX_COMMAND_TEMPLATE:-codex prompt --input {prompt} --output {output} --format markdown}"
volumes:
- ../ForCustomizing/inbox:/workspace/inbox
- ../ForCustomizing/outbox:/workspace/outbox
- ../ForCustomizing/processed:/workspace/processed
- ../ForCustomizing/failed:/workspace/failed
- ../resume:/workspace/resume:ro
- ../templates:/templates:ro
- ${CODEX_CONFIG_DIR:-/workspace/.codex}:/home/codex/.codex

58
input/Docker/entrypoint.sh Executable file
View File

@@ -0,0 +1,58 @@
#!/usr/bin/env bash
set -euo pipefail
USER_NAME=codex
PUID=${PUID:-1000}
PGID=${PGID:-1000}
ensure_group() {
local desired_gid=$1
local group_name
if getent group "${desired_gid}" >/dev/null 2>&1; then
group_name=$(getent group "${desired_gid}" | cut -d: -f1)
echo "${group_name}"
return 0
fi
if getent group "${USER_NAME}" >/dev/null 2>&1; then
groupmod -o -g "${desired_gid}" "${USER_NAME}"
echo "${USER_NAME}"
return 0
fi
groupadd -o -g "${desired_gid}" "${USER_NAME}"
echo "${USER_NAME}"
}
ensure_user() {
local desired_uid=$1
local primary_group=$2
if getent passwd "${USER_NAME}" >/dev/null 2>&1; then
usermod -o -u "${desired_uid}" -g "${primary_group}" -d "/home/${USER_NAME}" -s /bin/bash "${USER_NAME}"
else
useradd -o -m -u "${desired_uid}" -g "${primary_group}" -s /bin/bash "${USER_NAME}"
fi
}
GROUP_NAME=$(ensure_group "${PGID}")
ensure_user "${PUID}" "${GROUP_NAME}"
USER_HOME=$(eval echo "~${USER_NAME}")
mkdir -p /workspace/inbox /workspace/outbox /workspace/processed /workspace/failed
mkdir -p "${USER_HOME}/.codex"
for path in /workspace/inbox /workspace/outbox /workspace/processed /workspace/failed "${USER_HOME}" "${USER_HOME}/.codex"; do
if [ -e "${path}" ]; then
chown -R "${PUID}:${PGID}" "${path}"
fi
done
export HOME="${USER_HOME}"
export XDG_CACHE_HOME="${USER_HOME}/.cache"
mkdir -p "${XDG_CACHE_HOME}"
chown -R "${PUID}:${PGID}" "${XDG_CACHE_HOME}"
exec gosu "${PUID}:${PGID}" python3 /app/watch_and_customize.py

View File

@@ -0,0 +1,35 @@
#!/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 -v 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
CALLER_UID=$(id -u)
CALLER_GID=$(id -g)
DEFAULT_CODEX_CONFIG="${HOME}/.codex"
CODEX_CONFIG_DIR=${CODEX_CONFIG_DIR:-${DEFAULT_CODEX_CONFIG}}
mkdir -p "${CODEX_CONFIG_DIR}"
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
(
cd "${SCRIPT_DIR}"
LOCAL_UID="${CALLER_UID}" \
LOCAL_GID="${CALLER_GID}" \
CODEX_CONFIG_DIR="${CODEX_CONFIG_DIR}" \
"${COMPOSE_CMD[@]}" "$@"
)

View File

@@ -0,0 +1,342 @@
#!/usr/bin/env python3
"""
Monitor the customization inbox for job description Markdown files and run the Codex CLI
to produce tailored resumes.
The script expects exactly one base resume Markdown file and processes one job file at a
time. After a successful Codex run, the generated resume is written into a timestamped
outbox folder and the job description is archived under processed/. Failures move the
job description into failed/.
"""
from __future__ import annotations
import logging
import os
import shlex
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"
POLL_INTERVAL_SECONDS = int(os.environ.get("POLL_INTERVAL_SECONDS", "5"))
CODEX_COMMAND_TEMPLATE = os.environ.get(
"CODEX_COMMAND_TEMPLATE",
"codex prompt --input {prompt} --output {output} --format markdown",
)
CODEX_TIMEOUT_SECONDS = int(os.environ.get("CODEX_TIMEOUT_SECONDS", "600"))
RESOLVED_PROMPT_TEMPLATE: Path | None = None
class FatalConfigurationError(RuntimeError):
"""Raised when the watcher encounters a non-recoverable configuration problem."""
@dataclass(frozen=True)
class MarkdownInputs:
resume: Path
job_description: Path
prompt_template: Path
def ensure_environment() -> None:
"""Verify required directories and template assets exist."""
global RESOLVED_PROMPT_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_prompt_template(
PROMPT_TEMPLATE,
PROMPT_TEMPLATE_EXAMPLE,
TEMPLATE_CACHE,
)
def resolve_prompt_template(primary: Path, example: Path, cache_dir: Path) -> Path:
"""Return the prompt 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"Prompt template 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(md_files: Sequence[Path]) -> Path | None:
"""Validate there is at most one job description file."""
if not md_files:
return None
if len(md_files) > 1:
names = ", ".join(p.name for p in md_files)
raise FatalConfigurationError(
f"Multiple job description files detected in inbox: {names} "
"— expected exactly one."
)
return md_files[0]
def read_inputs(job_file: Path) -> MarkdownInputs:
"""Gather and return all markdown inputs required for the prompt."""
resume = ensure_single_resume()
missing = [str(path) for path in (job_file,) if not path.exists()]
if missing:
raise FatalConfigurationError(
"Required files disappeared before processing: " + ", ".join(missing)
)
if RESOLVED_PROMPT_TEMPLATE is None:
raise FatalConfigurationError("Prompt template was not resolved during startup.")
return MarkdownInputs(
resume=resume,
job_description=job_file,
prompt_template=RESOLVED_PROMPT_TEMPLATE,
)
def build_prompt_text(inputs: MarkdownInputs) -> str:
"""Return the combined prompt string fed to the Codex CLI."""
resume_text = inputs.resume.read_text(encoding="utf-8").strip()
jd_text = inputs.job_description.read_text(encoding="utf-8").strip()
instructions_text = inputs.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"{jd_text}\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 run_codex(prompt_path: Path, output_path: Path) -> None:
"""Execute the Codex CLI using the configured command template."""
command_text = CODEX_COMMAND_TEMPLATE.format(
prompt=str(prompt_path),
output=str(output_path),
)
logging.info("Running Codex CLI command: %s", command_text)
try:
command = shlex.split(command_text)
except ValueError as exc:
raise FatalConfigurationError(
f"Unable to parse CODEX_COMMAND_TEMPLATE into arguments: {exc}"
) from exc
try:
subprocess.run(
command,
check=True,
timeout=CODEX_TIMEOUT_SECONDS,
env=os.environ.copy(),
)
except FileNotFoundError as exc:
raise FatalConfigurationError(
f"Executable not found while running Codex CLI command: {command[0]}"
) 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 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:
"""Combine inputs, run Codex, and archive outputs."""
timestamp = datetime.now().astimezone()
out_dir = build_timestamp_dir(OUTBOX, timestamp)
processed_dir = build_timestamp_dir(PROCESSED, timestamp)
inputs = read_inputs(job_file)
prompt_text = build_prompt_text(inputs)
safe_resume_stem = sanitize_stem(inputs.resume.stem)
safe_job_stem = sanitize_stem(job_file.stem)
output_filename = f"{safe_resume_stem}-for-{safe_job_stem}.md"
with TemporaryDirectory() as tmp_dir_str:
tmp_dir = Path(tmp_dir_str)
prompt_path = tmp_dir / "prompt.md"
prompt_path.write_text(prompt_text, encoding="utf-8")
output_path = tmp_dir / "codex_output.md"
run_codex(prompt_path, output_path)
generated_output = out_dir / output_filename
counter = 1
while generated_output.exists():
generated_output = out_dir / f"{safe_resume_stem}-for-{safe_job_stem}_{counter}.md"
counter += 1
shutil.move(str(output_path), generated_output)
logging.info("Generated customized resume at %s", generated_output)
prompt_archive = out_dir / f"prompt-{safe_job_stem}.md"
prompt_archive.write_text(prompt_text, 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_files = sorted(INBOX.glob("*.md"))
try:
job_file = ensure_single_job(job_files)
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()