feat(input): add codex automation stack
This commit is contained in:
29
input/Docker/Dockerfile
Normal file
29
input/Docker/Dockerfile
Normal 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"]
|
||||
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:
|
||||
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
58
input/Docker/entrypoint.sh
Executable 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
|
||||
35
input/Docker/run-input-processor.sh
Executable file
35
input/Docker/run-input-processor.sh
Executable 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[@]}" "$@"
|
||||
)
|
||||
342
input/Docker/watch_and_customize.py
Executable file
342
input/Docker/watch_and_customize.py
Executable 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()
|
||||
Reference in New Issue
Block a user