Implements docs/api-contract-v2.md. Migration 002 adds follow-up fields. EchoTransport replaced by SMTP->Clipboard selection behind unchanged approval gate. Scheduler (APScheduler) daily 07:00 fetch+score, env-gated, default off. Test image installs workspace packages; build context moved to repo root. Recovered and committed by integration lead after W2 worker hit iteration limit.
485 lines
No EOL
14 KiB
Python
485 lines
No EOL
14 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 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,
|
|
}
|
|
|
|
|
|
# --- 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] |