Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.venv
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ RUN --mount=type=cache,target=/root/.cache/uv \
&& uv run -m nltk.downloader punkt_tab -d /app/.venv/nltk_data \
&& uv run -m nltk.downloader averaged_perceptron_tagger_eng -d /app/.venv/nltk_data

COPY src/ /app/src/
COPY . /app/

FROM python:3.13-slim-trixie@sha256:087a9f3b880e8b2c7688debb9df2a5106e060225ebd18c264d5f1d7a73399db0 AS runtime

WORKDIR /app

COPY --from=builder /app /app

ENV PATH="/app/.venv/bin:$PATH"
Expand Down
123 changes: 123 additions & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PAPA</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.6/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-4Q6Gf2aSP4eDXB8Miphtr37CMZZQ5oXLH2yaXMJ2w8e2ZtHTl7GptT4jmndRuHDT" crossorigin="anonymous" />
<script src="script.js" defer></script>
<link rel="stylesheet" href="style.css" />
</head>

<body>
<div class="container">
<h1 class="app-title">PAPA: Amazing Python Analytics</h1>

<div class="card app-card">
<div class="card-body">
<!-- FILE INPUT -->
<div class="mb-3">
<div class="btn-toolbar justify-content-between">
<label for="fileInput" class="form-label d-flex align-items-center">
Select a JSON file to analyse
</label>
<a href="/docs" target="_blank" class="btn btn-primary btn-sm mb-2 p-2">
API Docs
</a>
</div>
<div class="file-row">
<input class="form-control" type="file" id="fileInput" accept=".json" aria-describedby="fileHelp" />
</div>
<div id="fileHelp" class="form-text small-muted">
Only <code>.json</code> files are supported.
</div>
</div>

<!--SWITCHES -->
<div class="mb-3">
<div class="row">
<div class="col">
<div class="form-check form-switch me-3">
<input class="form-check-input" type="checkbox" id="node_level" />
<label class="form-check-label" for="node_level">Node Level</label>
</div>
</div>
<div class="col">
<div class="form-check form-switch me-3">
<input class="form-check-input" type="checkbox" id="speaker" />
<label class="form-check-label" for="speaker">Speaker</label>
</div>
</div>
<div class="col">
<div class="form-check form-switch me-3">
<input class="form-check-input" type="checkbox" id="forecast" />
<label class="form-check-label" for="forecast">Forecast</label>
</div>
</div>
</div>
</div>

<!-- SUBMIT BUTTON -->
<div class="mb-3">
<button type="button" class="btn btn-primary w-100" id="submitButton">
Submit and Download File
</button>
</div>

<div class="divider"></div>

<div class="d-flex">
<h5 class="mb-2 flex-grow-1" style="font-weight: 600; color: #2b3b50">
Analytics
</h5>
<button id="clearFile" class="btn btn-primary btn-sm mb-2 p-2" type="button" title="Clear selected file">
Clear Selection
</button>
</div>

<div id="responseBox" aria-live="polite">
Analytics will be displayed here
</div>
<json-viewer id="json"></json-viewer>
</div>
</div>
</div>

<script>
// optional UI helper for showing/clearing file selection
(function () {
const fileInput = document.getElementById("fileInput");
const clearBtn = document.getElementById("clearFile");
const responseBox = document.getElementById("responseBox");

if (fileInput) {
fileInput.addEventListener("change", () => {
const file = fileInput.files[0];
if (file) {
responseBox.textContent = `Selected file: ${file.name}\n\nClick the submit button to get analytics.`;
} else {
responseBox.textContent = "No file selected";
}
});
}

if (clearBtn) {
clearBtn.addEventListener("click", () => {
if (fileInput) fileInput.value = "";
responseBox.style = "display: flex";
responseBox.textContent = "Please select a file";
document.getElementById('json').style = "display: none";
});
}
})();
</script>

<script src="https://unpkg.com/@alenaksu/json-viewer@2.1.0/dist/json-viewer.bundle.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.6/dist/js/bootstrap.bundle.min.js"
integrity="sha384-j1CDi7MgGQ12Z7Qab0qlWQ/Qqz24Gc6BM0thvEMVjHnfYGF0rmFCozFSxQBxwHKO"
crossorigin="anonymous"></script>
</body>

