Skip to content

Commit 19e97e4

Browse files
authored
Merge pull request #164 from codeforpdx/issue-163/re-implement-feedback-feature
[Feature] - Issue 163/re implement feedback feature
2 parents e1dadf5 + 229dbd2 commit 19e97e4

File tree

10 files changed

+865
-6
lines changed

10 files changed

+865
-6
lines changed

backend/.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,10 @@ DB_USE_SSL=false
1212
# Not used for local dev
1313
#DB_USERNAME=default
1414
#DB_PASSWORD=password
15+
16+
# SMTP setup
17+
#MAIL_SERVER="smtp.service.com"
18+
#MAIL_PORT=587
19+
#SENDER_EMAIL="[email protected]"
20+
#APP_PASSWORD="app_specific_password"
21+
#RECIPIENT_EMAIL="[email protected]"

backend/pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1+
12
[project]
23
name = "tenant-first-aid"
34
version = "0.2.0"
45
requires-python = ">=3.12"
56
dependencies = [
67
"flask>=3.1.1",
8+
"flask-mailman",
9+
"Flask_Limiter",
10+
"xhtml2pdf",
11+
"redis",
712
"valkey>=6.1.0",
813
"gunicorn>=23.0.0",
914
"google-auth>=2.40.3",

backend/tenantfirstaid/app.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
from pathlib import Path
2-
from flask import Flask, jsonify, session
2+
from flask import Flask, jsonify, session, abort
3+
from flask_mailman import Mail
4+
from flask_limiter import Limiter
5+
from flask_limiter.util import get_remote_address
36
import os
47
import secrets
58

@@ -13,15 +16,44 @@
1316

1417
from .session import InitSessionView, TenantSession
1518
from .citations import get_citation
19+
from .feedback import send_feedback
1620

1721
app = Flask(__name__)
1822

23+
24+
def build_valkey_uri():
25+
host = os.getenv("DB_HOST", "127.0.0.1")
26+
port = os.getenv("DB_PORT", 6379)
27+
password = os.getenv("DB_PASSWORD")
28+
ssl = False if os.getenv("DB_USE_SSL") == "false" else True
29+
scheme = "rediss" if ssl else "redis"
30+
31+
if password:
32+
return f"{scheme}://:{password}@{host}:{port}"
33+
return f"{scheme}://{host}:{port}"
34+
35+
36+
limiter = Limiter(
37+
get_remote_address,
38+
app=app,
39+
storage_uri=build_valkey_uri(),
40+
)
41+
1942
# Configure Flask sessions
2043
app.secret_key = os.getenv("FLASK_SECRET_KEY", secrets.token_hex(32))
2144
app.config["SESSION_COOKIE_HTTPONLY"] = True
2245
app.config["SESSION_COOKIE_SECURE"] = os.getenv("ENV", "dev") == "prod"
2346
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
2447

48+
# Configure Flask Mail
49+
app.config["MAIL_SERVER"] = os.getenv("MAIL_SERVER")
50+
app.config["MAIL_PORT"] = os.getenv("MAIL_PORT")
51+
app.config["MAIL_USE_TLS"] = True
52+
app.config["MAIL_USERNAME"] = os.getenv("SENDER_EMAIL")
53+
app.config["MAIL_PASSWORD"] = os.getenv("APP_PASSWORD")
54+
app.config["MAIL_DEFAULT_SENDER"] = os.getenv("SENDER_EMAIL")
55+
56+
mail = Mail(app)
2557

2658
tenant_session = TenantSession()
2759

@@ -52,5 +84,21 @@ def clear_session():
5284
"/api/citation", endpoint="citation", view_func=get_citation, methods=["GET"]
5385
)
5486

87+
88+
@limiter.limit("3 per minute")
89+
def feedback_route():
90+
if not session.get("site_user"):
91+
abort(403, "Unauthorized: session missing")
92+
93+
return send_feedback()
94+
95+
96+
app.add_url_rule(
97+
"/api/feedback",
98+
endpoint="feedback",
99+
view_func=feedback_route,
100+
methods=["POST"],
101+
)
102+
55103
if __name__ == "__main__":
56104
app.run(debug=True, host="0.0.0.0", port=5001)

