forked from ChelseaKR/fare-policy-assistant
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathembed.py
More file actions
223 lines (212 loc) · 9.54 KB
/
Copy pathembed.py
File metadata and controls
223 lines (212 loc) · 9.54 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
"""Embeddable widget: a compact, frameable variant of the assistant.
An agency can drop the assistant into its own fare page with one iframe:
<iframe src="https://<demo-host>/embed" title="Transit fare policy assistant"
width="100%" height="520" style="border:1px solid #d6d3cb;border-radius:8px">
</iframe>
The widget is served from this origin, so its call to /api/ask stays same-origin
under the same connect-src 'self' policy. Framing is governed by a CSP
frame-ancestors header set in web/handler.py (configurable; see that file), not
by anything here. The page keeps the reference-implementation notice and the
"does not decide eligibility / does not collect personal information" line, so
the limits travel with the embed.
Single-turn on purpose: the widget is a doorway to the full assistant (a link
opens it), not a replacement for it.
"""
from __future__ import annotations
EMBED_HTML = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Transit fare policy assistant (embedded widget)</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, Helvetica, Arial, sans-serif; color: #1a1f24; background: #fff;
line-height: 1.5; font-size: 0.95rem; }
.wrap { padding: 0.8rem; max-width: 38rem; margin: 0 auto; }
h1 { font-size: 1.05rem; margin: 0 0 0.3rem; }
.note { color: #4d5860; font-size: 0.85rem; margin: 0 0 0.6rem; }
/* Above-the-fold liability + staleness frame: the "confirm with the agency"
and "may be out of date" notes ride at the top of the widget, not only in
the footer, since an agency is liable for a wrong fare or eligibility line
(RR2). */
.frame { border: 1px solid #92400e; background: #fffbeb; border-radius: 6px;
padding: 0.5rem 0.7rem; margin: 0 0 0.7rem; font-size: 0.85rem; color: #1a1f24; }
.frame .es { display: block; margin-top: 0.35rem; }
label { display: block; font-weight: 600; margin-bottom: 0.3rem; }
textarea { width: 100%; min-height: 3rem; font: inherit; padding: 0.5rem;
border: 1px solid #d6d3cb; border-radius: 6px; background: #fff; color: #1a1f24; }
textarea:focus-visible, button:focus-visible, a:focus-visible,
[tabindex]:focus-visible {
outline: 4px solid #1d4ed8; outline-offset: 3px;
box-shadow: 0 0 0 2px #ffffff; }
button { font: inherit; border: 1px solid #14532d; background: #14532d;
color: #fff; border-radius: 6px; padding: 0.5rem 1.1rem;
/* WCAG 2.2 AA 2.5.8 Target Size (Minimum): at least 24px. */
min-height: 2.5rem; cursor: pointer; margin-top: 0.5rem; }
button[disabled] { opacity: 0.6; cursor: wait; }
#status { color: #4d5860; min-height: 1.2rem; margin-top: 0.5rem; font-size: 0.85rem; }
#status.error { color: #991b1b; }
#answer p { margin: 0.4rem 0; }
#answer ul { margin: 0.3rem 0 0.3rem 1.2rem; padding: 0; }
.sources { border-top: 1px solid #d6d3cb; margin-top: 0.6rem; padding-top: 0.5rem;
font-size: 0.85rem; }
.sources a { color: #1d4ed8; }
/* h2 for heading navigation; styled to stay the inline bold caption it was. */
.sources-h { font-size: inherit; font-weight: 700; margin: 0; }
.asof { color: #4d5860; font-size: 0.8rem; margin-top: 0.4rem; }
.privacy { color: #4d5860; font-size: 0.8rem; margin: 0.45rem 0; }
.ref { color: #4d5860; font-size: 0.78rem; margin-top: 0.8rem;
border-top: 1px solid #d6d3cb; padding-top: 0.5rem; }
.ref a { color: #1d4ed8; }
</style>
</head>
<body>
<div class="wrap">
<h1>Transit fare policy assistant</h1>
<p class="note">Explains published fare and reduced-fare policy in English or
Spanish. It does not decide your eligibility and does not collect personal
information.</p>
<p class="frame" id="snapshot-note">
<strong>Snapshot-backed, not live.</strong> Answers use dated copies of
published policy pages, not the agency's live website, and can be out of date.
The agency makes the final eligibility decision; confirm current fares and
deadlines with it.
<span class="es" lang="es"><strong>Confirme antes de usar esta
información.</strong> Las respuestas usan copias con fecha, no el sitio web
actualizado de la agencia, y pueden estar desactualizadas. La agencia toma
la decisión final de elegibilidad; confirme tarifas y fechas límite con
ella.</span>
</p>
<form id="form">
<label for="q">Your question</label>
<textarea id="q" name="question" maxlength="500" required
aria-describedby="snapshot-note"
placeholder="Example: Senior discount on SBMTD?"></textarea>
<button type="submit" class="primary" id="submit">Ask</button>
</form>
<p class="privacy">Questions are processed transiently. Their raw text is not
logged or used as a cache key; refused or personal-information-like inputs
are not cached.</p>
<p id="status" role="status" aria-live="polite" aria-atomic="true"></p>
<div id="answer" role="region" aria-label="Assistant answer" aria-live="polite"
aria-relevant="additions text" aria-busy="false"></div>
<p class="ref">Reference implementation, not an official agency service. Confirm
important details with the agency.
<a href="/" target="_blank" rel="noopener">Open the full assistant</a>.</p>
</div>
<script>
(function () {
"use strict";
var form = document.getElementById("form");
var input = document.getElementById("q");
var submit = document.getElementById("submit");
var status = document.getElementById("status");
var answer = document.getElementById("answer");
function esc(s) {
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
}
// Answers are plain text with single or combined [doc:id] markers, **bold**,
// and "- " bullets.
function render(text) {
var cleaned = text.replace(
/\\s*\\[doc:[a-z0-9-]+(?:,\\s*doc:[a-z0-9-]+)*\\]/g, ""
);
var lines = cleaned.split(/\\n/);
var html = "";
var inList = false;
lines.forEach(function (line) {
var t = line.trim();
var item = /^[-\\u2022]\\s+/.test(t);
if (item && !inList) { html += "<ul>"; inList = true; }
if (!item && inList) { html += "</ul>"; inList = false; }
if (!t) { return; }
var body = esc(t.replace(/^[-\\u2022]\\s+/, ""))
.replace(/\\*\\*([^*]+)\\*\\*/g, "<strong>$1</strong>");
html += item ? "<li>" + body + "</li>" : "<p>" + body + "</p>";
});
if (inList) { html += "</ul>"; }
return html;
}
function appendSources(container, citations) {
if (!citations || !citations.length) { return; }
var src = document.createElement("div");
src.className = "sources";
// A heading, not a <strong>, so heading navigation reaches it (see the
// same change in web/index.html). h2 here: the embed's only other heading
// is its h1, so an h3 would skip a level.
var label = document.createElement("h2");
label.className = "sources-h";
label.textContent = "Sources";
src.appendChild(label);
var ul = document.createElement("ul");
citations.forEach(function (c) {
var li = document.createElement("li");
var a = document.createElement("a");
a.href = c.url; // set as a property: no attribute-injection risk
a.target = "_blank";
a.rel = "noopener";
a.textContent = c.agency + ": " + c.title;
li.appendChild(a);
li.appendChild(document.createTextNode(" (fetched " + c.fetch_date + ")"));
ul.appendChild(li);
});
src.appendChild(ul);
container.appendChild(src);
}
form.addEventListener("submit", function (ev) {
ev.preventDefault();
var question = input.value.trim();
if (!question) { return; }
// Clear the editable DOM immediately. The local variable exists only for
// this transient request; a refused PII-like input is not left in the page.
input.value = "";
submit.disabled = true;
status.className = "";
status.textContent = "Looking through the published policies\\u2026";
answer.setAttribute("aria-busy", "true");
answer.innerHTML = "";
fetch("/api/ask", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ question: question })
}).then(function (resp) {
return resp.json().then(function (data) { return { ok: resp.ok, data: data }; });
}).then(function (r) {
submit.disabled = false;
if (!r.ok) {
answer.setAttribute("aria-busy", "false");
status.className = "error";
status.textContent = r.data.error || "Something went wrong. Please try again.";
return;
}
status.textContent = "";
answer.setAttribute("aria-busy", "false");
var data = r.data;
var ans = document.createElement("div");
ans.innerHTML = render(data.answer);
ans.setAttribute("lang", data.language || "en");
appendSources(ans, data.citations);
// Same headline freshness claim as the full page: `as_of_date` is the
// oldest fetch date among the passages this answer cites
// (assistant.answer._as_of_cited), not the newest passage retrieved.
if (data.as_of_date) {
var asof = document.createElement("p");
asof.className = "asof";
asof.textContent = "Based on policies published as of " + data.as_of_date + ".";
ans.appendChild(asof);
}
answer.appendChild(ans);
}).catch(function () {
submit.disabled = false;
answer.setAttribute("aria-busy", "false");
status.className = "error";
status.textContent = "Could not reach the service. Please try again.";
});
});
})();
</script>
</body>
</html>
"""