537 lines
No EOL
16 KiB
Python
537 lines
No EOL
16 KiB
Python
"""Repository functions for job_posting, application, artifact, approval, outbox, task_run."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
from app.db import execute, fetch_all, fetch_one
|
|
|
|
|
|
# --- Job Posting ---
|
|
|
|
def create_job_posting(
|
|
source: str,
|
|
url: str,
|
|
company: str,
|
|
title: str,
|
|
location: str | None = None,
|
|
description: str = "",
|
|
external_id: str | None = None,
|
|
raw: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
row = execute(
|
|
"""
|
|
INSERT INTO job_posting (source, external_id, url, company, title, location, description, raw)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
|
ON CONFLICT (source, url) DO UPDATE SET
|
|
company = EXCLUDED.company,
|
|
title = EXCLUDED.title,
|
|
fetched_at = now()
|
|
RETURNING *
|
|
""",
|
|
(
|
|
source,
|
|
external_id,
|
|
url,
|
|
company,
|
|
title,
|
|
location,
|
|
description,
|
|
json.dumps(raw or {}),
|
|
),
|
|
)
|
|
if row is None:
|
|
raise RuntimeError("insert job_posting failed")
|
|
return _normalize_posting(row)
|
|
|
|
|
|
def get_job_posting(posting_id: str) -> dict[str, Any] | None:
|
|
row = fetch_one("SELECT * FROM job_posting WHERE id = %s", (posting_id,))
|
|
if row is None:
|
|
return None
|
|
return _normalize_posting(row)
|
|
|
|
|
|
def update_posting_cluster_id(posting_id: str, cluster_id: str) -> dict[str, Any] | None:
|
|
"""Set the cluster_id on a job posting."""
|
|
row = execute(
|
|
"UPDATE job_posting SET cluster_id = %s WHERE id = %s RETURNING *",
|
|
(cluster_id, posting_id),
|
|
)
|
|
if row is None:
|
|
return None
|
|
return _normalize_posting(row)
|
|
|
|
|
|
def update_posting_apply_by(posting_id: str, apply_by: Any) -> dict[str, Any] | None:
|
|
"""Set the apply_by date on a job posting."""
|
|
row = execute(
|
|
"UPDATE job_posting SET apply_by = %s WHERE id = %s RETURNING *",
|
|
(apply_by, posting_id),
|
|
)
|
|
if row is None:
|
|
return None
|
|
return _normalize_posting(row)
|
|
|
|
|
|
def list_postings() -> list[dict[str, Any]]:
|
|
rows = fetch_all("SELECT * FROM job_posting ORDER BY fetched_at DESC")
|
|
return [_normalize_posting(r) for r in rows]
|
|
|
|
|
|
def _normalize_posting(row: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"id": str(row["id"]),
|
|
"source": row["source"],
|
|
"external_id": row.get("external_id"),
|
|
"url": row["url"],
|
|
"company": row["company"],
|
|
"title": row["title"],
|
|
"location": row.get("location"),
|
|
"description": row.get("description", ""),
|
|
"fetched_at": row["fetched_at"].isoformat() if row.get("fetched_at") else None,
|
|
"cluster_id": row.get("cluster_id"),
|
|
"apply_by": row.get("apply_by").isoformat() if row.get("apply_by") else None,
|
|
}
|
|
|
|
|
|
# --- Application ---
|
|
|
|
def create_application(job_posting_id: str) -> dict[str, Any]:
|
|
row = execute(
|
|
"""
|
|
INSERT INTO application (job_posting_id, state)
|
|
VALUES (%s, 'discovered')
|
|
RETURNING *
|
|
""",
|
|
(job_posting_id,),
|
|
)
|
|
if row is None:
|
|
raise RuntimeError("insert application failed")
|
|
return _normalize_application(row)
|
|
|
|
|
|
def get_application(app_id: str) -> dict[str, Any] | None:
|
|
row = fetch_one(
|
|
"""
|
|
SELECT a.*, j.company, j.title, j.location
|
|
FROM application a
|
|
JOIN job_posting j ON a.job_posting_id = j.id
|
|
WHERE a.id = %s
|
|
""",
|
|
(app_id,),
|
|
)
|
|
if row is None:
|
|
return None
|
|
return _normalize_application(row)
|
|
|
|
|
|
def list_applications() -> list[dict[str, Any]]:
|
|
rows = fetch_all(
|
|
"""
|
|
SELECT a.*, j.company, j.title, j.location
|
|
FROM application a
|
|
JOIN job_posting j ON a.job_posting_id = j.id
|
|
ORDER BY a.created_at DESC
|
|
"""
|
|
)
|
|
return [_normalize_application(r) for r in rows]
|
|
|
|
|
|
def update_application_state(
|
|
app_id: str,
|
|
new_state: str,
|
|
) -> dict[str, Any] | None:
|
|
row = execute(
|
|
"""
|
|
UPDATE application
|
|
SET state = %s, state_changed_at = now(), last_activity_at = now()
|
|
WHERE id = %s
|
|
RETURNING *
|
|
""",
|
|
(new_state, app_id),
|
|
)
|
|
if row is None:
|
|
return None
|
|
return _normalize_application(row)
|
|
|
|
|
|
def update_application_score(
|
|
app_id: str,
|
|
score: float,
|
|
rationale: dict[str, Any],
|
|
) -> dict[str, Any] | None:
|
|
row = execute(
|
|
"""
|
|
UPDATE application
|
|
SET score = %s, score_rationale = %s, state = 'scored', state_changed_at = now()
|
|
WHERE id = %s
|
|
RETURNING *
|
|
""",
|
|
(score, json.dumps(rationale), app_id),
|
|
)
|
|
if row is None:
|
|
return None
|
|
return _normalize_application(row)
|
|
|
|
|
|
def _normalize_application(row: dict[str, Any]) -> dict[str, Any]:
|
|
rationale = row.get("score_rationale")
|
|
if rationale is not None and not isinstance(rationale, dict):
|
|
rationale = json.loads(rationale) if isinstance(rationale, str) else rationale
|
|
return {
|
|
"id": str(row["id"]),
|
|
"job_posting_id": str(row["job_posting_id"]),
|
|
"state": row["state"],
|
|
"score": float(row["score"]) if row.get("score") is not None else None,
|
|
"score_rationale": rationale,
|
|
"notes": row.get("notes"),
|
|
"state_changed_at": row["state_changed_at"].isoformat() if row.get("state_changed_at") else None,
|
|
"created_at": row["created_at"].isoformat() if row.get("created_at") else None,
|
|
"company": row.get("company"),
|
|
"title": row.get("title"),
|
|
"location": row.get("location"),
|
|
"follow_up_after_days": row.get("follow_up_after_days", 7),
|
|
"last_activity_at": row["last_activity_at"].isoformat() if row.get("last_activity_at") is not None else None,
|
|
"follow_up_snoozed_until": row.get("follow_up_snoozed_until").isoformat() if row.get("follow_up_snoozed_until") else None,
|
|
"interview_prep_artifact_id": str(row["interview_prep_artifact_id"]) if row.get("interview_prep_artifact_id") else None,
|
|
}
|
|
|
|
|
|
# --- Artifact ---
|
|
|
|
def create_artifact(
|
|
application_id: str,
|
|
kind: str,
|
|
filename: str,
|
|
content_bytes: bytes,
|
|
storage_path: str,
|
|
origin: str = "ai_reviewed",
|
|
version: int = 1,
|
|
) -> dict[str, Any]:
|
|
content_hash = hashlib.sha256(content_bytes).hexdigest()
|
|
row = execute(
|
|
"""
|
|
INSERT INTO artifact (application_id, kind, filename, content_hash, storage_path, version, origin)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
RETURNING *
|
|
""",
|
|
(application_id, kind, filename, content_hash, storage_path, version, origin),
|
|
)
|
|
if row is None:
|
|
raise RuntimeError("insert artifact failed")
|
|
return _normalize_artifact(row)
|
|
|
|
|
|
def list_artifacts(application_id: str) -> list[dict[str, Any]]:
|
|
rows = fetch_all(
|
|
"SELECT * FROM artifact WHERE application_id = %s ORDER BY created_at DESC",
|
|
(application_id,),
|
|
)
|
|
return [_normalize_artifact(r) for r in rows]
|
|
|
|
|
|
def get_artifact(artifact_id: str) -> dict[str, Any] | None:
|
|
row = fetch_one("SELECT * FROM artifact WHERE id = %s", (artifact_id,))
|
|
if row is None:
|
|
return None
|
|
return _normalize_artifact(row)
|
|
|
|
|
|
def _normalize_artifact(row: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"id": str(row["id"]),
|
|
"application_id": str(row["application_id"]),
|
|
"kind": row["kind"],
|
|
"filename": row["filename"],
|
|
"content_hash": row["content_hash"],
|
|
"storage_path": row["storage_path"],
|
|
"version": row["version"],
|
|
"origin": row["origin"],
|
|
}
|
|
|
|
|
|
# --- Approval ---
|
|
|
|
def create_approval(
|
|
application_id: str,
|
|
artifact_id: str,
|
|
artifact_hash: str,
|
|
action: str,
|
|
) -> dict[str, Any]:
|
|
row = execute(
|
|
"""
|
|
INSERT INTO approval (application_id, artifact_id, artifact_hash, action, expires_at)
|
|
VALUES (%s, %s, %s, %s, now() + interval '24 hours')
|
|
RETURNING *
|
|
""",
|
|
(application_id, artifact_id, artifact_hash, action),
|
|
)
|
|
if row is None:
|
|
raise RuntimeError("insert approval failed")
|
|
return _normalize_approval(row)
|
|
|
|
|
|
def get_approval(approval_id: str) -> dict[str, Any] | None:
|
|
row = fetch_one("SELECT * FROM approval WHERE id = %s", (approval_id,))
|
|
if row is None:
|
|
return None
|
|
return _normalize_approval(row)
|
|
|
|
|
|
def confirm_approval(approval_id: str) -> dict[str, Any] | None:
|
|
row = execute(
|
|
"""
|
|
UPDATE approval
|
|
SET confirmed_by_user = true, confirmed_at = now()
|
|
WHERE id = %s
|
|
RETURNING *
|
|
""",
|
|
(approval_id,),
|
|
)
|
|
if row is None:
|
|
return None
|
|
return _normalize_approval(row)
|
|
|
|
|
|
def reject_approval(approval_id: str) -> dict[str, Any] | None:
|
|
row = execute(
|
|
"""
|
|
UPDATE approval
|
|
SET confirmed_by_user = false, confirmed_at = now()
|
|
WHERE id = %s
|
|
RETURNING *
|
|
""",
|
|
(approval_id,),
|
|
)
|
|
if row is None:
|
|
return None
|
|
return _normalize_approval(row)
|
|
|
|
|
|
def _normalize_approval(row: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"id": str(row["id"]),
|
|
"application_id": str(row["application_id"]),
|
|
"artifact_id": str(row["artifact_id"]),
|
|
"artifact_hash": row["artifact_hash"],
|
|
"action": row["action"],
|
|
"confirmed_by_user": row["confirmed_by_user"],
|
|
"confirmed_at": row["confirmed_at"].isoformat() if row.get("confirmed_at") else None,
|
|
"expires_at": row["expires_at"].isoformat() if row.get("expires_at") else None,
|
|
"created_at": row["created_at"].isoformat() if row.get("created_at") else None,
|
|
}
|
|
|
|
|
|
# --- Outbox ---
|
|
|
|
def create_outbox(approval_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
row = execute(
|
|
"""
|
|
INSERT INTO outbox (approval_id, payload, status)
|
|
VALUES (%s, %s, 'pending')
|
|
RETURNING *
|
|
""",
|
|
(approval_id, json.dumps(payload)),
|
|
)
|
|
if row is None:
|
|
raise RuntimeError("insert outbox failed")
|
|
return _normalize_outbox(row)
|
|
|
|
|
|
def update_outbox_sent(outbox_id: str) -> dict[str, Any] | None:
|
|
row = execute(
|
|
"""
|
|
UPDATE outbox
|
|
SET status = 'sent', sent_at = now()
|
|
WHERE id = %s
|
|
RETURNING *
|
|
""",
|
|
(outbox_id,),
|
|
)
|
|
if row is None:
|
|
return None
|
|
return _normalize_outbox(row)
|
|
|
|
|
|
def _normalize_outbox(row: dict[str, Any]) -> dict[str, Any]:
|
|
payload = row.get("payload")
|
|
if payload is not None and not isinstance(payload, dict):
|
|
payload = json.loads(payload) if isinstance(payload, str) else payload
|
|
return {
|
|
"id": str(row["id"]),
|
|
"approval_id": str(row["approval_id"]),
|
|
"kind": row.get("kind", "email"),
|
|
"payload": payload or {},
|
|
"status": row["status"],
|
|
"sent_at": row["sent_at"].isoformat() if row.get("sent_at") else None,
|
|
"error": row.get("error"),
|
|
}
|
|
|
|
|
|
# --- Task Run (telemetry) ---
|
|
|
|
def create_task_run(data: dict[str, Any]) -> dict[str, Any]:
|
|
row = execute(
|
|
"""
|
|
INSERT INTO task_run (task, model, provider, input_tokens, output_tokens, cost_usd, duration_ms, application_id)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
|
RETURNING *
|
|
""",
|
|
(
|
|
data["task"],
|
|
data["model"],
|
|
data["provider"],
|
|
data["input_tokens"],
|
|
data["output_tokens"],
|
|
data.get("cost_usd"),
|
|
data["duration_ms"],
|
|
data.get("application_id"),
|
|
),
|
|
)
|
|
if row is None:
|
|
raise RuntimeError("insert task_run failed")
|
|
return _normalize_task_run(row)
|
|
|
|
|
|
def list_task_runs() -> list[dict[str, Any]]:
|
|
rows = fetch_all("SELECT * FROM task_run ORDER BY created_at DESC LIMIT 100")
|
|
return [_normalize_task_run(r) for r in rows]
|
|
|
|
|
|
def _normalize_task_run(row: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"id": str(row["id"]),
|
|
"task": row["task"],
|
|
"model": row["model"],
|
|
"provider": row["provider"],
|
|
"input_tokens": row["input_tokens"],
|
|
"output_tokens": row["output_tokens"],
|
|
"cost_usd": float(row["cost_usd"]) if row.get("cost_usd") is not None else None,
|
|
"duration_ms": row["duration_ms"],
|
|
"application_id": str(row["application_id"]) if row.get("application_id") else None,
|
|
"created_at": row["created_at"].isoformat() if row.get("created_at") else None,
|
|
}
|
|
|
|
|
|
# --- Follow-up nudges ---
|
|
|
|
def get_nudge_applications() -> list[dict[str, Any]]:
|
|
"""Return applications in 'sent' state past follow_up_after_days and not snoozed."""
|
|
rows = fetch_all(
|
|
"""
|
|
SELECT a.*, j.company, j.title, j.location,
|
|
(now() - a.last_activity_at) AS elapsed
|
|
FROM application a
|
|
JOIN job_posting j ON a.job_posting_id = j.id
|
|
WHERE a.state = 'sent'
|
|
AND EXTRACT(day FROM now() - a.last_activity_at) > a.follow_up_after_days
|
|
AND (a.follow_up_snoozed_until IS NULL OR a.follow_up_snoozed_until < CURRENT_DATE)
|
|
ORDER BY a.last_activity_at ASC
|
|
"""
|
|
)
|
|
results: list[dict[str, Any]] = []
|
|
for row in rows:
|
|
app = _normalize_application(row)
|
|
elapsed = row.get("elapsed")
|
|
days = None
|
|
if elapsed is not None:
|
|
days = abs(int(elapsed.days))
|
|
app["days_since_sent"] = days
|
|
results.append(app)
|
|
return results
|
|
|
|
|
|
def snooze_follow_up(app_id: str, until_date: Any) -> dict[str, Any] | None:
|
|
"""Snooze follow-up nudge for an application until a given date."""
|
|
row = execute(
|
|
"UPDATE application SET follow_up_snoozed_until = %s WHERE id = %s RETURNING *",
|
|
(until_date, app_id),
|
|
)
|
|
if row is None:
|
|
return None
|
|
return _normalize_application(row)
|
|
|
|
|
|
# --- Interview prep ---
|
|
|
|
def set_interview_prep_artifact(app_id: str, artifact_id: str) -> dict[str, Any] | None:
|
|
"""Link an interview prep artifact to the application."""
|
|
row = execute(
|
|
"""
|
|
UPDATE application
|
|
SET interview_prep_artifact_id = %s, last_activity_at = now()
|
|
WHERE id = %s
|
|
RETURNING *
|
|
""",
|
|
(artifact_id, app_id),
|
|
)
|
|
if row is None:
|
|
return None
|
|
return _normalize_application(row)
|
|
|
|
|
|
# --- Pending approvals count ---
|
|
|
|
def count_pending_approvals() -> int:
|
|
"""Count approvals that are not yet confirmed and not expired."""
|
|
row = fetch_one(
|
|
"""
|
|
SELECT count(*) AS cnt
|
|
FROM approval
|
|
WHERE confirmed_by_user = false
|
|
AND expires_at > now()
|
|
"""
|
|
)
|
|
if row is None:
|
|
return 0
|
|
return int(row["cnt"])
|
|
|
|
|
|
# --- Digest (scored applications, top by score) ---
|
|
|
|
def get_digest(limit: int = 20) -> list[dict[str, Any]]:
|
|
"""Return scored applications ordered by score descending, with posting info."""
|
|
rows = fetch_all(
|
|
"""
|
|
SELECT a.*, j.company, j.title, j.location
|
|
FROM application a
|
|
JOIN job_posting j ON a.job_posting_id = j.id
|
|
WHERE a.score IS NOT NULL
|
|
ORDER BY a.score DESC NULLS LAST
|
|
LIMIT %s
|
|
""",
|
|
(limit,),
|
|
)
|
|
return [_normalize_application(r) for r in rows]
|
|
|
|
|
|
def get_upcoming_deadlines(days: int = 7) -> list[dict[str, Any]]:
|
|
"""Return applications whose job_posting has apply_by within the next *days* days.
|
|
|
|
Returns list of dicts: {application_id, title, company, apply_by}.
|
|
"""
|
|
rows = fetch_all(
|
|
"""
|
|
SELECT a.id AS application_id, j.title, j.company, j.apply_by
|
|
FROM application a
|
|
JOIN job_posting j ON a.job_posting_id = j.id
|
|
WHERE j.apply_by IS NOT NULL
|
|
AND j.apply_by >= CURRENT_DATE
|
|
AND j.apply_by <= CURRENT_DATE + %s * INTERVAL '1 day'
|
|
ORDER BY j.apply_by ASC
|
|
""",
|
|
(days,),
|
|
)
|
|
result: list[dict[str, Any]] = []
|
|
for row in rows:
|
|
result.append({
|
|
"application_id": str(row["application_id"]),
|
|
"title": row["title"],
|
|
"company": row["company"],
|
|
"apply_by": row["apply_by"].isoformat() if row.get("apply_by") else None,
|
|
})
|
|
return result |