</html>
66 changes: 66 additions & 0 deletions frontend/script.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
document.getElementById("submitButton").addEventListener("click", async () => {
const selectedFile = document.getElementById("fileInput").files[0];

if (!selectedFile) {
alert("Please select a file first.");
return;
}

const reader = new FileReader();
const nodeLevel = document.getElementById("node_level");
const speaker = document.getElementById("speaker");
const forecast = document.getElementById("forecast");

reader.onload = async (e) => {
const rawJSON = JSON.parse(e.target.result);

// log to see if the json is being parsed correctly
console.log("Parsed JSON:", rawJSON);
console.log(nodeLevel.checked);
console.log(speaker.checked);
console.log(forecast.checked);

const response = await fetch("/api/all_analytics", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
xaif: rawJSON,
node_level: nodeLevel.checked,
speaker: speaker.checked,
forecast: forecast.checked,
}),
});

const data = await response.json();
console.log("Server response:", data);

// creating a downloadable file for the user
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");

a.href = url;
a.download = "updated.json"; // name of the file
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);

// displaying the json file on frontend if there is a response
if (response.status === 200) {
document.getElementById("json").style = "display: flex";
document.querySelector('#json').data = data.analytics;
document.getElementById("responseBox").style = "display: none";
}
// displaying an error message in response box if papa fails
else {
document.getElementById('json').style = "display: none";
document.getElementById("responseBox").style = "display: flex";
document.getElementById("responseBox").innerHTML = "There was an error in the analytics code. Open the downloaded file to see more details"
}
};

reader.readAsText(selectedFile);
});
111 changes: 111 additions & 0 deletions frontend/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
:root {
--bg: #f5f7fa;
--card-bg: #ffffff;
--muted: #6c757d;
--primary: #0b4fc6; /* navy blue */
--accent: #0d9488; /* teal */
--accent-2: #475ea8; /* indigo */
--border: #e6e9ef;
}

body {
background-color: var(--bg);
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
color: #222;
padding: 48px 16px;
}

.container {
max-width: 960px;
}

h1.app-title {
text-align: center;
color: var(--primary);
margin-bottom: 40px;
font-weight: 600;
letter-spacing: -0.3px;
font-size: 2.9rem;
}

.app-card {
border: none;
border-radius: 12px;
background: var(--card-bg);
box-shadow: 0 10px 30px rgba(28, 40, 60, 0.08);
overflow: hidden;
}

.app-card::before {
content: "";
display: block;
height: 6px;
background: linear-gradient(90deg, var(--primary), var(--accent));
}

.app-card .card-body {
padding: 1.5rem;
}

label.form-label {
font-weight: 600;
color: #24324a;
}

.file-row {
display: flex;
gap: 0.6rem;
align-items: center;
flex-wrap: wrap;
}

.form-text.small-muted {
color: var(--muted);
}

.form-control:focus {
box-shadow: 0 6px 18px rgba(13, 148, 136, 0.08);
border-color: var(--accent);
}

.form-check-input:checked {
background-color: var(--accent-2);
border-color: var(--accent-2);
}

.btn-primary {
background-color: var(--primary);
border-color: var(--primary);
border-radius: 999px;
padding: 0.55rem 1rem;
font-weight: 600;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.btn-primary:hover {
transform: translateY(-3px);
box-shadow: 0 12px 28px rgba(11, 79, 198, 0.12);
}

.btn-sm:hover {
transform: none;
}

.divider {
height: 1px;
background: linear-gradient(90deg, transparent, var(--border), transparent);
margin: 1rem 0;
}

#responseBox {
background: linear-gradient(to bottom, #f9fbfd, #f6f8fa);
border: 1px solid #dce3eb;
border-radius: 8px;
padding: 1.25rem;
min-height: 150px;
font-size: 0.95rem;
color: #2b3b50;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
3 changes: 3 additions & 0 deletions run.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export PYTHONPATH=src

uv run fastapi dev src
11 changes: 11 additions & 0 deletions src/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import traceback

from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, ConfigDict
from . import papa

Expand Down Expand Up @@ -29,3 +30,13 @@ def all_analytics(body: RequestBody | dict) -> dict:
return papa.all_analytics(body)
except Exception:
raise HTTPException(status_code=500, detail=traceback.format_exc())


app.mount(
"/",
StaticFiles(
directory="frontend",
html=True,
),
name="web",
)