summaryrefslogtreecommitdiff
path: root/Omni
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 /Omni
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
Diffstat (limited to 'Omni')
-rw-r--r--Omni/Agent/Telegram.hs4
-rw-r--r--Omni/Agent/Tools/Feedback.hs204
2 files changed, 207 insertions, 1 deletions
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)