backend/tenantfirstaid/feedback.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from xhtml2pdf import pisa
2+
from io import BytesIO
3+
from flask import request
4+
from flask_mailman import EmailMessage
5+
import os
6+
from typing import Optional, Tuple
7+
8+
MAX_ATTACHMENT_SIZE: int = 2 * 1024 * 1024
9+
10+
11+
def convert_html_to_pdf(html_content: str) -> Optional[bytes]:
12+
pdf_buffer = BytesIO()
13+
pisa_status = pisa.CreatePDF(html_content, dest=pdf_buffer)
14+
if pisa_status.err:
15+
return None
16+
return pdf_buffer.getvalue()
17+
18+
19+
def send_feedback() -> Tuple[str, int]:
20+
feedback = request.form.get("feedback")
21+
file = request.files.get("transcript")
22+
23+
if not file:
24+
return "No file provided", 404
25+
26+
html_content: str = file.read().decode("utf-8")
27+
pdf_content: Optional[bytes] = convert_html_to_pdf(html_content)
28+
if pdf_content is None:
29+
return "PDF conversion failed", 500
30+
31+
if len(pdf_content) > MAX_ATTACHMENT_SIZE:
32+
return "Attachment too large", 413
33+
34+
try:
35+
msg = EmailMessage(
36+
subject="Feedback with Transcript",
37+
from_email=os.getenv("SENDER_EMAIL"),
38+
to=[os.getenv("RECIPIENT_EMAIL")],
39+
body=f"User feedback:\n\n{feedback}\n\nTranscript is attached below",
40+
)
41+
msg.attach(
42+
"transcript.pdf",
43+
pdf_content,
44+
"application/pdf",
45+
)
46+
47+
msg.send()
48+
return "Message sent", 200
49+
except Exception as e:
50+
return f"Send failed: {str(e)}", 500

backend/tenantfirstaid/session.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ def get_flask_session_id(self) -> str:
4646
if not session_id:
4747
session_id = str(uuid.uuid4())
4848
session["session_id"] = session_id
49+
session["site_user"] = True
4950

5051
@after_this_request
5152
def save_session(response: Response) -> Response:

backend/uv.lock

Lines changed: 586 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { useState } from "react";
2+
import sendFeedback from "../utils/feedbackHelper";
3+
import { IMessage } from "../../../hooks/useMessages";
4+
5+
interface Props {
6+
messages: IMessage[];
7+
setOpenFeedback: React.Dispatch<React.SetStateAction<boolean>>;
8+
}
9+
10+
export default function FeedbackModal({ messages, setOpenFeedback }: Props) {
11+
const [feedback, setFeedback] = useState("");
12+
const [wordsToRedact, setWordsToRedact] = useState("");
13+
const [status, setStatus] = useState("idle");
14+
15+
const handleModalClose = () => {
16+
setOpenFeedback(false);
17+
setStatus("idle");
18+
setFeedback("");
19+
setWordsToRedact("");
20+
};
21+
22+
return (
23+
<dialog
24+
open
25+
className="absolute top-[50%] left-[50%] -translate-x-[50%] -translate-y-[50%] flex flex-col gap-4 items-center justify-center w-[300px] sm:w-[500px] h-[300px] rounded-lg p-4"
26+
>
27+
{status === "idle" ? (
28+
<>
29+
<textarea
30+
className="resize-none h-[80%] w-full px-3 py-2 border-1 border-[#ddd] rounded-md box-border transition-colors duration-300 focus:outline-0 focus:border-[#4a90e2] focus:shadow-[0_0_0_2px_rgba(74,144,226,0.2)]"
31+
placeholder="Please enter your feedback with regards to the chatbot here. A copy of your chat transcript will automatically be included with your response."
32+
onChange={(event) => setFeedback(event.target.value)}
33+
/>
34+
<input
35+
className="resize-none h-[20%] w-full px-3 py-2 border-1 border-[#ddd] rounded-md box-border transition-colors duration-300 focus:outline-0 focus:border-[#4a90e2] focus:shadow-[0_0_0_2px_rgba(74,144,226,0.2)]"
36+
placeholder="Please enter words to redact separated by commas"
37+
type="text"
38+
onChange={(event) => setWordsToRedact(event.target.value)}
39+
/>
40+
</>
41+
) : (
42+
<div className="flex items-center justify-center h-[80%] w-full">
43+
<p>Feedback Sent!</p>
44+
</div>
45+
)}
46+
<div className="flex gap-4">
47+
<button
48+
className="border rounded-full px-4 py-1 cursor-pointer font-semibold text-[#1F584F] transition-colors hover:bg-[#E8EEE2]"
49+
onClick={() => {
50+
if (feedback.trim() === "") handleModalClose();
51+
setStatus("sending");
52+
setTimeout(() => {
53+
sendFeedback(messages, feedback, wordsToRedact);
54+
handleModalClose();
55+
}, 1000);
56+
}}
57+
>
58+
Send
59+
</button>
60+
<button
61+
className="border rounded-full px-4 py-1 cursor-pointer font-semibold text-[#E3574B] transition-colors hover:bg-[#fff0ee]"
62+
onClick={handleModalClose}
63+
>
64+
Close
65+
</button>
66+
</div>
67+
</dialog>
68+
);
69+
}

