diff options
| author | Ben Sima <ben@bensima.com> | 2025-12-15 08:47:02 -0500 |
|---|---|---|
| committer | Ben Sima <ben@bensima.com> | 2025-12-15 08:47:02 -0500 |
| commit | 0baab1972e30c0e4629e67152838e660b02a2537 (patch) | |
| tree | d82d9402e4a0840777ee3d4e39ab4329f246918b | |
| parent | adf693eb82cddd2c383cdebd3392716446ddf054 (diff) | |
t-265.6: Add feedback collection endpoint for PIL
- Add feedback table with migration in Core.py
- Add FeedbackForm and FeedbackPage UI components
- Add /feedback GET/POST routes and /api/feedback JSON endpoint
- Add admin feedback view at /admin/feedback
- Create Omni/Agent/Tools/Feedback.hs with feedback_list tool
- Wire feedback tool into Telegram agent
| -rw-r--r-- | Biz/PodcastItLater/Admin.py | 6 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Admin/Handlers.py | 28 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Admin/Views.py | 89 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Core.py | 144 | ||||
| -rw-r--r-- | Biz/PodcastItLater/UI.py | 247 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Web.py | 85 | ||||
| -rw-r--r-- | Omni/Agent/Telegram.hs | 4 | ||||
| -rw-r--r-- | Omni/Agent/Tools/Feedback.hs | 204 |
8 files changed, 804 insertions, 3 deletions
diff --git a/Biz/PodcastItLater/Admin.py b/Biz/PodcastItLater/Admin.py index 29332df..10ea7f6 100644 --- a/Biz/PodcastItLater/Admin.py +++ b/Biz/PodcastItLater/Admin.py @@ -40,7 +40,10 @@ TruncatedTextAttrs = Views.TruncatedTextAttrs UserTableRow = Views.UserTableRow UserTableRowAttrs = Views.UserTableRowAttrs create_table_header = Views.create_table_header +AdminFeedback = Views.AdminFeedback +AdminFeedbackAttrs = Views.AdminFeedbackAttrs +admin_feedback = Handlers.admin_feedback admin_metrics = Handlers.admin_metrics admin_queue_status = Handlers.admin_queue_status admin_users = Handlers.admin_users @@ -52,6 +55,8 @@ update_user_status = Handlers.update_user_status __all__ = [ "ActionButtons", "ActionButtonsAttrs", + "AdminFeedback", + "AdminFeedbackAttrs", "AdminUsers", "AdminUsersAttrs", "AdminView", @@ -72,6 +77,7 @@ __all__ = [ "TruncatedTextAttrs", "UserTableRow", "UserTableRowAttrs", + "admin_feedback", "admin_metrics", "admin_queue_status", "admin_users", diff --git a/Biz/PodcastItLater/Admin/Handlers.py b/Biz/PodcastItLater/Admin/Handlers.py index b98c551..9e06fe6 100644 --- a/Biz/PodcastItLater/Admin/Handlers.py +++ b/Biz/PodcastItLater/Admin/Handlers.py @@ -296,3 +296,31 @@ def admin_metrics(request: Request) -> Views.MetricsDashboard | Response: metrics = Core.Database.get_metrics_summary() return Views.MetricsDashboard(metrics=metrics, user=user) + + +def admin_feedback(request: Request) -> Views.AdminFeedback | Response: + """Admin feedback view.""" + # Check if user is logged in and is admin + user_id = request.session.get("user_id") + if not user_id: + return Response( + "", + status_code=302, + headers={"Location": "/"}, + ) + + user = Core.Database.get_user_by_id( + user_id, + ) + if not user or not Core.is_admin(user): + return Response( + "", + status_code=302, + headers={"Location": "/?error=forbidden"}, + ) + + # Get feedback data + feedback = Core.Database.get_feedback(limit=100) + count = Core.Database.get_feedback_count() + + return Views.AdminFeedback(feedback=feedback, count=count, user=user) diff --git a/Biz/PodcastItLater/Admin/Views.py b/Biz/PodcastItLater/Admin/Views.py index 7cc71a5..7834340 100644 --- a/Biz/PodcastItLater/Admin/Views.py +++ b/Biz/PodcastItLater/Admin/Views.py @@ -742,3 +742,92 @@ class AdminView(Component[AnyChildren, AdminViewAttrs]): ), ), ) + + +class AdminFeedbackAttrs(Attrs): + """Attributes for AdminFeedback component.""" + + feedback: list[dict[str, typing.Any]] + count: int + user: dict[str, typing.Any] + + +class AdminFeedback(Component[AnyChildren, AdminFeedbackAttrs]): + """Admin feedback listing page.""" + + @override + def render(self) -> UI.PageLayout: + feedback = self.attrs["feedback"] + count = self.attrs["count"] + user = self.attrs["user"] + + rows = [ + html.tr( + html.td(html.small(fb["id"][:8], classes=["text-muted"])), + html.td(fb.get("email") or "-"), + html.td( + html.span( + fb.get("source") or "-", + classes=["badge", "bg-secondary"], + ), + ), + html.td(str(fb.get("rating") or "-")), + html.td( + TruncatedText( + text=fb.get("use_case") or "-", + max_length=50, + ), + ), + html.td( + TruncatedText( + text=fb.get("feedback_text") or "-", + max_length=50, + ), + ), + html.td( + html.small( + fb["created_at"], + classes=["text-muted"], + ), + ), + ) + for fb in feedback + ] + + return UI.PageLayout( + html.div( + html.h2( + html.i(classes=["bi", "bi-chat-heart-fill", "me-2"]), + f"Feedback ({count})", + classes=["mb-4"], + ), + html.div( + html.table( + create_table_header([ + "ID", + "Email", + "Source", + "Rating", + "Use Case", + "Feedback", + "Created", + ]), + html.tbody(*rows), + classes=["table", "table-hover", "table-sm"], + ), + classes=["table-responsive"], + ) + if rows + else html.div( + html.p( + "No feedback yet.", + classes=["text-muted", "text-center", "py-5"], + ), + ), + ), + user=user, + current_page="admin", + page_title="Admin Feedback - PodcastItLater", + error=None, + meta_tags=[], + ) diff --git a/Biz/PodcastItLater/Core.py b/Biz/PodcastItLater/Core.py index 62eead8..d0ed2f0 100644 --- a/Biz/PodcastItLater/Core.py +++ b/Biz/PodcastItLater/Core.py @@ -202,6 +202,9 @@ class Database: # noqa: PLR0904 # Run migration to add public feed features Database.migrate_add_public_feed() + # Run migration to add feedback table + Database.migrate_add_feedback_table() + @staticmethod def add_to_queue( url: str, @@ -774,6 +777,42 @@ class Database: # noqa: PLR0904 logger.info("Database migrated for public feed feature") @staticmethod + def migrate_add_feedback_table() -> None: + """Add feedback table for collecting user research data.""" + with Database.get_connection() as conn: + cursor = conn.cursor() + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS feedback ( + id TEXT PRIMARY KEY, + email TEXT, + source TEXT, + campaign_id TEXT, + rating INTEGER, + feedback_text TEXT, + use_case TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + # Create indexes for querying feedback + cursor.execute( + "CREATE INDEX IF NOT EXISTS idx_feedback_source " + "ON feedback(source)", + ) + cursor.execute( + "CREATE INDEX IF NOT EXISTS idx_feedback_campaign " + "ON feedback(campaign_id)", + ) + cursor.execute( + "CREATE INDEX IF NOT EXISTS idx_feedback_created " + "ON feedback(created_at)", + ) + + conn.commit() + logger.info("Created feedback table") + + @staticmethod def migrate_add_default_titles() -> None: """Add default titles to queue items that have None titles.""" with Database.get_connection() as conn: @@ -1201,6 +1240,111 @@ class Database: # noqa: PLR0904 return dict(row) if row is not None else None @staticmethod + def create_feedback( # noqa: PLR0913, PLR0917 + feedback_id: str, + email: str | None, + source: str | None, + campaign_id: str | None, + rating: int | None, + feedback_text: str | None, + use_case: str | None, + ) -> str: + """Create a new feedback entry. + + Args: + feedback_id: Unique ID for the feedback + email: Optional email address + source: How they heard about PIL (outreach, organic, trial) + campaign_id: Optional link to outreach draft ID + rating: Optional 1-5 rating + feedback_text: Optional general feedback + use_case: What they want to use PIL for + + Returns: + The feedback ID + """ + with Database.get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + """ + INSERT INTO feedback + (id, email, source, campaign_id, rating, + feedback_text, use_case) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + feedback_id, + email, + source, + campaign_id, + rating, + feedback_text, + use_case, + ), + ) + conn.commit() + logger.info("Created feedback %s", feedback_id) + return feedback_id + + @staticmethod + def get_feedback( + limit: int = 50, + since: str | None = None, + ) -> list[dict[str, Any]]: + """Get feedback entries. + + Args: + limit: Maximum number of entries to return + since: Optional ISO date to filter by (get entries after this date) + + Returns: + List of feedback entries + """ + with Database.get_connection() as conn: + cursor = conn.cursor() + if since: + cursor.execute( + """ + SELECT * FROM feedback + WHERE created_at >= ? + ORDER BY created_at DESC + LIMIT ? + """, + (since, limit), + ) + else: + cursor.execute( + """ + SELECT * FROM feedback + ORDER BY created_at DESC + LIMIT ? + """, + (limit,), + ) + rows = cursor.fetchall() + return [dict(row) for row in rows] + + @staticmethod + def get_feedback_by_id(feedback_id: str) -> dict[str, Any] | None: + """Get a single feedback entry by ID.""" + with Database.get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "SELECT * FROM feedback WHERE id = ?", + (feedback_id,), + ) + row = cursor.fetchone() + return dict(row) if row is not None else None + + @staticmethod + def get_feedback_count() -> int: + """Get total count of feedback entries.""" + with Database.get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT COUNT(*) as count FROM feedback") + return cursor.fetchone()["count"] + + @staticmethod def get_metrics_summary() -> dict[str, Any]: """Get aggregate metrics summary for admin dashboard. diff --git a/Biz/PodcastItLater/UI.py b/Biz/PodcastItLater/UI.py index e9ef27d..b243ae7 100644 --- a/Biz/PodcastItLater/UI.py +++ b/Biz/PodcastItLater/UI.py @@ -207,6 +207,14 @@ class PageLayout(Component[AnyChildren, PageLayoutAttrs]): classes=["dropdown-item"], ), ), + html.li( + html.a( + html.i(classes=["bi", "bi-chat-heart-fill", "me-2"]), + "Feedback", + href="/admin/feedback", + classes=["dropdown-item"], + ), + ), classes=["dropdown-menu"], aria_labelledby="adminDropdown", ), @@ -621,6 +629,245 @@ class PricingPageAttrs(Attrs): user: dict[str, typing.Any] | None +class FeedbackFormAttrs(Attrs): + """Attributes for FeedbackForm component.""" + + campaign_id: str | None + success: bool + + +class FeedbackForm(Component[AnyChildren, FeedbackFormAttrs]): + """Feedback collection form component.""" + + @override + def render(self) -> html.div: + campaign_id = self.attrs.get("campaign_id") + success = self.attrs.get("success", False) + + if success: + return html.div( + html.div( + html.div( + html.h3( + html.i( + classes=["bi", "bi-check-circle-fill", "me-2"] + ), + "Thank You!", + classes=["text-success", "mb-3"], + ), + html.p( + "Your feedback has been submitted. " + "We really appreciate you taking the time to help us " + "improve PodcastItLater.", + classes=["lead"], + ), + html.a( + html.i(classes=["bi", "bi-arrow-left", "me-2"]), + "Back to home", + href="/", + classes=["btn", "btn-primary", "mt-3"], + ), + classes=["card-body", "text-center", "py-5"], + ), + classes=["card", "shadow-sm"], + ), + ) + + source_options = [ + ("", "Select one..."), + ("outreach", "Email from Ben/Ava"), + ("search", "Search engine"), + ("social", "Social media"), + ("friend", "Friend/colleague"), + ("other", "Other"), + ] + + return html.div( + html.div( + html.div( + html.h3( + html.i(classes=["bi", "bi-chat-heart-fill", "me-2"]), + "Share Your Feedback", + classes=["card-title", "mb-4"], + ), + html.p( + "Help us make PodcastItLater better! " + "All fields are optional.", + classes=["text-muted", "mb-4"], + ), + html.form( + html.input( + type="hidden", + name="campaign_id", + value=campaign_id or "", + ) + if campaign_id + else html.div(), + # Email + html.div( + html.label( + "Email (optional)", + for_="email", + classes=["form-label"], + ), + html.input( + type="email", + name="email", + id="email", + placeholder="you@example.com", + classes=["form-control"], + ), + html.div( + "If you'd like us to follow up with you", + classes=["form-text"], + ), + classes=["mb-3"], + ), + # Source dropdown + html.div( + html.label( + "How did you hear about us?", + for_="source", + classes=["form-label"], + ), + html.select( + *[ + html.option(label, value=value) + for value, label in source_options + ], + name="source", + id="source", + classes=["form-select"], + ), + classes=["mb-3"], + ), + # Use case + html.div( + html.label( + "What would you use PodcastItLater for?", + for_="use_case", + classes=["form-label"], + ), + html.textarea( + name="use_case", + id="use_case", + rows="3", # type: ignore[call-arg] + placeholder=( + "e.g., catching up on articles during commute, " + "listening to research papers while exercising..." + ), + classes=["form-control"], + ), + classes=["mb-3"], + ), + # General feedback + html.div( + html.label( + "Any other feedback?", + for_="feedback_text", + classes=["form-label"], + ), + html.textarea( + name="feedback_text", + id="feedback_text", + rows="3", # type: ignore[call-arg] + placeholder="Suggestions, issues, feature requests...", + classes=["form-control"], + ), + classes=["mb-3"], + ), + # Rating + html.div( + html.label( + "How likely are you to recommend PIL? (1-5)", + for_="rating", + classes=["form-label"], + ), + html.div( + *[ + html.div( + html.input( + type="radio", + name="rating", + id=f"rating{i}", + value=str(i), + classes=["btn-check"], + ), + html.label( + str(i), + for_=f"rating{i}", + classes=[ + "btn", + "btn-outline-primary", + ], + ), + classes=["me-2"], + ) + for i in range(1, 6) + ], + classes=["d-flex"], + ), + html.div( + "1 = Not likely, 5 = Very likely", + classes=["form-text"], + ), + classes=["mb-4"], + ), + # Submit + html.button( + html.i(classes=["bi", "bi-send-fill", "me-2"]), + "Submit Feedback", + type="submit", + classes=["btn", "btn-primary", "btn-lg"], + ), + action="/feedback", + method="post", + ), + classes=["card-body", "p-4"], + ), + classes=["card", "shadow-sm"], + ), + ) + + +class FeedbackPageAttrs(Attrs): + """Attributes for FeedbackPage component.""" + + user: dict[str, typing.Any] | None + campaign_id: str | None + success: bool + + +class FeedbackPage(Component[AnyChildren, FeedbackPageAttrs]): + """Feedback page with layout.""" + + @override + def render(self) -> PageLayout: + user = self.attrs.get("user") + campaign_id = self.attrs.get("campaign_id") + success = self.attrs.get("success", False) + + return PageLayout( + html.div( + html.div( + html.div( + FeedbackForm( + campaign_id=campaign_id, + success=success, + ), + classes=["col-lg-8", "mx-auto"], + ), + classes=["row"], + ), + ), + user=user, + current_page="feedback", + page_title="Feedback - PodcastItLater", + error=None, + meta_tags=[], + ) + + class PricingPage(Component[AnyChildren, PricingPageAttrs]): """Pricing page component.""" diff --git a/Biz/PodcastItLater/Web.py b/Biz/PodcastItLater/Web.py index 30b5236..076eb95 100644 --- a/Biz/PodcastItLater/Web.py +++ b/Biz/PodcastItLater/Web.py @@ -167,7 +167,8 @@ def send_magic_link(email: str, token: str) -> None: # Create email body magic_link = f"{BASE_URL}/auth/verify?token={token}" - body_text_path.write_text(f""" + body_text_path.write_text( + f""" Hello, Click this link to login to PodcastItLater: @@ -179,7 +180,9 @@ If you didn't request this, please ignore this email. Best, PodcastItLater -""") +""", + encoding="utf-8", + ) try: Biz.EmailAgent.send_email( @@ -1033,6 +1036,83 @@ def pricing(request: Request) -> UI.PricingPage: ) +@app.get("/feedback") +def feedback_form(request: Request) -> UI.FeedbackPage: + """Display feedback form.""" + user_id = request.session.get("user_id") + user = Core.Database.get_user_by_id(user_id) if user_id else None + campaign_id = request.query_params.get("campaign") + + return UI.FeedbackPage( + user=user, + campaign_id=campaign_id, + success=False, + ) + + +@app.post("/feedback") +async def submit_feedback(request: Request) -> UI.FeedbackPage: + """Submit feedback form.""" + import secrets + + user_id = request.session.get("user_id") + user = Core.Database.get_user_by_id(user_id) if user_id else None + + form_data = await request.form() + + email = form_data.get("email") + source = form_data.get("source") + campaign_id = form_data.get("campaign_id") + rating_str = form_data.get("rating") + feedback_text = form_data.get("feedback_text") + use_case = form_data.get("use_case") + + rating = int(rating_str) if rating_str else None + + feedback_id = secrets.token_urlsafe(16) + + Core.Database.create_feedback( + feedback_id=feedback_id, + email=str(email) if email else None, + source=str(source) if source else None, + campaign_id=str(campaign_id) if campaign_id else None, + rating=rating, + feedback_text=str(feedback_text) if feedback_text else None, + use_case=str(use_case) if use_case else None, + ) + + logger.info( + "Received feedback %s from %s", feedback_id, email or "anonymous" + ) + + return UI.FeedbackPage( + user=user, + campaign_id=None, + success=True, + ) + + +@app.get("/api/feedback") +def api_feedback(request: Request) -> Response: + """Return feedback entries as JSON for agent tools.""" + import json + + limit_str = request.query_params.get("limit", "20") + since = request.query_params.get("since") + + try: + limit = min(100, max(1, int(limit_str))) + except ValueError: + limit = 20 + + feedback = Core.Database.get_feedback(limit=limit, since=since) + + return Response( + json.dumps(feedback), + media_type="application/json", + ) + + @app.post("/upgrade") def upgrade(request: Request) -> RedirectResponse: """Start upgrade checkout flow.""" @@ -1832,6 +1912,7 @@ def cancel_queue_item(request: Request, job_id: int) -> Response: app.delete("/queue/{job_id}")(Admin.delete_queue_item) app.get("/admin/users")(Admin.admin_users) app.get("/admin/metrics")(Admin.admin_metrics) +app.get("/admin/feedback")(Admin.admin_feedback) app.post("/admin/users/{user_id}/status")(Admin.update_user_status) app.post("/admin/episode/{episode_id}/toggle-public")( Admin.toggle_episode_public, diff --git a/Omni/Agent/Telegram.hs b/Omni/Agent/Telegram.hs index f950732..76a7be9 100644 --- a/Omni/Agent/Telegram.hs +++ b/Omni/Agent/Telegram.hs @@ -90,6 +90,7 @@ import qualified Omni.Agent.Telegram.Types as Types import qualified Omni.Agent.Tools as Tools import qualified Omni.Agent.Tools.Calendar as Calendar import qualified Omni.Agent.Tools.Email as Email +import qualified Omni.Agent.Tools.Feedback as Feedback import qualified Omni.Agent.Tools.Hledger as Hledger import qualified Omni.Agent.Tools.Http as Http import qualified Omni.Agent.Tools.Notes as Notes @@ -992,8 +993,9 @@ processEngagedMessage tgConfig provider engineCfg msg uid userName chatId userMe pythonTools = [Python.pythonExecTool] httpTools = Http.allHttpTools outreachTools = Outreach.allOutreachTools + feedbackTools = Feedback.allFeedbackTools fileTools = [Tools.readFileTool] - tools = memoryTools <> searchTools <> webReaderTools <> pdfTools <> notesTools <> calendarTools <> todoTools <> messageTools <> hledgerTools <> emailTools <> pythonTools <> httpTools <> outreachTools <> fileTools + tools = memoryTools <> searchTools <> webReaderTools <> pdfTools <> notesTools <> calendarTools <> todoTools <> messageTools <> hledgerTools <> emailTools <> pythonTools <> httpTools <> outreachTools <> feedbackTools <> fileTools let agentCfg = Engine.defaultAgentConfig diff --git a/Omni/Agent/Tools/Feedback.hs b/Omni/Agent/Tools/Feedback.hs new file mode 100644 index 0000000..1ec684c --- /dev/null +++ b/Omni/Agent/Tools/Feedback.hs @@ -0,0 +1,204 @@ +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE NoImplicitPrelude #-} + +-- | Feedback query tool for PodcastItLater user research. +-- +-- Allows the agent to query collected feedback from the PIL database. +-- Feedback is submitted via /feedback on the PIL web app. +-- +-- : out omni-agent-tools-feedback +-- : dep aeson +-- : dep http-conduit +module Omni.Agent.Tools.Feedback + ( -- * Tools + feedbackListTool, + allFeedbackTools, + + -- * Types + FeedbackEntry (..), + ListFeedbackArgs (..), + + -- * Testing + main, + test, + ) +where + +import Alpha +import Data.Aeson ((.!=), (.:), (.:?), (.=)) +import qualified Data.Aeson as Aeson +import qualified Data.Text as Text +import qualified Network.HTTP.Simple as HTTP +import qualified Omni.Agent.Engine as Engine +import qualified Omni.Test as Test +import System.Environment (lookupEnv) + +main :: IO () +main = Test.run test + +test :: Test.Tree +test = + Test.group + "Omni.Agent.Tools.Feedback" + [ Test.unit "feedbackListTool has correct name" <| do + Engine.toolName feedbackListTool Test.@=? "feedback_list", + Test.unit "allFeedbackTools has 1 tool" <| do + length allFeedbackTools Test.@=? 1, + Test.unit "ListFeedbackArgs parses correctly" <| do + let json = Aeson.object ["limit" .= (10 :: Int)] + case Aeson.fromJSON json of + Aeson.Success (args :: ListFeedbackArgs) -> lfaLimit args Test.@=? 10 + Aeson.Error e -> Test.assertFailure e, + Test.unit "ListFeedbackArgs parses with since" <| do + let json = + Aeson.object + [ "limit" .= (20 :: Int), + "since" .= ("2024-01-01" :: Text) + ] + case Aeson.fromJSON json of + Aeson.Success (args :: ListFeedbackArgs) -> do + lfaLimit args Test.@=? 20 + lfaSince args Test.@=? Just "2024-01-01" + Aeson.Error e -> Test.assertFailure e, + Test.unit "FeedbackEntry JSON roundtrip" <| do + let entry = + FeedbackEntry + { feId = "abc123", + feEmail = Just "test@example.com", + feSource = Just "outreach", + feCampaignId = Nothing, + feRating = Just 4, + feFeedbackText = Just "Great product!", + feUseCase = Just "Commute listening", + feCreatedAt = "2024-01-15T10:00:00Z" + } + case Aeson.decode (Aeson.encode entry) of + Nothing -> Test.assertFailure "Failed to decode FeedbackEntry" + Just decoded -> do + feId decoded Test.@=? "abc123" + feEmail decoded Test.@=? Just "test@example.com" + feRating decoded Test.@=? Just 4 + ] + +data FeedbackEntry = FeedbackEntry + { feId :: Text, + feEmail :: Maybe Text, + feSource :: Maybe Text, + feCampaignId :: Maybe Text, + feRating :: Maybe Int, + feFeedbackText :: Maybe Text, + feUseCase :: Maybe Text, + feCreatedAt :: Text + } + deriving (Show, Eq, Generic) + +instance Aeson.ToJSON FeedbackEntry where + toJSON e = + Aeson.object + [ "id" .= feId e, + "email" .= feEmail e, + "source" .= feSource e, + "campaign_id" .= feCampaignId e, + "rating" .= feRating e, + "feedback_text" .= feFeedbackText e, + "use_case" .= feUseCase e, + "created_at" .= feCreatedAt e + ] + +instance Aeson.FromJSON FeedbackEntry where + parseJSON = + Aeson.withObject "FeedbackEntry" <| \v -> + (FeedbackEntry </ (v .: "id")) + <*> (v .:? "email") + <*> (v .:? "source") + <*> (v .:? "campaign_id") + <*> (v .:? "rating") + <*> (v .:? "feedback_text") + <*> (v .:? "use_case") + <*> (v .: "created_at") + +data ListFeedbackArgs = ListFeedbackArgs + { lfaLimit :: Int, + lfaSince :: Maybe Text + } + deriving (Show, Eq, Generic) + +instance Aeson.FromJSON ListFeedbackArgs where + parseJSON = + Aeson.withObject "ListFeedbackArgs" <| \v -> + (ListFeedbackArgs </ (v .:? "limit" .!= 20)) + <*> (v .:? "since") + +allFeedbackTools :: [Engine.Tool] +allFeedbackTools = [feedbackListTool] + +feedbackListTool :: Engine.Tool +feedbackListTool = + Engine.Tool + { Engine.toolName = "feedback_list", + Engine.toolDescription = + "List feedback entries from PodcastItLater users. " + <> "Use to review user research data and understand what potential " + <> "customers want from the product.", + Engine.toolJsonSchema = + Aeson.object + [ "type" .= ("object" :: Text), + "properties" + .= Aeson.object + [ "limit" + .= Aeson.object + [ "type" .= ("integer" :: Text), + "description" .= ("Max entries to return (default: 20)" :: Text) + ], + "since" + .= Aeson.object + [ "type" .= ("string" :: Text), + "description" .= ("ISO date to filter by (entries after this date)" :: Text) + ] + ], + "required" .= ([] :: [Text]) + ], + Engine.toolExecute = executeFeedbackList + } + +executeFeedbackList :: Aeson.Value -> IO Aeson.Value +executeFeedbackList v = + case Aeson.fromJSON v of + Aeson.Error e -> pure (Aeson.object ["error" .= Text.pack e]) + Aeson.Success (args :: ListFeedbackArgs) -> do + mBaseUrl <- lookupEnv "PIL_BASE_URL" + let baseUrl = maybe "http://localhost:8000" Text.pack mBaseUrl + limit = min 100 (max 1 (lfaLimit args)) + sinceParam = case lfaSince args of + Nothing -> "" + Just since -> "&since=" <> since + url = baseUrl <> "/api/feedback?limit=" <> tshow limit <> sinceParam + result <- fetchFeedback url + case result of + Left err -> pure (Aeson.object ["error" .= err]) + Right entries -> + pure + ( Aeson.object + [ "success" .= True, + "count" .= length entries, + "entries" .= entries + ] + ) + +fetchFeedback :: Text -> IO (Either Text [FeedbackEntry]) +fetchFeedback url = do + result <- + try <| do + req <- HTTP.parseRequest (Text.unpack url) + resp <- HTTP.httpLBS req + pure (HTTP.getResponseStatusCode resp, HTTP.getResponseBody resp) + case result of + Left (e :: SomeException) -> pure (Left ("Request failed: " <> tshow e)) + Right (status, body) -> + if status /= 200 + then pure (Left ("HTTP " <> tshow status)) + else case Aeson.decode body of + Nothing -> pure (Left "Failed to parse response") + Just entries -> pure (Right entries) |
