Spaces:
Sleeping
Sleeping
<html lang="en"> | |
<head> | |
<meta charset="utf-8" /> | |
<meta name="viewport" content="width=device-width, initial-scale=1" /> | |
<title>Pledge Tracker β Demo</title> | |
<script src="https://cdn.tailwindcss.com"></script> | |
</head> | |
<body class="bg-gray-50 text-gray-800"> | |
<header class="bg-white shadow py-4 sticky top-0 z-10"> | |
<div class="container mx-auto flex items-center justify-between px-4"> | |
<div class="flex items-center gap-2"> | |
<span class="text-2xl font-bold text-purple-600">π€</span> | |
<span class="font-semibold text-lg">Pledge Tracking</span> | |
</div> | |
<nav class="hidden md:flex gap-6 font-medium"> | |
<a class="hover:text-purple-600" href="#eval-response">Track Your Pledge</a> | |
<a class="hover:text-purple-600" href="#about">About</a> | |
</nav> | |
</div> | |
</header> | |
<section class="py-16 bg-gradient-to-r from-purple-50 to-purple-50 text-center"> | |
<div class="container mx-auto px-4 max-w-3xl"> | |
<h1 class="text-3xl md:text-4xl font-extrabold mb-6"> | |
<span style="font-variant: small-caps; font-weight: bold;">PledgeTracker</span>: A System for Monitoring the Fulfilment of Pledges | |
</h1> | |
<div class="text-lg text-gray-600 leading-relaxed space-y-4 text-justify"> | |
<p> | |
<span style="font-variant: small-caps;">PledgeTracker</span> is a system to monitor the fulfilment of political pledges. As part of this study, we will collect your inputs to help evaluate and improve the system. We may also collect your feedback if you submit it via the feedback form. No personal information will be collected, and all data will be anonymised and stored securely. By using the system, you agree to participate in this study under these conditions. | |
</p> | |
<p class="text-center"> | |
Please contact | |
<a href="mailto:av308@cam.ac.uk" class="text-purple-600 underline">Andreas Vlachos</a> | |
and | |
<a href="mailto:yc632@cam.ac.uk" class="text-purple-600 underline">Yulong Chen</a> | |
if you have any concerns. | |
</p> | |
</div> | |
</div> | |
</section> | |
<section id="eval-response" class="py-12"> | |
<div class="container mx-auto px-4 max-w-4xl"> | |
<!-- <h2 class="text-2xl font-bold mb-6">Track Manifesto Pledge</h2> --> | |
<label for="claim" class="block text-sm font-medium mb-2"> | |
Please enter the pledge: | |
</label> | |
<textarea | |
id="claim" | |
class="w-full border rounded-lg p-3 h-40 focus:outline-none focus:ring-2 focus:ring-purple-500" | |
placeholder="For example: 'We will support families with children by introducing free breakfast clubs in every primary school...'" | |
></textarea> | |
<div id="similar-suggestions" class="mt-3 text-sm text-gray-600 hidden"></div> | |
<div class="mt-4"> | |
<label for="pledge-date" class="block text-sm font-medium mb-2"> | |
When was this pledge made? | |
</label> | |
<div class="grid grid-cols-[1fr_auto] items-center gap-2"> | |
<input | |
type="date" | |
id="pledge-date" | |
class="w-full border rounded-lg p-2" | |
/> | |
<button | |
onclick="setDefaultDate()" | |
type="button" | |
class="px-2 py-1 text-sm bg-purple-600 text-white rounded hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-500" | |
> | |
Use default: 4th Jul 2024 | |
</button> | |
</div> | |
<div id="date-warning" class="text-sm text-red-600 mt-1 hidden"> | |
Please select a date or click the button to use the default. | |
</div> | |
</div> | |
<div class="mt-4"> | |
<label for="pledge-author" class="block text-sm font-medium mb-2"> | |
Who made this pledge? | |
</label> | |
<div class="grid grid-cols-[1fr_auto] items-center gap-2"> | |
<input | |
type="text" | |
id="pledge-author" | |
class="w-full border rounded-lg p-2" | |
placeholder="Enter the name of the party or person" | |
/> | |
<button | |
onclick="setDefaultAuthor()" | |
type="button" | |
class="px-2 py-1 text-sm bg-purple-600 text-white rounded hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-500" | |
> | |
Use default: Labour | |
</button> | |
</div> | |
<div id="author-warning" class="text-sm text-red-600 mt-1 hidden"> | |
Please enter a speaker or click the button to use the default. | |
</div> | |
</div> | |
<label for="time-range" class="block text-sm font-medium mt-4 mb-2"> | |
Please select a time range: | |
</label> | |
<select id="time-range" class="w-full border rounded-lg p-2"> | |
<option value="week">Past one week</option> | |
<option value="month">Past one month</option> | |
<!-- <option value="year">From when the pledge was made</option> --> | |
<option value="since_pledge_date">From when the pledge was made</option> | |
</select> | |
<button | |
id="check" | |
class="mt-4 px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-500" | |
> | |
Let's track! | |
</button> | |
<div id="progress" class="mt-6 hidden border p-4 rounded-lg bg-white shadow"> | |
<h3 class="font-semibold mb-2">System Progress</h3> | |
<div id="status" class="text-sm text-gray-800 font-normal leading-relaxed"></div> | |
</div> | |
<div id="result" class="mt-6 hidden border p-4 rounded-lg bg-white shadow"> | |
<h3 class="font-semibold mb-2">Result</h3> | |
<p class="text-gray-700"></p> | |
</div> | |
</div> | |
</section> | |
<section id="about" class="py-12"> | |
<div class="container mx-auto px-4 max-w-4xl"> | |
<h2 class="text-2xl font-bold mb-6">About</h2> | |
<p class="text-gray-700 leading-relaxed"> | |
<span style="font-variant: small-caps;">PledgeTracker</span> is a research prototype developed to support the monitoring of political pledge fulfilment. | |
This demo is developed by researchers at the University of Cambridge, Queen Mary University London, and Full Fact. | |
</p> | |
</div> | |
</section> | |
<script> | |
let suggestedPledge = null; | |
let currentAbortController = null; | |
const feedbackData = {}; | |
let lastUsedFile = null; | |
let lastUserId = null; | |
let lastTimestamp = null; | |
const checkBtn = document.getElementById("check"); | |
const stepListStandard = { | |
1: "Retrieving evidence related to the pledge", | |
2: "Scraping documents from URLs", | |
3: "Generating more queries based on the retrieved evidence", | |
4: "Searching more articles", | |
5: "Scraping documents from URLs", | |
6: "Finding the most relevant documents", | |
7: "Extracting events from top documents", | |
8: "Sorting events temporally" | |
}; | |
const stepListSuggestion = { | |
1: "Generating queries to retrieve evidence", | |
2: "Searching more articles", | |
3: "Scraping documents from URLs", | |
4: "Finding the most relevant documents", | |
5: "Extracting events from top documents", | |
6: "Sorting events temporally" | |
}; | |
let stepList = stepListStandard; | |
function renderStatus(statusDict) { | |
let html = "<ul class='list-disc ml-6 space-y-1 text-sm'>"; | |
for (let step in stepList) { | |
const raw = statusDict?.[step] || stepList[step]; | |
const content = raw.replace(/\n/g, "<br>"); | |
const prefix = statusDict?.[step] ? "β " : "β³"; | |
html += `<li>${prefix} Step ${step}: ${content}</li>`; | |
} | |
html += "</ul>"; | |
return html; | |
} | |
function setDefaultDate() { | |
const input = document.getElementById("pledge-date"); | |
input.value = "2024-07-04"; | |
document.getElementById("date-warning").classList.add("hidden"); | |
} | |
function setDefaultAuthor() { | |
const input = document.getElementById("pledge-author"); | |
input.value = "Labour"; | |
document.getElementById("author-warning").classList.add("hidden"); | |
} | |
// function setFeedback(index, answer) { | |
// feedbackData[index] = answer; | |
// const message = document.getElementById(`msg-${index}`); | |
// message.textContent = `β Selected: ${answer ? 'Yes' : 'No'}`; | |
// message.className = answer | |
// ? "text-sm text-green-600 mt-1" | |
// : "text-sm text-red-600 mt-1"; | |
// } | |
function setFeedback(index, answer) { | |
feedbackData[index] = answer; | |
const message = document.getElementById(`msg-${index}`); | |
let displayText = ""; | |
let colorClass = ""; | |
switch(answer) { | |
case "not_relevant": | |
displayText = "Not relevant"; | |
colorClass = "text-red-300"; | |
break; | |
case "relevant_seen": | |
displayText = "Relevant but already seen"; | |
colorClass = "text-grey-400"; | |
break; | |
case "relevant_updated": | |
displayText = "Relevant and up-to-date"; | |
colorClass = "text-blue-400"; | |
break; | |
} | |
message.textContent = `β Selected: ${displayText}`; | |
message.className = `text-sm ${colorClass} mt-1`; | |
} | |
function pollStatus(userId, timestamp, statusElement) { | |
if (window.pollIntervalId) { | |
clearInterval(window.pollIntervalId); | |
} | |
window.pollIntervalId = setInterval(async () => { | |
try { | |
const res = await fetch(`/api/status?user_id=${userId}×tamp=${timestamp}&_=${Date.now()}`); | |
const data = await res.json(); | |
if (data.status) { | |
statusElement.innerHTML = renderStatus(data.status); | |
} | |
const values = Object.values(data.status || {}); | |
const finalText = values.join(" ").toLowerCase(); | |
if (finalText.includes("done") || finalText.includes("finished")) { | |
clearInterval(window.pollIntervalId); | |
window.pollIntervalId = null; | |
statusElement.innerHTML += `<div class="mt-2 text-green-600 font-semibold">β All done.</div>`; | |
checkBtn.disabled = false; | |
checkBtn.classList.remove("opacity-50", "cursor-not-allowed"); | |
suggestedPledge = null; | |
const waitForFile = setInterval(() => { | |
if (lastUsedFile) { | |
clearInterval(waitForFile); | |
loadEvents(lastUsedFile); | |
} | |
}, 200); | |
} else if (Object.values(data.status || {}).some(v => v.startsWith("β"))) { | |
clearInterval(window.pollIntervalId); | |
window.pollIntervalId = null; | |
statusElement.innerHTML += `<div class="mt-2 text-red-600 font-semibold">β The process failed.</div>`; | |
checkBtn.disabled = false; | |
checkBtn.classList.remove("opacity-50", "cursor-not-allowed"); | |
} | |
} catch (err) { | |
clearInterval(window.pollIntervalId); | |
window.pollIntervalId = null; | |
statusElement.innerHTML = `<div class="text-red-600">β Failed to check status: ${err.message}</div>`; | |
} | |
}, 2000); | |
} | |
async function submitAllFeedback() { | |
const entries = Object.entries(feedbackData); | |
if (entries.length === 0) { | |
alert("No feedback to submit!"); | |
return; | |
} | |
const confirmed = confirm("By submitting feedback, you agree that your feedback may be collected for our analysis. Your data will be anonymised and stored securely. No personal information will be recorded. If you do not wish to take part, please cancel this submission."); | |
if (!confirmed) return; | |
const pledgeText = document.getElementById("claim").value.trim(); | |
const res = await fetch('/api/feedback', { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, | |
body: JSON.stringify({ | |
pledge: pledgeText, | |
file: lastUsedFile, | |
user_id: lastUserId, | |
timestamp: lastTimestamp, | |
feedback: entries.map(([index, answer]) => ({ | |
eventIndex: index, | |
answer: answer | |
})) | |
}) | |
}); | |
alert(res.ok ? "β Feedback submitted successfully!" : "β Submission failed."); | |
} | |
async function loadEvents(file) { | |
const resultBox = document.getElementById("result"); | |
const p = resultBox.querySelector("p"); | |
resultBox.classList.remove("hidden"); | |
try { | |
const fileParam = encodeURIComponent(file); | |
const eventsRes = await fetch(`/api/events?file=${fileParam}`); | |
if (!eventsRes.ok) throw new Error("β Event file not found or malformed"); | |
const data = await eventsRes.json(); | |
if (!Array.isArray(data)) throw new Error("β Unexpected data format"); | |
if (data.length === 0) { | |
p.innerHTML = `<div class="text-gray-500 italic"> Sorry, we do not find any progress for this pledge.</div>`; | |
return; | |
} | |
// p.innerHTML = `<strong>We have found ${data.length} events for this pledge.</strong><br><br>` + | |
// data.map((e, index) => ` | |
p.innerHTML = | |
data.map((e, index) => ` | |
<div class="mb-6 border-b pb-4"> | |
ποΈ <b>${e.date}</b>: ${e.event}<br> | |
π <a href="${e.url}" target="_blank" class="text-purple-400 underline">Source</a> | |
<div class="mt-3"> | |
<label class="block text-sm font-medium mb-2">How relevant is this event?</label> | |
<div class="flex flex-wrap gap-2"> | |
<button onclick="setFeedback(${index}, 'not_relevant')" | |
class="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 border border-gray-300 rounded-lg text-gray-700"> | |
Not relevant | |
</button> | |
<button onclick="setFeedback(${index}, 'relevant_seen')" | |
class="px-3 py-1.5 bg-blue-100 hover:bg-blue-200 border border-blue-300 rounded-lg text-blue-700"> | |
Relevant but seen | |
</button> | |
<button onclick="setFeedback(${index}, 'relevant_updated')" | |
class="px-3 py-1.5 bg-green-100 hover:bg-green-200 border border-green-300 rounded-lg text-green-700"> | |
Relevant & up-to-date | |
</button> | |
</div> | |
<div id="msg-${index}" class="text-sm mt-1"></div> | |
</div> | |
</div> | |
`).join('') + | |
`<button onclick="submitAllFeedback()" class="mt-6 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"> | |
π€ Submit All Feedback | |
</button> | |
<button onclick="window.location.href='/download?file=${fileParam}'" class="mt-4 ml-4 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"> | |
π Download Excel | |
</button>`; | |
} catch (err) { | |
p.textContent = `β Failed to load timeline: ${err.message}`; | |
} | |
} | |
let suggestTimer = null; | |
document.getElementById("claim").addEventListener("input", () => { | |
suggestedPledge = null; | |
clearTimeout(suggestTimer); | |
suggestTimer = setTimeout(fetchSuggestions, 300); // 300ms delay to avoid flooding | |
}); | |
async function fetchSuggestions() { | |
const claimText = document.getElementById("claim").value.trim(); | |
const suggestionBox = document.getElementById("similar-suggestions"); | |
if (!claimText) { | |
suggestionBox.classList.add("hidden"); | |
return; | |
} | |
const res = await fetch("/api/similar-pledges", { | |
method: "POST", | |
headers: { "Content-Type": "application/json" }, | |
body: JSON.stringify({ claim: claimText }) | |
}); | |
const data = await res.json(); | |
const suggestions = data.suggestions || []; | |
if (suggestions.length === 0) { | |
suggestionBox.classList.add("hidden"); | |
} else { | |
const author = "Labour"; | |
const date = "2024-07-04"; | |
suggestionBox.innerHTML = | |
"<div class='font-semibold mb-1'>π‘ Are you fact-checking this pledge? </div>" + | |
"<ul class='list-disc ml-6 mt-1'>" + | |
suggestions.map(s => ` | |
<li class="mb-2"> | |
${author}: ${s.text} (${date}) | |
<button | |
onclick="useSuggestedPledge('${s.text.replace(/'/g, "\\'")}', ${s.index})" | |
class="ml-2 px-2 py-1 text-xs bg-purple-600 text-white rounded hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-500"> | |
Fact-check this pledge | |
</button> | |
</li> | |
`).join("") + | |
"</ul>"; | |
suggestionBox.classList.remove("hidden"); | |
} | |
} | |
checkBtn.addEventListener("click", async () => { | |
const claim = document.getElementById("claim").value.trim(); | |
const pledgeDate = document.getElementById("pledge-date").value.trim(); | |
const pledgeAuthor = document.getElementById("pledge-author").value.trim(); | |
const statusElement = document.getElementById("status"); | |
const resultBox = document.getElementById("result"); | |
// resultBox.classList.remove("hidden"); | |
const p = resultBox.querySelector("p"); | |
let valid = true; | |
if (!claim) { | |
alert("Please enter the pledge text."); | |
valid = false; | |
} | |
if (!pledgeDate) { | |
document.getElementById("date-warning").classList.remove("hidden"); | |
valid = false; | |
} | |
if (!pledgeAuthor) { | |
document.getElementById("author-warning").classList.remove("hidden"); | |
valid = false; | |
} | |
if (!valid) return; | |
checkBtn.disabled = true; | |
checkBtn.classList.add("opacity-50", "cursor-not-allowed"); | |
// document.getElementById("status").classList.remove("hidden"); | |
// statusElement.innerHTML = renderStatus({}); | |
// document.getElementById("result").classList.remove("hidden"); | |
// document.getElementById("progress").classList.remove("hidden"); | |
document.getElementById("status").innerHTML = ""; | |
document.getElementById("result").classList.add("hidden"); | |
document.getElementById("progress").classList.add("hidden"); | |
document.getElementById("result").querySelector("p").innerHTML = ""; | |
if (window.pollIntervalId) { | |
clearInterval(window.pollIntervalId); | |
window.pollIntervalId = null; | |
} | |
Object.keys(feedbackData).forEach(key => delete feedbackData[key]); | |
lastUsedFile = null; | |
lastUserId = null; | |
lastTimestamp = null; | |
// π ε―δ»₯ι’ε ζΎη€Ίζη€Ί | |
document.getElementById("result").querySelector("p").textContent = "β³ Please wait, checking..."; | |
document.getElementById("progress").classList.remove("hidden"); | |
document.getElementById("result").classList.remove("hidden"); | |
try { | |
const timeRange = document.getElementById("time-range").value; | |
// const pledgeDate = document.getElementById("pledge-date").value; | |
// const pledgeAuthor = document.getElementById("pledge-author").value; | |
if (currentAbortController) currentAbortController.abort(); | |
currentAbortController = new AbortController(); | |
const signal = currentAbortController.signal; | |
let valid = true; | |
stepList = (suggestedPledge !== null) ? stepListSuggestion : stepListStandard; | |
if (!pledgeDate) { | |
document.getElementById("date-warning").classList.remove("hidden"); | |
valid = false; | |
} | |
if (!pledgeAuthor) { | |
document.getElementById("author-warning").classList.remove("hidden"); | |
valid = false; | |
} | |
if (!valid) return; | |
const userId = Math.random().toString(36).substring(2, 10); | |
const now = new Date(); | |
const timestamp = now.toISOString().replace(/[:.]/g, "-").slice(0, 19); | |
statusElement.textContent = ""; | |
// pollStatus(userId, timestamp, p); | |
pollStatus(userId, timestamp, document.getElementById("status")); | |
const runRes = await fetch("/api/run-model", { | |
method: "POST", | |
headers: { "Content-Type": "application/json" }, | |
body: JSON.stringify({ | |
claim, | |
time_range: timeRange, | |
pledge_date: pledgeDate, | |
pledge_author: pledgeAuthor, | |
user_id: userId, | |
timestamp: timestamp, | |
signal: signal, | |
suggestion_meta: suggestedPledge | |
}) | |
}); | |
const runData = await runRes.json(); | |
lastUsedFile = runData.file; | |
lastUserId = runData.user_id; | |
lastTimestamp = runData.timestamp; | |
} catch (err) { | |
if (err.name === "AbortError") { | |
console.log("Previous request aborted."); | |
checkBtn.disabled = false; | |
checkBtn.classList.remove("opacity-50", "cursor-not-allowed"); | |
return; | |
} | |
p.textContent = `β Failed to load timeline: ${err.message}`; | |
} | |
}); | |
async function useSuggestedPledge(text, index) { | |
document.getElementById("claim").value = text; | |
document.getElementById("pledge-author").value = "Labour"; | |
document.getElementById("pledge-date").value = "2024-07-04"; | |
suggestedPledge = { text, index }; | |
alert("β This pledge has been filled in. You can now click 'Let's track!'"); | |
await fetch("/api/log-similar-selection", { | |
method: "POST", | |
headers: { "Content-Type": "application/json" }, | |
body: JSON.stringify({ | |
selected_text: text, | |
index: index | |
}) | |
}); | |
} | |
</script> | |
</body> | |
</html> | |