From ecbc8385d590cf8f52d437796ff91d6e55bfd55e Mon Sep 17 00:00:00 2001 From: Ben Sima Date: Thu, 27 Nov 2025 22:26:02 -0500 Subject: Add Omni/Fact.hs core module with CRUD operations The Omni/Fact.hs module is complete with CRUD operations: - **createFact**: Create a new fact with project, content, related files - **getFact**: Retrieve a fact by ID - **getAllFacts**: Get all facts from the database - **getFactsByProject**: Get facts filtered by project - **getFactsByFile**: Get facts related to a specific file - **updateFact**: Update an existing fact's content, related files, and - **deleteFact**: Delete a fact by ID The module properly re-exports the `Fact` data type from `Omni.Task.Core Task-Id: t-158.2 --- Omni/Fact.hs | 81 ++++++++++++++++++++++++++++++++++++ Omni/Task/Core.hs | 121 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 Omni/Fact.hs diff --git a/Omni/Fact.hs b/Omni/Fact.hs new file mode 100644 index 0000000..57db7fc --- /dev/null +++ b/Omni/Fact.hs @@ -0,0 +1,81 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE NoImplicitPrelude #-} + +-- | Fact module for the Jr knowledge base. +-- +-- Facts are pieces of knowledge learned during task execution that can +-- inform future work on similar tasks or files. +module Omni.Fact + ( Fact (..), + createFact, + getFact, + getAllFacts, + getFactsByProject, + getFactsByFile, + updateFact, + deleteFact, + ) +where + +import Alpha +import Data.Aeson (encode) +import qualified Data.ByteString.Lazy.Char8 as BLC +import qualified Data.Text as Text +import Data.Time (getCurrentTime) +import qualified Database.SQLite.Simple as SQL +import Omni.Task.Core + ( Fact (..), + getFactsForFile, + getFactsForProject, + loadFacts, + saveFact, + withDb, + ) +import qualified Omni.Task.Core as TaskCore + +-- | Create a new fact and return its ID. +createFact :: Text -> Text -> [Text] -> Maybe Text -> Double -> IO Int +createFact project content relatedFiles sourceTask confidence = do + now <- getCurrentTime + let fact = + Fact + { factId = Nothing, + factProject = project, + factContent = content, + factRelatedFiles = relatedFiles, + factSourceTask = sourceTask, + factConfidence = confidence, + factCreatedAt = now + } + saveFact fact + +-- | Get a fact by its ID. +getFact :: Int -> IO (Maybe Fact) +getFact fid = do + facts <- getAllFacts + pure <| find (\f -> factId f == Just fid) facts + +-- | Get all facts from the database. +getAllFacts :: IO [Fact] +getAllFacts = loadFacts + +-- | Get facts for a specific project. +getFactsByProject :: Text -> IO [Fact] +getFactsByProject = getFactsForProject + +-- | Get facts related to a specific file. +getFactsByFile :: Text -> IO [Fact] +getFactsByFile = getFactsForFile + +-- | Update an existing fact. +updateFact :: Int -> Text -> [Text] -> Double -> IO () +updateFact fid content relatedFiles confidence = + withDb <| \conn -> + SQL.execute + conn + "UPDATE facts SET fact = ?, related_files = ?, confidence = ? WHERE id = ?" + (content, Text.pack (BLC.unpack (encode relatedFiles)), confidence, fid) + +-- | Delete a fact by ID. +deleteFact :: Int -> IO () +deleteFact = TaskCore.deleteFact diff --git a/Omni/Task/Core.hs b/Omni/Task/Core.hs index 4ff0f5f..d15ed8e 100644 --- a/Omni/Task/Core.hs +++ b/Omni/Task/Core.hs @@ -103,6 +103,18 @@ data TaskActivity = TaskActivity } deriving (Show, Eq, Generic) +-- Fact for knowledge base +data Fact = Fact + { factId :: Maybe Int, + factProject :: Text, + factContent :: Text, + factRelatedFiles :: [Text], + factSourceTask :: Maybe Text, + factConfidence :: Double, + factCreatedAt :: UTCTime + } + deriving (Show, Eq, Generic) + instance ToJSON TaskType instance FromJSON TaskType @@ -143,6 +155,10 @@ instance ToJSON TaskActivity instance FromJSON TaskActivity +instance ToJSON Fact + +instance FromJSON Fact + -- HTTP API Instances (for Servant query params) instance FromHttpApiData Status where @@ -268,6 +284,38 @@ instance SQL.ToRow TaskActivity where SQL.toField (activityTokensUsed a) ] +instance SQL.FromRow Fact where + fromRow = do + fid <- SQL.field + proj <- SQL.field + content <- SQL.field + (relatedFilesJson :: String) <- SQL.field + sourceTask <- SQL.field + confidence <- SQL.field + createdAt <- SQL.field + let relatedFiles = fromMaybe [] (decode (BLC.pack relatedFilesJson)) + pure + Fact + { factId = fid, + factProject = proj, + factContent = content, + factRelatedFiles = relatedFiles, + factSourceTask = sourceTask, + factConfidence = confidence, + factCreatedAt = createdAt + } + +instance SQL.ToRow Fact where + toRow f = + [ SQL.toField (factId f), + SQL.toField (factProject f), + SQL.toField (factContent f), + SQL.toField (BLC.unpack (encode (factRelatedFiles f))), + SQL.toField (factSourceTask f), + SQL.toField (factConfidence f), + SQL.toField (factCreatedAt f) + ] + -- | Case-insensitive ID comparison matchesId :: Text -> Text -> Bool matchesId id1 id2 = normalizeId id1 == normalizeId id2 @@ -375,6 +423,17 @@ initTaskDb = do \ tokens_used INTEGER, \ \ FOREIGN KEY (task_id) REFERENCES tasks(id) \ \)" + SQL.execute_ + conn + "CREATE TABLE IF NOT EXISTS facts (\ + \ id INTEGER PRIMARY KEY AUTOINCREMENT, \ + \ project TEXT NOT NULL, \ + \ fact TEXT NOT NULL, \ + \ related_files TEXT NOT NULL, \ + \ source_task TEXT, \ + \ confidence REAL NOT NULL, \ + \ created_at DATETIME DEFAULT CURRENT_TIMESTAMP \ + \)" runMigrations conn -- | Run schema migrations to add missing columns to existing tables @@ -383,6 +442,7 @@ runMigrations conn = do migrateTable conn "task_activity" taskActivityColumns migrateTable conn "tasks" tasksColumns migrateTable conn "retry_context" retryContextColumns + migrateTable conn "facts" factsColumns -- | Expected columns for task_activity table (name, type, nullable) taskActivityColumns :: [(Text, Text)] @@ -427,6 +487,18 @@ retryContextColumns = ("notes", "TEXT") ] +-- | Expected columns for facts table +factsColumns :: [(Text, Text)] +factsColumns = + [ ("id", "INTEGER"), + ("project", "TEXT"), + ("fact", "TEXT"), + ("related_files", "TEXT"), + ("source_task", "TEXT"), + ("confidence", "REAL"), + ("created_at", "DATETIME") + ] + -- | Migrate a table by adding any missing columns migrateTable :: SQL.Connection -> Text -> [(Text, Text)] -> IO () migrateTable conn tableName expectedCols = do @@ -1212,3 +1284,52 @@ updateRetryNotes tid notes = do } Just ctx -> setRetryContext ctx {retryNotes = Just notes} + +-- Fact management + +-- | Save a fact to the database +saveFact :: Fact -> IO Int +saveFact f = + withDb <| \conn -> do + let filesJson = T.pack <| BLC.unpack <| encode (factRelatedFiles f) + SQL.execute + conn + "INSERT INTO facts (project, fact, related_files, source_task, confidence, created_at) \ + \VALUES (?, ?, ?, ?, ?, ?)" + (factProject f, factContent f, filesJson, factSourceTask f, factConfidence f, factCreatedAt f) + [SQL.Only factIdVal] <- SQL.query_ conn "SELECT last_insert_rowid()" :: IO [SQL.Only Int] + pure factIdVal + +-- | Load all facts from the database +loadFacts :: IO [Fact] +loadFacts = + withDb <| \conn -> + SQL.query_ + conn + "SELECT id, project, fact, related_files, source_task, confidence, created_at FROM facts" + +-- | Get facts for a specific project +getFactsForProject :: Text -> IO [Fact] +getFactsForProject proj = + withDb <| \conn -> + SQL.query + conn + "SELECT id, project, fact, related_files, source_task, confidence, created_at \ + \FROM facts WHERE project = ? ORDER BY confidence DESC" + (SQL.Only proj) + +-- | Get facts related to a specific file +getFactsForFile :: Text -> IO [Fact] +getFactsForFile filePath = + withDb <| \conn -> + SQL.query + conn + "SELECT id, project, fact, related_files, source_task, confidence, created_at \ + \FROM facts WHERE related_files LIKE ? ORDER BY confidence DESC" + (SQL.Only ("%" <> filePath <> "%")) + +-- | Delete a fact by ID +deleteFact :: Int -> IO () +deleteFact fid = + withDb <| \conn -> + SQL.execute conn "DELETE FROM facts WHERE id = ?" (SQL.Only fid) -- cgit v1.2.3