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
194 lines (183 loc) · 7.99 KB
/
Copy pathembed.py
File metadata and controls
194 lines (183 loc) · 7.99 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
"""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 {
outline: 3px solid #1d4ed8; outline-offset: 2px; }
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; }
.asof { color: #4d5860; font-size: 0.8rem; margin-top: 0.4rem; }
.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">
<strong>Confirm before you rely on this.</strong> Answers explain published
policy from dated snapshots and can be out of date. The agency makes the
final eligibility decision; confirm anything time-sensitive with it.
<span class="es" lang="es"><strong>Confirme antes de usar esta
información.</strong> Las respuestas explican la política publicada a partir
de instantáneas con fecha y pueden estar desactualizadas. La agencia toma la
decisión final de elegibilidad; confirme cualquier detalle con ella.</span>
</p>
<form id="form">
<label for="q">Your question</label>
<textarea id="q" name="question" maxlength="500" required
placeholder="Example: Senior discount on SBMTD?"></textarea>
<button type="submit" class="primary" id="submit">Ask</button>
</form>
<p id="status" role="status" aria-live="polite"></p>
<div id="answer"></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 [doc:id] markers, **bold**, and "- " bullets.
function render(text) {
var cleaned = text.replace(/\\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";
var label = document.createElement("strong");
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; }
submit.disabled = true;
status.className = "";
status.textContent = "Looking through the published policies\\u2026";
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) {
status.className = "error";
status.textContent = r.data.error || "Something went wrong. Please try again.";
return;
}
status.textContent = "";
var data = r.data;
var ans = document.createElement("div");
ans.innerHTML = render(data.answer);
ans.setAttribute("lang", data.language || "en");
appendSources(ans, data.citations);
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;
status.className = "error";
status.textContent = "Could not reach the service. Please try again.";
});
});
})();
</script>
</body>
</html>
"""