summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBen Sima <ben@bensima.com>2025-12-15 08:47:02 -0500
committerBen Sima <ben@bensima.com>2025-12-15 08:47:02 -0500
commit0baab1972e30c0e4629e67152838e660b02a2537 (patch)
treed82d9402e4a0840777ee3d4e39ab4329f246918b
parentadf693eb82cddd2c383cdebd3392716446ddf054 (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.py6
-rw-r--r--Biz/PodcastItLater/Admin/Handlers.py28
-rw-r--r--Biz/PodcastItLater/Admin/Views.py89
-rw-r--r--Biz/PodcastItLater/Core.py144
-rw-r--r--Biz/PodcastItLater/UI.py247
-rw-r--r--Biz/PodcastItLater/Web.py85
-rw-r--r--Omni/Agent/Telegram.hs4
-rw-r--r--Omni/Agent/Tools/Feedback.hs204
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)