${item.name}
+${item.summary}
+diff --git a/.gitignore b/.gitignore index e69de29b..f73693ab 100644 --- a/.gitignore +++ b/.gitignore @@ -0,0 +1,2 @@ +data.sqlite3 +.idea/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 00000000..3bb65501 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,78 @@ +# Architecture Overview + +## System Structure +- `app/main.py`: FastAPI app bootstrap, startup init, and static file mounting. +- `app/db.py`: SQLite connection management and ranking operations (insert, move, delete). +- `app/schemas.py`: Pydantic request/response models. +- `app/models.py`: Lightweight domain model for applications. +- `app/routers/applications.py`: API endpoints for queue operations. +- `static/`: Frontend UI (HTML/CSS/JS) served by FastAPI. +- `tests/`: Pytest coverage for ranking workflows. + +## Data Model +Single SQLite table `applications`: +- `id` (auto-increment) +- `name` +- `summary` +- `position` (1-based rank) + +`position` is treated as the canonical ranking, with reindexing on insert/move/delete. + +## Key Design Decisions +- Integer ranking with reindexing: + - Keeps ordering deterministic and easy to reason about. + - Avoids fractional rank drift and simplifies constraints. +- SQLite for persistence: + - Zero-config local storage fits assessment constraints. + - Simple to reset or inspect via a single file. +- Frontend as static assets served by FastAPI: + - Minimal setup and no build step. + - Keeps deployment and local run straightforward. +- Backend validates bounds, frontend clamps: + - UI clamps move requests to `[1, max]` for convenience. + - Backend still enforces range to avoid invalid data. + +## Sample Commands + +### List Applications +```powershell +curl http://127.0.0.1:8000/applications +``` + +### Insert at End +```powershell +curl -X POST http://127.0.0.1:8000/applications/insert ^ + -H "Content-Type: application/json" ^ + -d "{\"name\":\"Jane Doe\",\"summary\":\"Small business line of credit\",\"placement\":\"end\"}" +``` + +### Insert at Start +```powershell +curl -X POST http://127.0.0.1:8000/applications/insert ^ + -H "Content-Type: application/json" ^ + -d "{\"name\":\"Urgent Review\",\"summary\":\"Escalated request\",\"placement\":\"start\"}" +``` + +### Insert Between Two Items +```powershell +curl -X POST http://127.0.0.1:8000/applications/insert ^ + -H "Content-Type: application/json" ^ + -d "{\"name\":\"Priority Insert\",\"summary\":\"Manual triage\",\"placement\":\"between\",\"before_id\":1,\"after_id\":2}" +``` + +### Move (Change Rank) +```powershell +curl -X POST http://127.0.0.1:8000/applications/3/move ^ + -H "Content-Type: application/json" ^ + -d "{\"new_position\":1}" +``` + +### Delete +```powershell +curl -X DELETE http://127.0.0.1:8000/applications/3 +``` + +## Testing Focus +- Ordering after inserts (start/end/between). +- Reindexing after moves and deletes. +- Error handling for invalid moves and unknown IDs is enforced by the API. \ No newline at end of file diff --git a/RUNNING.md b/RUNNING.md new file mode 100644 index 00000000..77376bda --- /dev/null +++ b/RUNNING.md @@ -0,0 +1,26 @@ +# Run Guide + +## Prerequisites +- Python 3.11+ (recommended) +- pip + +## Install Dependencies +```powershell +python -m pip install -r requirements.txt +``` + +## Run the Backend API +```powershell +python -m uvicorn app.main:app --reload +``` + +The API will be available at `http://127.0.0.1:8000/` and the frontend will load from the same address. + +## Run Tests +```powershell +python -m pytest +``` + +## Notes +- SQLite data is stored in `data.sqlite3` at the repo root. +- Tests use a temporary SQLite file via the `APP_DB_PATH` environment variable. \ No newline at end of file diff --git a/SPECS/credit-risk-review-queue.md b/SPECS/credit-risk-review-queue.md new file mode 100644 index 00000000..5bf30cf2 --- /dev/null +++ b/SPECS/credit-risk-review-queue.md @@ -0,0 +1,35 @@ +# Feature Spec: Credit Risk Review Queue Ranking + +Fulfilled via OpenAI Codex + +## Goal +- Provide a full-stack app to manage and reprioritize credit application reviews by ranked order. + +## Scope +- In: + - Full ranking view of all applications with rank, applicant name, and summary. + - Insert a new application at the start, end, or between two applications. + - Remove an application from the queue. + - Change an application's rank. + - Persist data in SQLite. +- Out: + - Authentication, authorization, or multi-user roles. + - External credit bureau integrations. + - Complex scoring models beyond manual ranking. + +## Requirements +- Backend built with Python + FastAPI. +- Database using SQLite. +- REST endpoints to list, insert (start/end/between), move, and delete applications. +- Frontend that loads the full ranking and supports the above actions. +- Tests that cover key workflows and edge cases for ranking changes. + +## Acceptance Criteria +- [ ] Viewing the main page shows the full ranking with each item's rank, applicant name, and summary. +- [ ] A user can insert a new application at the start of the list. +- [ ] A user can insert a new application at the end of the list. +- [ ] A user can insert a new application between two existing applications. +- [ ] A user can remove an application from the queue. +- [ ] A user can change the rank of an application and the list reflects the new order. +- [ ] Data persists in SQLite across server restarts. +- [ ] Tests validate list ordering, insertions, deletions, and rank changes. \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/db.py b/app/db.py new file mode 100644 index 00000000..6c20df96 --- /dev/null +++ b/app/db.py @@ -0,0 +1,190 @@ +import os +import sqlite3 +from pathlib import Path + +DEFAULT_DB_PATH = Path(__file__).resolve().parent.parent / "data.sqlite3" +DB_PATH = Path(os.environ.get("APP_DB_PATH", DEFAULT_DB_PATH)) + + +def get_connection() -> sqlite3.Connection: + conn = sqlite3.connect(DB_PATH, check_same_thread=False) + conn.row_factory = sqlite3.Row + return conn + + +def init_db() -> None: + conn = get_connection() + try: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS applications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + summary TEXT NOT NULL, + position INTEGER NOT NULL + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_applications_position ON applications(position)" + ) + conn.commit() + finally: + conn.close() + + +def fetch_all_applications() -> list[sqlite3.Row]: + conn = get_connection() + try: + rows = conn.execute( + "SELECT id, name, summary, position FROM applications ORDER BY position ASC" + ).fetchall() + return rows + finally: + conn.close() + + +def get_application_by_id(application_id: int) -> sqlite3.Row | None: + conn = get_connection() + try: + row = conn.execute( + "SELECT id, name, summary, position FROM applications WHERE id = ?", + (application_id,), + ).fetchone() + return row + finally: + conn.close() + + +def get_max_position(conn: sqlite3.Connection) -> int: + row = conn.execute("SELECT MAX(position) AS max_pos FROM applications").fetchone() + if row is None or row["max_pos"] is None: + return 0 + return int(row["max_pos"]) + + +def insert_application_at_position(name: str, summary: str, position: int) -> int: + conn = get_connection() + try: + conn.execute( + "UPDATE applications SET position = position + 1 WHERE position >= ?", + (position,), + ) + cursor = conn.execute( + "INSERT INTO applications (name, summary, position) VALUES (?, ?, ?)", + (name, summary, position), + ) + conn.commit() + return int(cursor.lastrowid) + finally: + conn.close() + + +def insert_application_start(name: str, summary: str) -> int: + return insert_application_at_position(name, summary, 1) + + +def insert_application_end(name: str, summary: str) -> int: + conn = get_connection() + try: + position = get_max_position(conn) + 1 + cursor = conn.execute( + "INSERT INTO applications (name, summary, position) VALUES (?, ?, ?)", + (name, summary, position), + ) + conn.commit() + return int(cursor.lastrowid) + finally: + conn.close() + + +def insert_application_between( + name: str, summary: str, before_id: int, after_id: int +) -> int: + conn = get_connection() + try: + before_row = conn.execute( + "SELECT position FROM applications WHERE id = ?", (before_id,) + ).fetchone() + after_row = conn.execute( + "SELECT position FROM applications WHERE id = ?", (after_id,) + ).fetchone() + if before_row is None or after_row is None: + raise ValueError("before_id or after_id not found") + + before_pos = int(before_row["position"]) + after_pos = int(after_row["position"]) + if before_pos >= after_pos: + raise ValueError("before_id must be ranked before after_id") + + insert_pos = before_pos + 1 + conn.execute( + "UPDATE applications SET position = position + 1 WHERE position >= ?", + (insert_pos,), + ) + cursor = conn.execute( + "INSERT INTO applications (name, summary, position) VALUES (?, ?, ?)", + (name, summary, insert_pos), + ) + conn.commit() + return int(cursor.lastrowid) + finally: + conn.close() + + +def delete_application(application_id: int) -> None: + conn = get_connection() + try: + row = conn.execute( + "SELECT position FROM applications WHERE id = ?", (application_id,) + ).fetchone() + if row is None: + raise ValueError("application not found") + position = int(row["position"]) + conn.execute("DELETE FROM applications WHERE id = ?", (application_id,)) + conn.execute( + "UPDATE applications SET position = position - 1 WHERE position > ?", + (position,), + ) + conn.commit() + finally: + conn.close() + + +def move_application(application_id: int, new_position: int) -> None: + conn = get_connection() + try: + row = conn.execute( + "SELECT position FROM applications WHERE id = ?", (application_id,) + ).fetchone() + if row is None: + raise ValueError("application not found") + + current_position = int(row["position"]) + max_position = get_max_position(conn) + if new_position < 1 or new_position > max_position: + raise ValueError("new_position out of range") + + if new_position == current_position: + return + + if new_position < current_position: + conn.execute( + "UPDATE applications SET position = position + 1 " + "WHERE position >= ? AND position < ?", + (new_position, current_position), + ) + else: + conn.execute( + "UPDATE applications SET position = position - 1 " + "WHERE position > ? AND position <= ?", + (current_position, new_position), + ) + + conn.execute( + "UPDATE applications SET position = ? WHERE id = ?", + (new_position, application_id), + ) + conn.commit() + finally: + conn.close() diff --git a/app/main.py b/app/main.py new file mode 100644 index 00000000..e272f1c4 --- /dev/null +++ b/app/main.py @@ -0,0 +1,29 @@ +from fastapi import FastAPI +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + +from app.db import init_db +from app.routers.applications import router as applications_router + +app = FastAPI(title="Credit Risk Review Queue") + + +@app.on_event("startup") +def on_startup() -> None: + init_db() + + +app.mount("/static", StaticFiles(directory="static"), name="static") + + +@app.get("/health") +def health() -> dict: + return {"status": "ok"} + + +@app.get("/", include_in_schema=False) +def index() -> FileResponse: + return FileResponse("static/index.html") + + +app.include_router(applications_router) diff --git a/app/models.py b/app/models.py new file mode 100644 index 00000000..056eb4d2 --- /dev/null +++ b/app/models.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass +import sqlite3 + + +@dataclass(frozen=True) +class Application: + id: int + name: str + summary: str + position: int + + @classmethod + def from_row(cls, row: sqlite3.Row) -> "Application": + return cls( + id=int(row["id"]), + name=str(row["name"]), + summary=str(row["summary"]), + position=int(row["position"]), + ) diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 00000000..ef5e5254 --- /dev/null +++ b/app/routers/__init__.py @@ -0,0 +1,3 @@ +from app.routers.applications import router as applications_router + +__all__ = ["applications_router"] diff --git a/app/routers/applications.py b/app/routers/applications.py new file mode 100644 index 00000000..9f98cfde --- /dev/null +++ b/app/routers/applications.py @@ -0,0 +1,67 @@ +from fastapi import APIRouter, HTTPException + +from app.db import ( + delete_application, + fetch_all_applications, + get_application_by_id, + insert_application_between, + insert_application_end, + insert_application_start, + move_application, +) +from app.models import Application +from app.schemas import ApplicationOut, InsertRequest, MoveRequest + +router = APIRouter(tags=["applications"]) + + +@router.get("/applications", response_model=list[ApplicationOut]) +def list_applications() -> list[ApplicationOut]: + rows = fetch_all_applications() + applications = [Application.from_row(row) for row in rows] + return [ApplicationOut(**application.__dict__) for application in applications] + + +@router.post("/applications/insert", response_model=ApplicationOut) +def insert_application(payload: InsertRequest) -> ApplicationOut: + if payload.placement == "start": + application_id = insert_application_start(payload.name, payload.summary) + elif payload.placement == "end": + application_id = insert_application_end(payload.name, payload.summary) + else: + if payload.before_id is None or payload.after_id is None: + raise HTTPException(status_code=400, detail="before_id and after_id required") + if payload.before_id == payload.after_id: + raise HTTPException(status_code=400, detail="before_id and after_id must differ") + try: + application_id = insert_application_between( + payload.name, payload.summary, payload.before_id, payload.after_id + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + row = get_application_by_id(application_id) + if row is None: + raise HTTPException(status_code=500, detail="failed to load inserted application") + return ApplicationOut(**Application.from_row(row).__dict__) + + +@router.post("/applications/{application_id}/move") +def move_application_endpoint(application_id: int, payload: MoveRequest) -> dict: + try: + move_application(application_id, payload.new_position) + except ValueError as exc: + detail = str(exc) + if detail == "application not found": + raise HTTPException(status_code=404, detail=detail) from exc + raise HTTPException(status_code=400, detail=detail) from exc + return {"status": "ok"} + + +@router.delete("/applications/{application_id}") +def delete_application_endpoint(application_id: int) -> dict: + try: + delete_application(application_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + return {"status": "ok"} diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 00000000..9afdac3a --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel, Field + + +class ApplicationOut(BaseModel): + id: int + name: str + summary: str + position: int + + +class ApplicationCreate(BaseModel): + name: str = Field(min_length=1) + summary: str = Field(min_length=1) + + +class InsertRequest(ApplicationCreate): + placement: str = Field(pattern="^(start|end|between)$") + before_id: int | None = None + after_id: int | None = None + + +class MoveRequest(BaseModel): + new_position: int = Field(ge=1) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..2522ad0e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fastapi +uvicorn +pytest +httpx diff --git a/static/app.js b/static/app.js new file mode 100644 index 00000000..b33674a1 --- /dev/null +++ b/static/app.js @@ -0,0 +1,206 @@ +const listEl = document.getElementById("list"); +const formEl = document.getElementById("insert-form"); +const statusEl = document.getElementById("form-status"); +const refreshBtn = document.getElementById("refresh"); +let latestApplications = []; + +async function fetchApplications() { + const response = await fetch("/applications"); + if (!response.ok) { + throw new Error("Failed to load applications"); + } + return await response.json(); +} + +function renderList(applications) { + if (!applications.length) { + listEl.innerHTML = "
No applications in queue.
"; + return; + } + + const rows = applications + .map( + (item) => ` +${item.summary}
+${error.message}
`; + } +} + +formEl.addEventListener("submit", async (event) => { + event.preventDefault(); + statusEl.textContent = ""; + + const formData = new FormData(formEl); + const payload = { + name: formData.get("name").trim(), + summary: formData.get("summary").trim(), + }; + + if (!payload.name || !payload.summary) { + statusEl.textContent = "Name and summary are required."; + return; + } + + const positionValue = formData.get("position"); + const desiredPosition = positionValue ? Number.parseInt(positionValue, 10) : null; + const maxPosition = latestApplications.length; + + let placementPayload = { ...payload, placement: "end" }; + if (desiredPosition !== null && Number.isFinite(desiredPosition)) { + if (desiredPosition <= 1) { + placementPayload = { ...payload, placement: "start" }; + } else if (desiredPosition > maxPosition) { + placementPayload = { ...payload, placement: "end" }; + } else { + const beforeItem = latestApplications.find( + (item) => item.position === desiredPosition - 1 + ); + const afterItem = latestApplications.find( + (item) => item.position === desiredPosition + ); + if (beforeItem && afterItem) { + placementPayload = { + ...payload, + placement: "between", + before_id: beforeItem.id, + after_id: afterItem.id, + }; + } else { + placementPayload = { ...payload, placement: "end" }; + } + } + } + + const response = await fetch("/applications/insert", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(placementPayload), + }); + + if (!response.ok) { + const result = await response.json(); + statusEl.textContent = result.detail || "Failed to insert application."; + return; + } + + formEl.reset(); + statusEl.textContent = "Application added."; + await refreshList(); +}); + +refreshBtn.addEventListener("click", refreshList); + +listEl.addEventListener("click", async (event) => { + const target = event.target; + if (!(target instanceof Element)) { + return; + } + + const actionButton = target.closest("button[data-action]"); + if (!actionButton) { + return; + } + + const rankCell = actionButton.closest(".rank-cell"); + if (!rankCell) { + return; + } + + const applicationId = Number(rankCell.dataset.id); + const currentPosition = Number(rankCell.dataset.position); + if (!Number.isFinite(applicationId) || !Number.isFinite(currentPosition)) { + return; + } + + const action = actionButton.dataset.action; + if (action === "move") { + const input = prompt("Enter new rank:", String(currentPosition)); + if (input === null) { + return; + } + + const newPosition = Number.parseInt(input, 10); + if (!Number.isFinite(newPosition)) { + statusEl.textContent = "Rank must be a whole number."; + return; + } + + const maxPosition = latestApplications.length || currentPosition; + const clampedPosition = Math.min(Math.max(newPosition, 1), maxPosition); + + if (clampedPosition === currentPosition) { + statusEl.textContent = "Rank unchanged."; + return; + } + + const response = await fetch(`/applications/${applicationId}/move`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ new_position: clampedPosition }), + }); + + if (!response.ok) { + const result = await response.json(); + statusEl.textContent = result.detail || "Failed to move application."; + return; + } + + statusEl.textContent = "Rank updated."; + await refreshList(); + } + + if (action === "delete") { + const confirmed = confirm("Remove this application from the queue?"); + if (!confirmed) { + return; + } + + const response = await fetch(`/applications/${applicationId}`, { + method: "DELETE", + }); + + if (!response.ok) { + const result = await response.json(); + statusEl.textContent = result.detail || "Failed to remove application."; + return; + } + + statusEl.textContent = "Application removed."; + await refreshList(); + } +}); + +refreshList(); diff --git a/static/index.html b/static/index.html new file mode 100644 index 00000000..82364e30 --- /dev/null +++ b/static/index.html @@ -0,0 +1,49 @@ + + + + + +Credit Risk
+Prioritize applications in a single ranked list.
+