frontend/src/pages/Chat/components/MessageWindow.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import useSession from "../../../hooks/useSession";
66
import ExportMessagesButton from "./ExportMessagesButton";
77
import CitySelectField from "./CitySelectField";
88
import SuggestedPrompts from "./SuggestedPrompts";
9+
import FeedbackModal from "./FeedbackModal";
910

1011
interface Props {
1112
messages: IMessage[];
@@ -24,6 +25,7 @@ export default function MessageWindow({
2425
}: Props) {
2526
const [isLoading, setIsLoading] = useState(false);
2627
const [inputValue, setInputValue] = useState("");
28+
const [openFeedback, setOpenFeedback] = useState(false);
2729
const { handleNewSession } = useSession();
2830
const inputRef = useRef<HTMLTextAreaElement | null>(null);
2931
const messagesRef = useRef<HTMLDivElement | null>(null);
@@ -97,6 +99,9 @@ export default function MessageWindow({
9799
</div>
98100
)}
99101
</div>
102+
{openFeedback && (
103+
<FeedbackModal messages={messages} setOpenFeedback={setOpenFeedback} />
104+
)}
100105
<div>
101106
{messages.length > 0 ? (
102107
<>
@@ -119,9 +124,15 @@ export default function MessageWindow({
119124
>
120125
Clear Chat
121126
</button>
122-
<div className="">
123-
<ExportMessagesButton messages={messages} />
124-
</div>
127+
<ExportMessagesButton messages={messages} />
128+
<button
129+
className="py-2 px-4 border rounded-md font-semibold hover:bg-gray-200 transition-colors cursor-pointer opacity-70"
130+
onClick={() => {
131+
setOpenFeedback(true);
132+
}}
133+
>
134+
Feedback
135+
</button>
125136
</div>
126137
</>
127138
) : (

frontend/src/pages/Chat/utils/exportHelper.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,13 @@ export default function exportMessages(messages: IMessage[]) {
3333
<style>
3434
body {
3535
font-family: sans-serif;
36-
padding: 20px;
3736
}
3837
strong {
3938
font-weight: bold;
4039
}
4140
p {
42-
margin: 12px;
41+
margin: 6px 0;
42+
line-height: 1.2;
4343
}
4444
</style>
4545
</head>
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import type { IMessage } from "../../../hooks/useMessages";
2+
3+
function sanitizeText(str: string) {
4+
// Strips anchor tags
5+
str = str.replace(/<a\b[^>]*>(.*?)<\/a>/gi, "$1");
6+
7+
return str
8+
.replace(/&/g, "&amp;")
9+
.replace(/</g, "&lt;")
10+
.replace(/>/g, "&gt;")
11+
.replace(/"/g, "&quot;")
12+
.replace(/'/g, "&#039;");
13+
}
14+
15+
function redactText(message: string, wordsToRedact: string) {
16+
let redactedMessage = message;
17+
const redactList = wordsToRedact.split(/\s*,\s*/).map((s) => s.trim());
18+
redactList.forEach((word) => {
19+
const regex = new RegExp(`\\b${word.replace(/\s+/g, "\\s+")}\\b`, "gi");
20+
redactedMessage = redactedMessage.replace(regex, () => {
21+
return `<span style="
22+
background-color: black;
23+
color:transparent;
24+
white-space: nowrap;
25+
user-select: none;
26+
">${"_".repeat(10)}</span>`;
27+
});
28+
});
29+
return redactedMessage;
30+
}
31+
32+
export default async function sendFeedback(
33+
messages: IMessage[],
34+
userFeedback: string,
35+
wordsToRedact: string,
36+
) {
37+
if (messages.length < 2) return;
38+
39+
const messageChain = messages
40+
.map(
41+
({ role, content }) =>
42+
`<p><strong>${
43+
role.charAt(0).toUpperCase() + role.slice(1)
44+
}</strong>: ${redactText(sanitizeText(content), wordsToRedact)}</p>`,
45+
)
46+
.join("");
47+
48+
const htmlContent = `
49+
<html>
50+
<head>
51+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'none'; object-src 'none'; base-uri 'none'; style-src 'self'; img-src 'self' data:; font-src 'self'; form-action 'none';">
52+
<title>Conversation History</title>
53+
<style>
54+
body {
55+
font-family: sans-serif;
56+
}
57+
strong {
58+
font-weight: bold;
59+
}
60+
p {
61+
margin: 6px 0;
62+
line-height: 1.2;
63+
}
64+
</style>
65+
</head>
66+
<body>
67+
${messageChain}
68+
</body>
69+
</html>
70+
`;
71+
72+
const blob = new Blob([htmlContent], { type: "text/html" });
73+
const formData = new FormData();
74+
75+
formData.append("feedback", userFeedback);
76+
formData.append("transcript", blob, "transcript.html");
77+
78+
await fetch("/api/feedback", {
79+
method: "POST",
80+
body: formData,
81+
});
82+
}

0 commit comments

Comments
 (0)