Step 14: Connect components — chamishim karsay zahav
Three connections built and tested: - Haskell Shoresh Engine via Erlang Port (JSON lines over stdin/stdout) - Prolog Talmudic Engine via Unix domain socket (JSON over /tmp/shorashim/talmudic.sock) - Erlang Sanhedrin via native BEAM calls New: Server.hs (JSON-line server loop), json_bridge.pl + socket_server.pl (Prolog socket interface), ShoreshEnginePort + TalmudicEngineSocket + SanhedrinClient (Elixir GenServer wrappers). BereishitCycle now routes unresolved cases through Talmudic Engine and Sanhedrin. All connections gracefully degrade — pipeline falls back to L1 data when engines unavailable. 46 Elixir tests, 33 Haskell tests, 57 Prolog tests — 0 failures. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
f8f70c77e5
commit
c67672777b
18 files changed
+1435
-121
No files matched your search
@@ -32,6 +32,7 @@ library
|
|||||||
Shoresh.Havdalah
|
Shoresh.Havdalah
|
||||||
Shoresh.Kriah
|
Shoresh.Kriah
|
||||||
Shoresh.KiTov
|
Shoresh.KiTov
|
||||||
|
Shoresh.Server
|
||||||
Luchot.Firmware
|
Luchot.Firmware
|
||||||
hs-source-dirs: src
|
hs-source-dirs: src
|
||||||
default-language: Haskell2010
|
default-language: Haskell2010
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import Shoresh.Teamim
|
|||||||
import Shoresh.KetivQere
|
import Shoresh.KetivQere
|
||||||
import Shoresh.Lexicon
|
import Shoresh.Lexicon
|
||||||
import Shoresh.Graph
|
import Shoresh.Graph
|
||||||
|
import Shoresh.Server (runServer)
|
||||||
import Luchot.Firmware
|
import Luchot.Firmware
|
||||||
|
|
||||||
main :: IO ()
|
main :: IO ()
|
||||||
@@ -34,6 +35,7 @@ main = do
|
|||||||
args <- getArgs
|
args <- getArgs
|
||||||
case args of
|
case args of
|
||||||
["verify"] -> runVerification
|
["verify"] -> runVerification
|
||||||
|
["server"] -> runServer
|
||||||
["verse", book, ch, vs] -> runVerse book ch vs
|
["verse", book, ch, vs] -> runVerse book ch vs
|
||||||
["root", rootText] -> runRoot rootText
|
["root", rootText] -> runRoot rootText
|
||||||
["gematria", word] -> runGematria word
|
["gematria", word] -> runGematria word
|
||||||
@@ -45,6 +47,7 @@ printUsage = do
|
|||||||
putStrLn ""
|
putStrLn ""
|
||||||
putStrLn "Usage:"
|
putStrLn "Usage:"
|
||||||
putStrLn " shoresh-engine verify Run Step 7 verification"
|
putStrLn " shoresh-engine verify Run Step 7 verification"
|
||||||
|
putStrLn " shoresh-engine server JSON-line server (Erlang Port mode)"
|
||||||
putStrLn " shoresh-engine verse <book> <ch> <vs> Read and parse a verse from L1"
|
putStrLn " shoresh-engine verse <book> <ch> <vs> Read and parse a verse from L1"
|
||||||
putStrLn " shoresh-engine root <root> Activate a root through all 7 binyanim"
|
putStrLn " shoresh-engine root <root> Activate a root through all 7 binyanim"
|
||||||
putStrLn " shoresh-engine gematria <word> Compute gematria of a Hebrew word"
|
putStrLn " shoresh-engine gematria <word> Compute gematria of a Hebrew word"
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ data L1Word = L1Word
|
|||||||
, l1wShoresh :: !(Maybe Text)
|
, l1wShoresh :: !(Maybe Text)
|
||||||
} deriving (Show, Generic)
|
} deriving (Show, Generic)
|
||||||
|
|
||||||
|
instance ToJSON L1Word
|
||||||
instance FromJSON L1Word where
|
instance FromJSON L1Word where
|
||||||
parseJSON = withObject "L1Word" $ \v -> L1Word
|
parseJSON = withObject "L1Word" $ \v -> L1Word
|
||||||
<$> v .: "position"
|
<$> v .: "position"
|
||||||
@@ -100,6 +101,7 @@ data L1Verse = L1Verse
|
|||||||
, l1vWords :: ![L1Word]
|
, l1vWords :: ![L1Word]
|
||||||
} deriving (Show, Generic)
|
} deriving (Show, Generic)
|
||||||
|
|
||||||
|
instance ToJSON L1Verse
|
||||||
instance FromJSON L1Verse where
|
instance FromJSON L1Verse where
|
||||||
parseJSON = withObject "L1Verse" $ \v -> L1Verse
|
parseJSON = withObject "L1Verse" $ \v -> L1Verse
|
||||||
<$> v .: "ref"
|
<$> v .: "ref"
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
{-# LANGUAGE DeriveGeneric #-}
|
||||||
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
|
||||||
|
-- | Server — JSON-line port interface for pipeline integration.
|
||||||
|
--
|
||||||
|
-- Step 14 (Architecture §4): "Connect components: Elixir pipeline
|
||||||
|
-- calls Haskell engine."
|
||||||
|
--
|
||||||
|
-- Construction spec §3.1: Protocol is Erlang Port (JSON).
|
||||||
|
-- The engine reads one JSON line from stdin, processes it,
|
||||||
|
-- writes one JSON line to stdout, and repeats.
|
||||||
|
--
|
||||||
|
-- Buffering is set to LineBuffering — critical for Erlang Port
|
||||||
|
-- communication. Haskell defaults to block-buffering on non-TTY
|
||||||
|
-- stdout, which would deadlock the port.
|
||||||
|
module Shoresh.Server
|
||||||
|
( runServer
|
||||||
|
, handleRequest
|
||||||
|
) where
|
||||||
|
|
||||||
|
import qualified Data.ByteString.Char8 as BS8
|
||||||
|
import qualified Data.ByteString.Lazy as BL
|
||||||
|
import qualified Data.ByteString.Lazy.Char8 as BLC
|
||||||
|
import qualified Data.Map.Strict as Map
|
||||||
|
import Data.Text (Text)
|
||||||
|
import qualified Data.Text as T
|
||||||
|
import Data.Aeson
|
||||||
|
import Data.Maybe (mapMaybe)
|
||||||
|
import System.IO (hSetBuffering, stdin, stdout, stderr,
|
||||||
|
BufferMode(..), hFlush, hIsEOF)
|
||||||
|
import System.Directory (doesFileExist)
|
||||||
|
import Control.Exception (catch, SomeException)
|
||||||
|
|
||||||
|
import Shoresh.Types
|
||||||
|
import Shoresh.Gematria
|
||||||
|
import Shoresh.Binyanim
|
||||||
|
import Shoresh.Teamim
|
||||||
|
import Shoresh.Lexicon
|
||||||
|
|
||||||
|
|
||||||
|
-- | Run the JSON-line server loop.
|
||||||
|
-- Called by Main.hs when invoked with "server" argument.
|
||||||
|
runServer :: IO ()
|
||||||
|
runServer = do
|
||||||
|
-- Critical: set line buffering on stdin/stdout.
|
||||||
|
-- Haskell defaults to block-buffering on non-TTY, which would
|
||||||
|
-- deadlock the Erlang Port.
|
||||||
|
hSetBuffering stdin LineBuffering
|
||||||
|
hSetBuffering stdout LineBuffering
|
||||||
|
hSetBuffering stderr LineBuffering
|
||||||
|
serverLoop
|
||||||
|
|
||||||
|
|
||||||
|
-- | Main server loop: read line, process, write line, repeat.
|
||||||
|
serverLoop :: IO ()
|
||||||
|
serverLoop = do
|
||||||
|
eof <- hIsEOF stdin
|
||||||
|
if eof
|
||||||
|
then return ()
|
||||||
|
else do
|
||||||
|
line <- BL.fromStrict <$> BS8.getLine
|
||||||
|
response <- processLine line
|
||||||
|
BLC.putStrLn (encode response)
|
||||||
|
hFlush stdout
|
||||||
|
serverLoop
|
||||||
|
|
||||||
|
|
||||||
|
-- | Process a single JSON line and return a response.
|
||||||
|
processLine :: BL.ByteString -> IO Value
|
||||||
|
processLine line = case eitherDecode line of
|
||||||
|
Left err -> return $ errorResponse "PARSE_ERROR" (T.pack err)
|
||||||
|
Right req -> handleRequest req
|
||||||
|
`catch` \e -> return $ errorResponse "INTERNAL_ERROR"
|
||||||
|
(T.pack $ show (e :: SomeException))
|
||||||
|
|
||||||
|
|
||||||
|
-- | Handle a parsed request and produce a response.
|
||||||
|
handleRequest :: Value -> IO Value
|
||||||
|
handleRequest val = case parseOperation val of
|
||||||
|
Nothing -> return $ errorResponse "INVALID_OPERATION" "Missing or invalid 'operation' field"
|
||||||
|
Just "process" -> handleProcess val
|
||||||
|
Just "separate" -> handleSeparate val
|
||||||
|
Just "name" -> handleName val
|
||||||
|
Just "evaluate" -> handleEvaluate val
|
||||||
|
Just op -> return $ errorResponse "UNKNOWN_OPERATION" ("Unknown operation: " <> op)
|
||||||
|
|
||||||
|
|
||||||
|
-- | Extract the operation string from a request.
|
||||||
|
parseOperation :: Value -> Maybe Text
|
||||||
|
parseOperation (Object obj) = case obj .: "operation" of
|
||||||
|
Success t -> Just t
|
||||||
|
_ -> Nothing
|
||||||
|
where
|
||||||
|
(.:) o k = case Map.lookup k (toMap o) of
|
||||||
|
Just (String s) -> Success s
|
||||||
|
_ -> Error "not found"
|
||||||
|
toMap = \o -> case fromJSON (Object o) :: Result (Map.Map Text Value) of
|
||||||
|
Success m -> m
|
||||||
|
Error _ -> Map.empty
|
||||||
|
parseOperation _ = Nothing
|
||||||
|
|
||||||
|
|
||||||
|
-- | Handle "process" operation — root activation, te'amim parse, gematria.
|
||||||
|
-- Construction spec §3.1: Takes text + scope, returns roots_activated,
|
||||||
|
-- parse_tree, dual_readings.
|
||||||
|
handleProcess :: Value -> IO Value
|
||||||
|
handleProcess val = do
|
||||||
|
let reqText = extractText val
|
||||||
|
mscope = extractScope val
|
||||||
|
|
||||||
|
-- Parse roots from input text
|
||||||
|
let roots = extractRoots reqText
|
||||||
|
|
||||||
|
-- Build activated roots with gematria and binyanim
|
||||||
|
let activatedRoots = map activateRoot roots
|
||||||
|
|
||||||
|
-- If scope is provided, load verse from L1 and parse te'amim
|
||||||
|
(parseTree, dualReadings) <- case mscope of
|
||||||
|
Nothing -> return (Null, toJSON ([] :: [Value]))
|
||||||
|
Just (book, ch, vs) -> do
|
||||||
|
let path = "l1/torah/" ++ T.unpack (bookToText book)
|
||||||
|
++ "/" ++ show ch ++ "/" ++ show vs ++ ".json"
|
||||||
|
exists <- doesFileExist path
|
||||||
|
if not exists
|
||||||
|
then return (Null, toJSON ([] :: [Value]))
|
||||||
|
else do
|
||||||
|
mverse <- loadL1Verse path
|
||||||
|
case mverse of
|
||||||
|
Nothing -> return (Null, toJSON ([] :: [Value]))
|
||||||
|
Just verse -> do
|
||||||
|
let annotated = mapMaybe annotateL1Word (l1vWords verse)
|
||||||
|
tree = readTeamim annotated
|
||||||
|
ptJson = treeToJson tree
|
||||||
|
drJson = toJSON ([] :: [Value]) -- Phase 3: extract ketiv/qere
|
||||||
|
return (ptJson, drJson)
|
||||||
|
|
||||||
|
return $ object
|
||||||
|
[ "status" .= ("ok" :: Text)
|
||||||
|
, "result" .= object
|
||||||
|
[ "roots_activated" .= activatedRoots
|
||||||
|
, "parse_tree" .= parseTree
|
||||||
|
, "dual_readings" .= dualReadings
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
-- | Handle "separate" operation — te'amim-based separation.
|
||||||
|
-- Construction spec §3.1 operation "separate".
|
||||||
|
handleSeparate :: Value -> IO Value
|
||||||
|
handleSeparate _val = do
|
||||||
|
-- Phase 2: returns stub separation
|
||||||
|
-- Full implementation requires the annotated words from context
|
||||||
|
return $ object
|
||||||
|
[ "status" .= ("ok" :: Text)
|
||||||
|
, "result" .= object
|
||||||
|
[ "groups" .= ([] :: [Value])
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
-- | Handle "name" operation — assign names to categories.
|
||||||
|
handleName :: Value -> IO Value
|
||||||
|
handleName _val = do
|
||||||
|
return $ object
|
||||||
|
[ "status" .= ("ok" :: Text)
|
||||||
|
, "result" .= object
|
||||||
|
[ "name" .= ("unnamed" :: Text)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
-- | Handle "evaluate" operation — ki tov check.
|
||||||
|
handleEvaluate :: Value -> IO Value
|
||||||
|
handleEvaluate _val = do
|
||||||
|
return $ object
|
||||||
|
[ "status" .= ("ok" :: Text)
|
||||||
|
, "result" .= object
|
||||||
|
[ "evaluation" .= ("teiku" :: Text)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Helpers
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- | Extract text from the input field of a request.
|
||||||
|
extractText :: Value -> Text
|
||||||
|
extractText (Object obj) = case Map.lookup "input" (toMap obj) of
|
||||||
|
Just (Object inp) -> case Map.lookup "text" (toMap inp) of
|
||||||
|
Just (String t) -> t
|
||||||
|
_ -> ""
|
||||||
|
_ -> ""
|
||||||
|
where
|
||||||
|
toMap o = case fromJSON (Object o) :: Result (Map.Map Text Value) of
|
||||||
|
Success m -> m
|
||||||
|
Error _ -> Map.empty
|
||||||
|
extractText _ = ""
|
||||||
|
|
||||||
|
|
||||||
|
-- | Extract scope (book, chapter, verse) from the input field.
|
||||||
|
extractScope :: Value -> Maybe (Book, Int, Int)
|
||||||
|
extractScope (Object obj) = do
|
||||||
|
inp <- case Map.lookup "input" (toMap obj) of
|
||||||
|
Just (Object i) -> Just (toMap i)
|
||||||
|
_ -> Nothing
|
||||||
|
scopeObj <- case Map.lookup "scope" inp of
|
||||||
|
Just (Object s) -> Just (toMap s)
|
||||||
|
_ -> Nothing
|
||||||
|
bookVal <- Map.lookup "book" scopeObj
|
||||||
|
bookText <- case bookVal of
|
||||||
|
String s -> Just s
|
||||||
|
_ -> Nothing
|
||||||
|
book <- textToBook bookText
|
||||||
|
ch <- case Map.lookup "chapter" scopeObj of
|
||||||
|
Just (Number n) -> Just (floor n)
|
||||||
|
_ -> Nothing
|
||||||
|
vs <- case Map.lookup "verse" scopeObj of
|
||||||
|
Just (Number n) -> Just (floor n)
|
||||||
|
_ -> Nothing
|
||||||
|
return (book, ch, vs)
|
||||||
|
where
|
||||||
|
toMap o = case fromJSON (Object o) :: Result (Map.Map Text Value) of
|
||||||
|
Success m -> m
|
||||||
|
Error _ -> Map.empty
|
||||||
|
extractScope _ = Nothing
|
||||||
|
|
||||||
|
|
||||||
|
-- | Extract Hebrew roots from input text.
|
||||||
|
extractRoots :: Text -> [Shoresh]
|
||||||
|
extractRoots t =
|
||||||
|
let words' = T.words t
|
||||||
|
-- Try each word as a potential root
|
||||||
|
candidates = mapMaybe textToShoresh words'
|
||||||
|
-- Also try the whole text as a single root
|
||||||
|
wholeRoot = textToShoresh (T.filter (\c -> c >= '\x05D0' && c <= '\x05EA') t)
|
||||||
|
in case candidates of
|
||||||
|
[] -> case wholeRoot of
|
||||||
|
Just r -> [r]
|
||||||
|
Nothing -> []
|
||||||
|
rs -> rs
|
||||||
|
|
||||||
|
|
||||||
|
-- | Activate a root: expand through binyanim, compute gematria, find neighbors.
|
||||||
|
activateRoot :: Shoresh -> Value
|
||||||
|
activateRoot sh =
|
||||||
|
let expanded = expand sh
|
||||||
|
gv = gematriaRoot sh
|
||||||
|
consonantNames = map consonantName [c1 sh, c2 sh, c3 sh]
|
||||||
|
defaultBinyan = case Map.lookup Qal expanded of
|
||||||
|
Just p -> T.pack (show (Shoresh.Types.binyan p))
|
||||||
|
Nothing -> "Qal"
|
||||||
|
in object
|
||||||
|
[ "shoresh" .= consonantNames
|
||||||
|
, "shoresh_hebrew" .= shoreshToText sh
|
||||||
|
, "binyan" .= defaultBinyan
|
||||||
|
, "domain" .= ("unknown" :: Text) -- Phase 3: domain lookup
|
||||||
|
, "torah_refs" .= ([] :: [Value]) -- Phase 3: lexicon lookup
|
||||||
|
, "gematria_value" .= gv
|
||||||
|
, "gematria_neighbors" .= ([] :: [Value]) -- Phase 3: lexicon needed
|
||||||
|
, "neighbors" .= ([] :: [Value]) -- Phase 3: lexicon needed
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
-- | Convert an L1 word to an AnnotatedWord for te'amim parsing.
|
||||||
|
annotateL1Word :: L1Word -> Maybe AnnotatedWord
|
||||||
|
annotateL1Word w = case l1wTeamim w of
|
||||||
|
[] -> Nothing
|
||||||
|
(t:_) -> case taamFromName t of
|
||||||
|
Nothing -> Nothing
|
||||||
|
Just taam -> Just AnnotatedWord
|
||||||
|
{ awWord = l1wText w
|
||||||
|
, awTaam = taam
|
||||||
|
, awRole = taamRole taam
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
-- | Convert a ParsedVerse tree to JSON.
|
||||||
|
treeToJson :: ParsedVerse -> Value
|
||||||
|
treeToJson (Leaf w) = object
|
||||||
|
[ "type" .= ("leaf" :: Text)
|
||||||
|
, "word" .= awWord w
|
||||||
|
, "taam" .= T.pack (show (awTaam w))
|
||||||
|
]
|
||||||
|
treeToJson (Branch t l r) = object
|
||||||
|
[ "type" .= ("branch" :: Text)
|
||||||
|
, "taam" .= T.pack (show t)
|
||||||
|
, "left" .= treeToJson l
|
||||||
|
, "right" .= treeToJson r
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
-- | Build an error response.
|
||||||
|
errorResponse :: Text -> Text -> Value
|
||||||
|
errorResponse code msg = object
|
||||||
|
[ "status" .= ("error" :: Text)
|
||||||
|
, "error" .= object
|
||||||
|
[ "code" .= code
|
||||||
|
, "message" .= msg
|
||||||
|
]
|
||||||
|
]
|
||||||
@@ -49,7 +49,7 @@ import Data.Text (Text)
|
|||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
import Data.Map.Strict (Map)
|
import Data.Map.Strict (Map)
|
||||||
import GHC.Generics (Generic)
|
import GHC.Generics (Generic)
|
||||||
import Data.Aeson (FromJSON(..), ToJSON(..), withText)
|
import Data.Aeson (FromJSON(..), ToJSON(..), FromJSONKey(..), ToJSONKey(..), withText)
|
||||||
|
|
||||||
-- | The 22 Hebrew consonants.
|
-- | The 22 Hebrew consonants.
|
||||||
-- These are the building blocks. No more, no less.
|
-- These are the building blocks. No more, no less.
|
||||||
@@ -145,6 +145,8 @@ data Binyan
|
|||||||
|
|
||||||
instance ToJSON Binyan
|
instance ToJSON Binyan
|
||||||
instance FromJSON Binyan
|
instance FromJSON Binyan
|
||||||
|
instance ToJSONKey Binyan
|
||||||
|
instance FromJSONKey Binyan
|
||||||
|
|
||||||
-- | All seven binyanim as a list.
|
-- | All seven binyanim as a list.
|
||||||
allBinyanim :: [Binyan]
|
allBinyanim :: [Binyan]
|
||||||
@@ -298,6 +300,9 @@ data ShoreshEntry = ShoreshEntry
|
|||||||
, seGlosses :: !(Map Binyan Text)
|
, seGlosses :: !(Map Binyan Text)
|
||||||
} deriving (Eq, Show, Generic)
|
} deriving (Eq, Show, Generic)
|
||||||
|
|
||||||
|
instance ToJSON ShoreshEntry
|
||||||
|
instance FromJSON ShoreshEntry
|
||||||
|
|
||||||
|
|
||||||
-- | Raw input to the processing pipeline.
|
-- | Raw input to the processing pipeline.
|
||||||
data Input = Input
|
data Input = Input
|
||||||
@@ -305,18 +310,27 @@ data Input = Input
|
|||||||
, inputSource :: !Text
|
, inputSource :: !Text
|
||||||
} deriving (Show, Generic)
|
} deriving (Show, Generic)
|
||||||
|
|
||||||
|
instance ToJSON Input
|
||||||
|
instance FromJSON Input
|
||||||
|
|
||||||
-- | A distinguished category after havdalah.
|
-- | A distinguished category after havdalah.
|
||||||
data Category = Category
|
data Category = Category
|
||||||
{ catName :: !Text
|
{ catName :: !Text
|
||||||
, catContent :: !Text
|
, catContent :: !Text
|
||||||
} deriving (Show, Generic)
|
} deriving (Show, Generic)
|
||||||
|
|
||||||
|
instance ToJSON Category
|
||||||
|
instance FromJSON Category
|
||||||
|
|
||||||
-- | A processed result ready for evaluation.
|
-- | A processed result ready for evaluation.
|
||||||
data ProcessedResult = ProcessedResult
|
data ProcessedResult = ProcessedResult
|
||||||
{ prCategories :: ![Category]
|
{ prCategories :: ![Category]
|
||||||
, prRoots :: ![ShoreshEntry]
|
, prRoots :: ![ShoreshEntry]
|
||||||
} deriving (Show, Generic)
|
} deriving (Show, Generic)
|
||||||
|
|
||||||
|
instance ToJSON ProcessedResult
|
||||||
|
instance FromJSON ProcessedResult
|
||||||
|
|
||||||
-- | The evaluation outcome.
|
-- | The evaluation outcome.
|
||||||
-- Tov: fits the specification.
|
-- Tov: fits the specification.
|
||||||
-- LoTov: does not fit, with reason.
|
-- LoTov: does not fit, with reason.
|
||||||
@@ -326,3 +340,6 @@ data Evaluation
|
|||||||
| LoTov !Text
|
| LoTov !Text
|
||||||
| Teiku
|
| Teiku
|
||||||
deriving (Eq, Show, Generic)
|
deriving (Eq, Show, Generic)
|
||||||
|
|
||||||
|
instance ToJSON Evaluation
|
||||||
|
instance FromJSON Evaluation
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
%% json_bridge.pl — JSON ↔ Prolog term conversion for Talmudic Engine.
|
||||||
|
%%
|
||||||
|
%% Construction §3.3: Bidirectional conversion between the JSON schema
|
||||||
|
%% defined in the spec and internal Prolog terms used by api.pl.
|
||||||
|
%%
|
||||||
|
%% Inbound: JSON dict → Prolog request term
|
||||||
|
%% Outbound: Prolog response term → JSON dict
|
||||||
|
|
||||||
|
:- module(json_bridge, [
|
||||||
|
json_to_request/2,
|
||||||
|
response_to_json/2,
|
||||||
|
parse_root_name/2
|
||||||
|
]).
|
||||||
|
|
||||||
|
:- use_module(library(json)).
|
||||||
|
:- use_module(library(lists)).
|
||||||
|
|
||||||
|
%% ============================================================
|
||||||
|
%% INBOUND: JSON → Prolog request
|
||||||
|
%% ============================================================
|
||||||
|
|
||||||
|
%% Convert a JSON dict (as parsed by SWI-Prolog's json library) to a
|
||||||
|
%% Prolog request term compatible with api:process_request/2.
|
||||||
|
%%
|
||||||
|
%% JSON format (Construction §3.3):
|
||||||
|
%% { "action": "interpret", "case": { "ref": "case-uuid",
|
||||||
|
%% "facts": [...], "question": "...", "context_roots": [...] } }
|
||||||
|
%%
|
||||||
|
%% Prolog format:
|
||||||
|
%% request(interpret, CaseRef, Domain, Roots, Question)
|
||||||
|
|
||||||
|
json_to_request(Dict, request(Action, CaseRef, Domain, Roots, Question)) :-
|
||||||
|
get_dict(action, Dict, ActionStr),
|
||||||
|
atom_string(Action, ActionStr),
|
||||||
|
get_dict(case, Dict, CaseDict),
|
||||||
|
get_dict(ref, CaseDict, RefStr),
|
||||||
|
atom_string(CaseRef, RefStr),
|
||||||
|
%% Extract question
|
||||||
|
( get_dict(question, CaseDict, QuestionStr)
|
||||||
|
-> atom_string(Question, QuestionStr)
|
||||||
|
; Question = none
|
||||||
|
),
|
||||||
|
%% Extract context_roots and derive domain
|
||||||
|
( get_dict(context_roots, CaseDict, RootNames)
|
||||||
|
-> maplist(parse_root_name, RootNames, Roots)
|
||||||
|
; Roots = []
|
||||||
|
),
|
||||||
|
%% Derive domain from roots (first root's known domain, or 'general')
|
||||||
|
( Roots = [FirstRoot|_],
|
||||||
|
root_to_domain(FirstRoot, D)
|
||||||
|
-> Domain = D
|
||||||
|
; Domain = general
|
||||||
|
).
|
||||||
|
|
||||||
|
%% Handle halt_check action (different format — no case, just action)
|
||||||
|
json_to_request(Dict, request(halt_check, Action)) :-
|
||||||
|
get_dict(action, Dict, "halt_check"),
|
||||||
|
get_dict(check, Dict, CheckDict),
|
||||||
|
get_dict(type, CheckDict, TypeStr),
|
||||||
|
atom_string(Type, TypeStr),
|
||||||
|
( get_dict(target, CheckDict, TargetStr)
|
||||||
|
-> atom_string(Target, TargetStr)
|
||||||
|
; Target = unknown
|
||||||
|
),
|
||||||
|
( get_dict(source, CheckDict, SourceStr)
|
||||||
|
-> atom_string(Source, SourceStr)
|
||||||
|
; Source = unknown
|
||||||
|
),
|
||||||
|
Action = action(Type, source(Source), target(Target)).
|
||||||
|
|
||||||
|
|
||||||
|
%% ============================================================
|
||||||
|
%% OUTBOUND: Prolog response → JSON
|
||||||
|
%% ============================================================
|
||||||
|
|
||||||
|
%% Convert a Prolog response from api.pl to a JSON-serializable dict.
|
||||||
|
%%
|
||||||
|
%% Prolog format: response(ok, interpreted, result(...))
|
||||||
|
%% JSON format (Construction §3.3):
|
||||||
|
%% { "status": "resolved", "interpretation": { ... } }
|
||||||
|
|
||||||
|
%% Interpreted response
|
||||||
|
response_to_json(response(ok, interpreted, result(ref(Ref), mitzvot(Mitzvot),
|
||||||
|
verses(Verses), reading(Reading),
|
||||||
|
confidence(Conf))),
|
||||||
|
Dict) :-
|
||||||
|
maplist(term_to_string, Mitzvot, MitzvotStrs),
|
||||||
|
maplist(term_to_string, Verses, VerseStrs),
|
||||||
|
term_to_string(Reading, ReadingStr),
|
||||||
|
atom_string(Conf, ConfStr),
|
||||||
|
atom_string(Ref, RefStr),
|
||||||
|
Dict = json{status: "resolved",
|
||||||
|
interpretation: json{text: ReadingStr,
|
||||||
|
prolog_form: RefStr,
|
||||||
|
grounding: MitzvotStrs,
|
||||||
|
verses: VerseStrs,
|
||||||
|
confidence: ConfStr},
|
||||||
|
challenge: null}.
|
||||||
|
|
||||||
|
%% Challenged response
|
||||||
|
response_to_json(response(ok, resolved_challenged,
|
||||||
|
result(interpretation(Interp), challenge(Kushya))),
|
||||||
|
Dict) :-
|
||||||
|
term_to_string(Interp, InterpStr),
|
||||||
|
term_to_string(Kushya, KushyaStr),
|
||||||
|
Dict = json{status: "challenged",
|
||||||
|
interpretation: json{text: InterpStr,
|
||||||
|
prolog_form: InterpStr,
|
||||||
|
grounding: [],
|
||||||
|
confidence: "medium"},
|
||||||
|
challenge: json{kushya: KushyaStr,
|
||||||
|
contradicting: "",
|
||||||
|
strength: "moderate"}}.
|
||||||
|
|
||||||
|
%% Teiku response
|
||||||
|
response_to_json(response(ok, teiku, result(record(Record))), Dict) :-
|
||||||
|
term_to_string(Record, RecordStr),
|
||||||
|
Dict = json{status: "teiku",
|
||||||
|
interpretation: json{text: RecordStr,
|
||||||
|
prolog_form: RecordStr,
|
||||||
|
grounding: [],
|
||||||
|
confidence: "low"},
|
||||||
|
challenge: null}.
|
||||||
|
|
||||||
|
%% No interpretation
|
||||||
|
response_to_json(response(error, no_interpretation, CaseRef), Dict) :-
|
||||||
|
atom_string(CaseRef, RefStr),
|
||||||
|
Dict = json{status: "teiku",
|
||||||
|
interpretation: json{text: "No interpretation found",
|
||||||
|
prolog_form: RefStr,
|
||||||
|
grounding: [],
|
||||||
|
confidence: "low"},
|
||||||
|
challenge: null}.
|
||||||
|
|
||||||
|
%% No resolution
|
||||||
|
response_to_json(response(error, no_resolution, CaseRef), Dict) :-
|
||||||
|
atom_string(CaseRef, RefStr),
|
||||||
|
Dict = json{status: "teiku",
|
||||||
|
interpretation: json{text: "No resolution found",
|
||||||
|
prolog_form: RefStr,
|
||||||
|
grounding: [],
|
||||||
|
confidence: "low"},
|
||||||
|
challenge: null}.
|
||||||
|
|
||||||
|
%% Halt response
|
||||||
|
response_to_json(response(halt, violations(Halts), action(Action)), Dict) :-
|
||||||
|
maplist(term_to_string, Halts, HaltStrs),
|
||||||
|
term_to_string(Action, ActionStr),
|
||||||
|
Dict = json{status: "halt",
|
||||||
|
violations: HaltStrs,
|
||||||
|
action: ActionStr}.
|
||||||
|
|
||||||
|
%% No halt
|
||||||
|
response_to_json(response(ok, no_halt, action(Action)), Dict) :-
|
||||||
|
term_to_string(Action, ActionStr),
|
||||||
|
Dict = json{status: "ok",
|
||||||
|
violations: [],
|
||||||
|
action: ActionStr}.
|
||||||
|
|
||||||
|
%% Catch-all for unexpected responses
|
||||||
|
response_to_json(Response, Dict) :-
|
||||||
|
term_to_string(Response, Str),
|
||||||
|
Dict = json{status: "error",
|
||||||
|
error: json{code: "UNEXPECTED_RESPONSE",
|
||||||
|
message: Str}}.
|
||||||
|
|
||||||
|
|
||||||
|
%% ============================================================
|
||||||
|
%% HELPERS
|
||||||
|
%% ============================================================
|
||||||
|
|
||||||
|
%% Parse a root name like "bet-resh-aleph" into an atom bet_resh_aleph
|
||||||
|
parse_root_name(Name, Atom) :-
|
||||||
|
( string(Name) -> S = Name
|
||||||
|
; atom_string(Name, S)
|
||||||
|
),
|
||||||
|
split_string(S, "-", "", Parts),
|
||||||
|
atomic_list_concat(Parts, '_', Atom).
|
||||||
|
|
||||||
|
%% Convert any Prolog term to a string
|
||||||
|
term_to_string(Term, Str) :-
|
||||||
|
term_string(Term, Str).
|
||||||
|
|
||||||
|
%% Map known roots to domains (basic mapping; will be extended)
|
||||||
|
root_to_domain(bet_resh_aleph, creation).
|
||||||
|
root_to_domain(yod_tsade_resh, creation).
|
||||||
|
root_to_domain(aleph_lamed_he, holiness).
|
||||||
|
root_to_domain(qof_dalet_shin, holiness).
|
||||||
|
root_to_domain(shin_pe_tet, judgment).
|
||||||
|
root_to_domain(dalet_yod_nun, judgment).
|
||||||
|
root_to_domain(chet_samekh_dalet, mercy).
|
||||||
|
root_to_domain(resh_chet_mem, mercy).
|
||||||
|
root_to_domain(aleph_vav_resh, light).
|
||||||
|
root_to_domain(chet_shin_kaf, darkness).
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
%% socket_server.pl — Unix domain socket listener for Talmudic Engine.
|
||||||
|
%%
|
||||||
|
%% Construction §3.3: "JSON over Unix domain socket"
|
||||||
|
%% Step 14 (Architecture §4): "Connect components"
|
||||||
|
%%
|
||||||
|
%% Listens on a Unix domain socket, accepts JSON requests,
|
||||||
|
%% dispatches to api:process_request/2 via json_bridge,
|
||||||
|
%% returns JSON responses.
|
||||||
|
%%
|
||||||
|
%% Protocol: One JSON object per line (same as Haskell server).
|
||||||
|
%% Connection model: accept, handle one or more requests per
|
||||||
|
%% connection, close when client disconnects.
|
||||||
|
|
||||||
|
:- module(socket_server, [
|
||||||
|
serve/0,
|
||||||
|
serve/1,
|
||||||
|
stop_server/0
|
||||||
|
]).
|
||||||
|
|
||||||
|
:- use_module(library(socket)).
|
||||||
|
:- use_module(library(json)).
|
||||||
|
:- use_module(library(readutil)).
|
||||||
|
|
||||||
|
:- use_module('../interface/api').
|
||||||
|
:- use_module('../interface/json_bridge').
|
||||||
|
|
||||||
|
%% Default socket path — dev path; production uses /var/run/shorashim/talmudic.sock
|
||||||
|
default_socket_path('/tmp/shorashim/talmudic.sock').
|
||||||
|
|
||||||
|
%% Dynamic flag to control server loop
|
||||||
|
:- dynamic server_running/1.
|
||||||
|
|
||||||
|
%% ============================================================
|
||||||
|
%% SERVER ENTRY POINTS
|
||||||
|
%% ============================================================
|
||||||
|
|
||||||
|
%% serve/0 — Start server on default socket path
|
||||||
|
serve :-
|
||||||
|
default_socket_path(Path),
|
||||||
|
serve(Path).
|
||||||
|
|
||||||
|
%% serve/1 — Start server on given socket path
|
||||||
|
serve(Path) :-
|
||||||
|
%% Clean up any stale socket file
|
||||||
|
( exists_file(Path)
|
||||||
|
-> delete_file(Path)
|
||||||
|
; true
|
||||||
|
),
|
||||||
|
%% Ensure parent directory exists
|
||||||
|
file_directory_name(Path, Dir),
|
||||||
|
( exists_directory(Dir)
|
||||||
|
-> true
|
||||||
|
; make_directory_path(Dir)
|
||||||
|
),
|
||||||
|
%% Create Unix domain socket
|
||||||
|
unix_domain_socket(ServerSocket),
|
||||||
|
tcp_bind(ServerSocket, Path),
|
||||||
|
tcp_listen(ServerSocket, 5),
|
||||||
|
format('Talmudic Engine listening on ~w~n', [Path]),
|
||||||
|
flush_output,
|
||||||
|
%% Set running flag
|
||||||
|
assert(server_running(true)),
|
||||||
|
%% Accept loop
|
||||||
|
accept_loop(ServerSocket, Path).
|
||||||
|
|
||||||
|
%% Stop the server gracefully
|
||||||
|
stop_server :-
|
||||||
|
retractall(server_running(_)).
|
||||||
|
|
||||||
|
%% ============================================================
|
||||||
|
%% CONNECTION HANDLING
|
||||||
|
%% ============================================================
|
||||||
|
|
||||||
|
%% Main accept loop — waits for connections
|
||||||
|
accept_loop(ServerSocket, Path) :-
|
||||||
|
( server_running(true)
|
||||||
|
-> catch(
|
||||||
|
( tcp_accept(ServerSocket, ClientSocket, _Peer),
|
||||||
|
tcp_open_socket(ClientSocket, InStream, OutStream),
|
||||||
|
%% Handle this client's requests
|
||||||
|
catch(
|
||||||
|
handle_connection(InStream, OutStream),
|
||||||
|
Error,
|
||||||
|
( format(user_error, 'Connection error: ~w~n', [Error]),
|
||||||
|
flush_output(user_error)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
close(InStream, [force(true)]),
|
||||||
|
close(OutStream, [force(true)]),
|
||||||
|
accept_loop(ServerSocket, Path)
|
||||||
|
),
|
||||||
|
AcceptError,
|
||||||
|
( format(user_error, 'Accept error: ~w~n', [AcceptError]),
|
||||||
|
flush_output(user_error),
|
||||||
|
%% Clean up
|
||||||
|
tcp_close_socket(ServerSocket),
|
||||||
|
( exists_file(Path) -> delete_file(Path) ; true ),
|
||||||
|
fail
|
||||||
|
)
|
||||||
|
)
|
||||||
|
; %% Server stopped — clean up
|
||||||
|
tcp_close_socket(ServerSocket),
|
||||||
|
( exists_file(Path) -> delete_file(Path) ; true ),
|
||||||
|
format('Talmudic Engine stopped.~n'),
|
||||||
|
flush_output
|
||||||
|
).
|
||||||
|
|
||||||
|
%% Handle a single connection — read lines until EOF
|
||||||
|
handle_connection(InStream, OutStream) :-
|
||||||
|
read_line_to_string(InStream, Line),
|
||||||
|
( Line == end_of_file
|
||||||
|
-> true
|
||||||
|
; process_json_line(Line, ResponseJson),
|
||||||
|
%% Write JSON response on a single line
|
||||||
|
json_write_dict(OutStream, ResponseJson),
|
||||||
|
nl(OutStream),
|
||||||
|
flush_output(OutStream),
|
||||||
|
%% Continue reading from this connection
|
||||||
|
handle_connection(InStream, OutStream)
|
||||||
|
).
|
||||||
|
|
||||||
|
|
||||||
|
%% ============================================================
|
||||||
|
%% JSON REQUEST PROCESSING
|
||||||
|
%% ============================================================
|
||||||
|
|
||||||
|
%% Process a JSON line string and produce a JSON response string
|
||||||
|
process_json_line(Line, ResponseJson) :-
|
||||||
|
catch(
|
||||||
|
( %% Parse JSON string to dict
|
||||||
|
atom_json_dict(Line, RequestDict, []),
|
||||||
|
%% Convert JSON to Prolog request
|
||||||
|
json_to_request(RequestDict, Request),
|
||||||
|
%% Dispatch to API
|
||||||
|
process_request(Request, Response),
|
||||||
|
%% Convert response to JSON
|
||||||
|
response_to_json(Response, ResponseJson)
|
||||||
|
),
|
||||||
|
Error,
|
||||||
|
( term_string(Error, ErrorStr),
|
||||||
|
ResponseJson = json{status: "error",
|
||||||
|
error: json{code: "PROCESSING_ERROR",
|
||||||
|
message: ErrorStr}}
|
||||||
|
)
|
||||||
|
).
|
||||||
@@ -17,6 +17,8 @@
|
|||||||
:- use_module(halt/shfichut_damim).
|
:- use_module(halt/shfichut_damim).
|
||||||
:- use_module(halt/gilui_arayot).
|
:- use_module(halt/gilui_arayot).
|
||||||
:- use_module(interface/api).
|
:- use_module(interface/api).
|
||||||
|
:- use_module(interface/json_bridge).
|
||||||
|
:- use_module(interface/socket_server).
|
||||||
|
|
||||||
%% Load test files
|
%% Load test files
|
||||||
:- use_module(library(plunit)).
|
:- use_module(library(plunit)).
|
||||||
|
|||||||
@@ -10,7 +10,13 @@ config :vav_pipeline,
|
|||||||
# Shabbat check interval (1 minute)
|
# Shabbat check interval (1 minute)
|
||||||
cycle_boundary_check_interval_ms: 60_000,
|
cycle_boundary_check_interval_ms: 60_000,
|
||||||
# ShemaContext: no caching — always read fresh
|
# ShemaContext: no caching — always read fresh
|
||||||
shema_context_cache_ttl_ms: 0
|
shema_context_cache_ttl_ms: 0,
|
||||||
|
# Step 14 — Component connection config
|
||||||
|
# Shoresh Engine (Haskell) — binary path for Erlang Port
|
||||||
|
shoresh_engine_binary: "shoresh-engine",
|
||||||
|
shoresh_engine_cwd: nil,
|
||||||
|
# Talmudic Engine (Prolog) — Unix domain socket path
|
||||||
|
talmudic_socket_path: "/tmp/shorashim/talmudic.sock"
|
||||||
|
|
||||||
# Import environment specific config
|
# Import environment specific config
|
||||||
import_config "#{config_env()}.exs"
|
import_config "#{config_env()}.exs"
|
||||||
@@ -4,18 +4,48 @@ defmodule VavPipeline.Application do
|
|||||||
|
|
||||||
Starts the supervision tree with:
|
Starts the supervision tree with:
|
||||||
- ShabbatTimer (mandatory cessation monitor)
|
- ShabbatTimer (mandatory cessation monitor)
|
||||||
|
- ShoreshEnginePort (Erlang Port to Haskell engine, if binary found)
|
||||||
|
- TalmudicEngineSocket (Unix socket to Prolog engine)
|
||||||
|
|
||||||
|
Step 14 (Architecture §4): Component connections supervised.
|
||||||
|
The port and socket are optional — the pipeline degrades gracefully
|
||||||
|
if external engines are not available (clients fall back to stubs).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
use Application
|
use Application
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def start(_type, _args) do
|
def start(_type, _args) do
|
||||||
children = [
|
shoresh_binary = Application.get_env(:vav_pipeline, :shoresh_engine_binary, "shoresh-engine")
|
||||||
# ShabbatTimer monitors Shabbat boundaries
|
shoresh_cwd = Application.get_env(:vav_pipeline, :shoresh_engine_cwd, nil)
|
||||||
{VavPipeline.ShabbatTimer, []}
|
talmudic_socket = Application.get_env(:vav_pipeline, :talmudic_socket_path, "/tmp/shorashim/talmudic.sock")
|
||||||
]
|
|
||||||
|
children =
|
||||||
|
[
|
||||||
|
{VavPipeline.ShabbatTimer, []}
|
||||||
|
]
|
||||||
|
|> maybe_add_shoresh_port(shoresh_binary, shoresh_cwd)
|
||||||
|
|> maybe_add_talmudic_socket(talmudic_socket)
|
||||||
|
|
||||||
opts = [strategy: :one_for_one, name: VavPipeline.Supervisor]
|
opts = [strategy: :one_for_one, name: VavPipeline.Supervisor]
|
||||||
Supervisor.start_link(children, opts)
|
Supervisor.start_link(children, opts)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp maybe_add_shoresh_port(children, binary, cwd) do
|
||||||
|
case System.find_executable(binary) do
|
||||||
|
nil ->
|
||||||
|
Logger.warning("ShoreshEnginePort: binary '#{binary}' not in PATH — running without Haskell engine")
|
||||||
|
children
|
||||||
|
|
||||||
|
path ->
|
||||||
|
children ++ [{VavPipeline.ShoreshEnginePort, [binary: path, cwd: cwd]}]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_add_talmudic_socket(children, socket_path) do
|
||||||
|
# Always add the socket client — it connects lazily on first request
|
||||||
|
children ++ [{VavPipeline.TalmudicEngineSocket, [socket_path: socket_path]}]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -19,7 +19,8 @@ defmodule VavPipeline.BereishitCycle do
|
|||||||
Latency budget (Construction spec §2.5): 5000ms total
|
Latency budget (Construction spec §2.5): 5000ms total
|
||||||
"""
|
"""
|
||||||
|
|
||||||
alias VavPipeline.{Specification, CycleLog, Manna}
|
alias VavPipeline.{Specification, CycleLog, Manna,
|
||||||
|
ShoreshEngineClient, TalmudicEngineClient, SanhedrinClient}
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
@@ -81,27 +82,22 @@ defmodule VavPipeline.BereishitCycle do
|
|||||||
"""
|
"""
|
||||||
@spec vayomer(map(), VavPipeline.Shema.shema_context()) :: map()
|
@spec vayomer(map(), VavPipeline.Shema.shema_context()) :: map()
|
||||||
def vayomer(state, context) do
|
def vayomer(state, context) do
|
||||||
# In Phase 2: use L1 data from ShemaContext directly
|
# Step 14: Call Shoresh Engine via port. Falls back to L1 data.
|
||||||
# In Phase 3: call ShoreshEngineClient.process(state.raw, context)
|
case ShoreshEngineClient.process(state.raw, context) do
|
||||||
roots =
|
{:ok, engine_result} ->
|
||||||
context.verse.words
|
Map.merge(state, %{
|
||||||
|> Enum.filter(fn w -> w.shoresh != nil end)
|
roots_activated: get_result(engine_result, :roots_activated, "roots_activated", []),
|
||||||
|> Enum.map(fn w ->
|
parse_tree: get_result(engine_result, :parse_tree, "parse_tree", nil),
|
||||||
%{
|
dual_readings: get_result(engine_result, :dual_readings, "dual_readings", []),
|
||||||
shoresh: w.shoresh,
|
teamim_parse: context.teamim_marks,
|
||||||
word: w.consonants,
|
ketiv_qere: context.ketiv_qere,
|
||||||
position: w.position,
|
stage: :vayomer
|
||||||
morph: w.morph,
|
})
|
||||||
gematria: Map.get(context.root_gematria, w.shoresh, 0)
|
|
||||||
}
|
|
||||||
end)
|
|
||||||
|
|
||||||
Map.merge(state, %{
|
{:error, reason} ->
|
||||||
roots_activated: roots,
|
Logger.warning("BereishitCycle.vayomer: engine error (#{reason}), using L1 fallback")
|
||||||
teamim_parse: context.teamim_marks,
|
vayomer_fallback(state, context)
|
||||||
ketiv_qere: context.ketiv_qere,
|
end
|
||||||
stage: :vayomer
|
|
||||||
})
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
@@ -202,7 +198,7 @@ defmodule VavPipeline.BereishitCycle do
|
|||||||
"""
|
"""
|
||||||
@spec erev_voker(VavPipeline.cycle_result(), VavPipeline.Shema.shema_context()) ::
|
@spec erev_voker(VavPipeline.cycle_result(), VavPipeline.Shema.shema_context()) ::
|
||||||
VavPipeline.cycle_result()
|
VavPipeline.cycle_result()
|
||||||
def erev_voker(result, _context) do
|
def erev_voker(result, context) do
|
||||||
# Log the cycle
|
# Log the cycle
|
||||||
phase =
|
phase =
|
||||||
case result do
|
case result do
|
||||||
@@ -217,11 +213,133 @@ defmodule VavPipeline.BereishitCycle do
|
|||||||
# Enforce anti-hoarding
|
# Enforce anti-hoarding
|
||||||
Manna.enforce_anti_hoarding()
|
Manna.enforce_anti_hoarding()
|
||||||
|
|
||||||
result
|
# Step 14: Route unresolved cases to Talmudic Engine / Sanhedrin
|
||||||
|
case result do
|
||||||
|
{:unresolved, data} ->
|
||||||
|
route_if_unresolved(data, context)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
result
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# --- Routing ---
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
defp route_if_unresolved(data, context) do
|
||||||
|
# Extract roots for the Talmudic Engine query
|
||||||
|
roots = Map.get(data, :roots_activated, [])
|
||||||
|
context_roots = Enum.map(roots, fn
|
||||||
|
%{shoresh: s} when is_binary(s) -> s
|
||||||
|
%{"shoresh" => s} when is_list(s) -> Enum.join(s, "-")
|
||||||
|
_ -> "unknown"
|
||||||
|
end)
|
||||||
|
|
||||||
|
case_ref = "cycle-#{:erlang.system_time(:millisecond)}"
|
||||||
|
|
||||||
|
talmudic_context = %{
|
||||||
|
context_roots: context_roots,
|
||||||
|
question: "Interpret roots #{inspect(context_roots)} in context"
|
||||||
|
}
|
||||||
|
|
||||||
|
# First: consult the Talmudic Engine
|
||||||
|
case TalmudicEngineClient.interpret(case_ref, talmudic_context) do
|
||||||
|
{:ok, %{"status" => "resolved"} = interpretation} ->
|
||||||
|
Logger.info("BereishitCycle: Talmudic Engine resolved case #{case_ref}")
|
||||||
|
{:ok, Map.merge(data, %{
|
||||||
|
talmudic_interpretation: interpretation,
|
||||||
|
resolution: :talmudic,
|
||||||
|
stage: :erev_voker
|
||||||
|
})}
|
||||||
|
|
||||||
|
{:ok, %{"status" => "teiku"}} ->
|
||||||
|
# Still unresolved — escalate to Sanhedrin
|
||||||
|
Logger.info("BereishitCycle: Talmudic Engine returned teiku, escalating to Sanhedrin")
|
||||||
|
escalate_to_sanhedrin(data, context, case_ref, context_roots)
|
||||||
|
|
||||||
|
{:ok, interpretation} ->
|
||||||
|
# Challenged or other status — include and return
|
||||||
|
{:unresolved, Map.merge(data, %{
|
||||||
|
talmudic_interpretation: interpretation,
|
||||||
|
stage: :erev_voker
|
||||||
|
})}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.warning("BereishitCycle: Talmudic routing failed: #{reason}")
|
||||||
|
{:unresolved, Map.put(data, :routing_error, reason)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp escalate_to_sanhedrin(data, _context, case_ref, context_roots) do
|
||||||
|
sanhedrin_params = %{
|
||||||
|
case_ref: case_ref,
|
||||||
|
complexity: :simple,
|
||||||
|
question: "Deliberate on unresolved interpretation of #{inspect(context_roots)}",
|
||||||
|
context: %{
|
||||||
|
roots: context_roots,
|
||||||
|
named_results: Map.get(data, :named_results, [])
|
||||||
|
},
|
||||||
|
requested_by: "bereishit_cycle"
|
||||||
|
}
|
||||||
|
|
||||||
|
case SanhedrinClient.submit_case(sanhedrin_params) do
|
||||||
|
{:ok, decision} ->
|
||||||
|
outcome = Map.get(decision, :outcome, :teiku)
|
||||||
|
Logger.info("BereishitCycle: Sanhedrin decided #{outcome} for #{case_ref}")
|
||||||
|
|
||||||
|
case outcome do
|
||||||
|
:accepted ->
|
||||||
|
{:ok, Map.merge(data, %{
|
||||||
|
sanhedrin_decision: decision,
|
||||||
|
resolution: :sanhedrin,
|
||||||
|
stage: :erev_voker
|
||||||
|
})}
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
{:unresolved, Map.merge(data, %{
|
||||||
|
sanhedrin_decision: decision,
|
||||||
|
stage: :erev_voker
|
||||||
|
})}
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.warning("BereishitCycle: Sanhedrin escalation failed: #{reason}")
|
||||||
|
{:unresolved, Map.put(data, :escalation_error, reason)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# --- L1 Fallback (when port is unavailable) ---
|
||||||
|
|
||||||
|
defp vayomer_fallback(state, context) do
|
||||||
|
roots =
|
||||||
|
context.verse.words
|
||||||
|
|> Enum.filter(fn w -> w.shoresh != nil end)
|
||||||
|
|> Enum.map(fn w ->
|
||||||
|
%{
|
||||||
|
shoresh: w.shoresh,
|
||||||
|
word: w.consonants,
|
||||||
|
position: w.position,
|
||||||
|
morph: w.morph,
|
||||||
|
gematria: Map.get(context.root_gematria, w.shoresh, 0)
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|
||||||
|
Map.merge(state, %{
|
||||||
|
roots_activated: roots,
|
||||||
|
teamim_parse: context.teamim_marks,
|
||||||
|
ketiv_qere: context.ketiv_qere,
|
||||||
|
stage: :vayomer
|
||||||
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
# --- Private helpers ---
|
# --- Private helpers ---
|
||||||
|
|
||||||
|
# Get a value from a map that may have atom or string keys
|
||||||
|
# (atom keys from L1 fallback, string keys from JSON engine response)
|
||||||
|
defp get_result(map, atom_key, string_key, default) do
|
||||||
|
Map.get(map, atom_key, Map.get(map, string_key, default))
|
||||||
|
end
|
||||||
|
|
||||||
defp extract_content(input) do
|
defp extract_content(input) do
|
||||||
Map.get(input, :content, "") |> String.trim()
|
Map.get(input, :content, "") |> String.trim()
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
defmodule VavPipeline.SanhedrinClient do
|
||||||
|
@moduledoc """
|
||||||
|
Client interface to the Erlang Sanhedrin deliberation engine.
|
||||||
|
|
||||||
|
Construction spec §3.2: Native BEAM message passing.
|
||||||
|
Protocol: Erlang records via escalation:submit_case/1.
|
||||||
|
|
||||||
|
The Sanhedrin runs in the same BEAM VM. This module translates
|
||||||
|
Elixir maps into Erlang record tuples and calls the Sanhedrin
|
||||||
|
API directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Submit a case for deliberation.
|
||||||
|
|
||||||
|
Construction spec §3.2: submit_case with case_ref, complexity,
|
||||||
|
question, context, requested_by.
|
||||||
|
|
||||||
|
Returns {:ok, decision_map} or {:error, reason}.
|
||||||
|
"""
|
||||||
|
@spec submit_case(map()) :: {:ok, map()} | {:error, String.t()}
|
||||||
|
def submit_case(case_params) do
|
||||||
|
case_ref = Map.get(case_params, :case_ref, generate_ref())
|
||||||
|
complexity = Map.get(case_params, :complexity, :simple)
|
||||||
|
question = Map.get(case_params, :question, "")
|
||||||
|
context = Map.get(case_params, :context, %{})
|
||||||
|
requested_by = Map.get(case_params, :requested_by, "pipeline")
|
||||||
|
|
||||||
|
# Build Erlang record tuple matching #sanhedrin_case{}
|
||||||
|
# Record: {sanhedrin_case, case_ref, complexity, question, context,
|
||||||
|
# requested_by, submitted_at}
|
||||||
|
erlang_case =
|
||||||
|
{:sanhedrin_case, case_ref, complexity, question, context,
|
||||||
|
requested_by, :erlang.system_time(:millisecond)}
|
||||||
|
|
||||||
|
try do
|
||||||
|
case :escalation.submit_case(erlang_case) do
|
||||||
|
{:ok, decision_ref} ->
|
||||||
|
# Decision is stored asynchronously by the panel.
|
||||||
|
# Wait briefly for the result, then fetch from clerk.
|
||||||
|
wait_for_decision(decision_ref, case_ref)
|
||||||
|
|
||||||
|
{:error, {:halted, halt_type, reason}} ->
|
||||||
|
{:error, "Halted (#{halt_type}): #{reason}"}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:error, "Submission failed: #{inspect(reason)}"}
|
||||||
|
end
|
||||||
|
catch
|
||||||
|
:exit, {:noproc, _} ->
|
||||||
|
Logger.warning("SanhedrinClient: Sanhedrin not running, returning teiku")
|
||||||
|
{:ok, stub_teiku(case_ref)}
|
||||||
|
|
||||||
|
kind, reason ->
|
||||||
|
Logger.error("SanhedrinClient: #{kind} — #{inspect(reason)}")
|
||||||
|
{:error, "Sanhedrin call failed: #{inspect(reason)}"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Get a previously stored decision from the clerk.
|
||||||
|
"""
|
||||||
|
@spec get_decision(binary()) :: {:ok, map()} | {:error, :not_found}
|
||||||
|
def get_decision(case_ref) do
|
||||||
|
try do
|
||||||
|
case :clerk.get_decision(case_ref) do
|
||||||
|
{:ok, decision} ->
|
||||||
|
{:ok, decision_to_map(decision)}
|
||||||
|
|
||||||
|
:not_found ->
|
||||||
|
{:error, :not_found}
|
||||||
|
end
|
||||||
|
catch
|
||||||
|
:exit, {:noproc, _} ->
|
||||||
|
{:error, :not_found}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# --- Private ---
|
||||||
|
|
||||||
|
defp wait_for_decision(decision_ref, case_ref) do
|
||||||
|
# The panel deliberates asynchronously. Poll clerk briefly.
|
||||||
|
wait_for_decision(decision_ref, case_ref, 10, 500)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp wait_for_decision(_decision_ref, case_ref, 0, _interval) do
|
||||||
|
# Timeout — return teiku (unresolved)
|
||||||
|
Logger.warning("SanhedrinClient: deliberation timeout for #{case_ref}")
|
||||||
|
{:ok, stub_teiku(case_ref)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp wait_for_decision(decision_ref, case_ref, retries, interval) do
|
||||||
|
Process.sleep(interval)
|
||||||
|
|
||||||
|
case get_decision(case_ref) do
|
||||||
|
{:ok, decision} ->
|
||||||
|
{:ok, decision}
|
||||||
|
|
||||||
|
{:error, :not_found} ->
|
||||||
|
wait_for_decision(decision_ref, case_ref, retries - 1, interval)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp decision_to_map({:decision, case_ref, outcome, majority, minority, panel_size,
|
||||||
|
delib_time, unanimous_check}) do
|
||||||
|
%{
|
||||||
|
case_ref: case_ref,
|
||||||
|
outcome: outcome,
|
||||||
|
majority: majority,
|
||||||
|
minority: minority,
|
||||||
|
panel_size: panel_size,
|
||||||
|
deliberation_time_ms: delib_time,
|
||||||
|
unanimous_check: unanimous_check
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp decision_to_map(other), do: other
|
||||||
|
|
||||||
|
defp stub_teiku(case_ref) do
|
||||||
|
%{
|
||||||
|
case_ref: case_ref,
|
||||||
|
outcome: :teiku,
|
||||||
|
majority: %{position: "Sanhedrin not available", reasoning: "Engine not running", torah_refs: [], vote_count: 0},
|
||||||
|
minority: %{position: "", reasoning: "", torah_refs: [], vote_count: 0},
|
||||||
|
panel_size: 0,
|
||||||
|
deliberation_time_ms: 0,
|
||||||
|
unanimous_check: :passed
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp generate_ref do
|
||||||
|
:crypto.strong_rand_bytes(16)
|
||||||
|
|> Base.encode16(case: :lower)
|
||||||
|
|> then(fn hex ->
|
||||||
|
<<a::binary-size(8), b::binary-size(4), c::binary-size(4), d::binary-size(4), e::binary-size(12)>> = hex
|
||||||
|
"#{a}-#{b}-#{c}-#{d}-#{e}"
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -2,54 +2,29 @@ defmodule VavPipeline.ShoreshEngineClient do
|
|||||||
@moduledoc """
|
@moduledoc """
|
||||||
Client interface to the Haskell Shoresh Engine.
|
Client interface to the Haskell Shoresh Engine.
|
||||||
|
|
||||||
Architecture §3.3: NIF or port to Haskell engine.
|
Architecture §3.3: Erlang Port to Haskell engine.
|
||||||
Construction spec §3.1: JSON protocol over port.
|
Construction spec §3.1: JSON protocol over port.
|
||||||
|
|
||||||
Phase 2: Stub that returns data from L1/ShemaContext.
|
Delegates to ShoreshEnginePort (the live Haskell process).
|
||||||
Phase 3 (Step 14): Full port/NIF connection to shoresh-engine binary.
|
Falls back to L1/ShemaContext extraction if port is unavailable.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Process input through the Shoresh Engine.
|
Process input through the Shoresh Engine.
|
||||||
|
Sends JSON request to Haskell port; falls back to L1 data.
|
||||||
Phase 2: Uses L1 data directly.
|
|
||||||
Phase 3: Sends JSON request to Haskell port.
|
|
||||||
"""
|
"""
|
||||||
@spec process(String.t(), map()) :: {:ok, map()} | {:error, String.t()}
|
@spec process(String.t(), map()) :: {:ok, map()} | {:error, String.t()}
|
||||||
def process(_raw_text, context) do
|
def process(raw_text, context) do
|
||||||
# Phase 2 stub — extract from ShemaContext
|
call_port(:process, raw_text, context)
|
||||||
roots =
|
|
||||||
context.verse.words
|
|
||||||
|> Enum.filter(fn w -> w.shoresh != nil end)
|
|
||||||
|> Enum.map(fn w ->
|
|
||||||
%{
|
|
||||||
shoresh: w.shoresh,
|
|
||||||
binyan: infer_binyan(w.morph),
|
|
||||||
domain: :unknown,
|
|
||||||
torah_refs: [%{book: context.verse.book, chapter: context.verse.chapter, verse: context.verse.verse}],
|
|
||||||
gematria_value: Map.get(context.root_gematria, w.shoresh, 0)
|
|
||||||
}
|
|
||||||
end)
|
|
||||||
|
|
||||||
{:ok,
|
|
||||||
%{
|
|
||||||
roots_activated: roots,
|
|
||||||
parse_tree: nil,
|
|
||||||
dual_readings: context.ketiv_qere
|
|
||||||
}}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Separate utterance using te'amim marks.
|
Separate utterance using te'amim marks.
|
||||||
|
|
||||||
Phase 2: Returns groups based on melekh positions.
|
|
||||||
Phase 3: Delegates to Haskell Havdalah module.
|
|
||||||
"""
|
"""
|
||||||
@spec separate(map(), [map()]) :: [map()]
|
@spec separate(map(), [map()]) :: [map()]
|
||||||
def separate(utterance, teamim_marks) do
|
def separate(utterance, teamim_marks) do
|
||||||
# Phase 2 stub — group by melekh divisions
|
|
||||||
melekh_positions =
|
melekh_positions =
|
||||||
teamim_marks
|
teamim_marks
|
||||||
|> Enum.filter(fn m -> m.role == "melekh" end)
|
|> Enum.filter(fn m -> m.role == "melekh" end)
|
||||||
@@ -60,29 +35,20 @@ defmodule VavPipeline.ShoreshEngineClient do
|
|||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Name a distinction.
|
Name a distinction.
|
||||||
|
|
||||||
Phase 2: Returns the primary root as the name.
|
|
||||||
Phase 3: Delegates to Haskell Kriah module.
|
|
||||||
"""
|
"""
|
||||||
@spec name(map()) :: map()
|
@spec name(map()) :: map()
|
||||||
def name(distinction) do
|
def name(distinction) do
|
||||||
Map.put(distinction, :name, Map.get(distinction, :shoresh, "unnamed"))
|
Map.put(distinction, :name, Map.get(distinction, :shoresh, "unnamed"))
|
||||||
end
|
end
|
||||||
|
|
||||||
# Infer binyan from morphological code
|
# --- Private ---
|
||||||
# H = Hebrew, V = verb, then binyan indicator
|
|
||||||
defp infer_binyan(morph) when is_binary(morph) do
|
defp call_port(operation, text, context) do
|
||||||
cond do
|
if Process.whereis(VavPipeline.ShoreshEnginePort) do
|
||||||
String.contains?(morph, "Vq") -> :qal
|
apply(VavPipeline.ShoreshEnginePort, operation, [text, context])
|
||||||
String.contains?(morph, "VN") -> :nifal
|
else
|
||||||
String.contains?(morph, "Vp") -> :piel
|
{:error, "port not started"}
|
||||||
String.contains?(morph, "VP") -> :pual
|
|
||||||
String.contains?(morph, "Vh") -> :hifil
|
|
||||||
String.contains?(morph, "VH") -> :hufal
|
|
||||||
String.contains?(morph, "Vt") -> :hitpael
|
|
||||||
true -> :qal
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp infer_binyan(_), do: :qal
|
|
||||||
end
|
end
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
defmodule VavPipeline.ShoreshEnginePort do
|
||||||
|
@moduledoc """
|
||||||
|
Erlang Port interface to the Haskell Shoresh Engine.
|
||||||
|
|
||||||
|
Step 14 (Architecture §4): "Connect components: Elixir pipeline
|
||||||
|
calls Haskell engine."
|
||||||
|
|
||||||
|
Construction spec §3.1: JSON protocol over Erlang Port.
|
||||||
|
Protocol: JSON line in, JSON line out.
|
||||||
|
|
||||||
|
The port launches `shoresh-engine server` as an OS process.
|
||||||
|
Communication is through stdin/stdout with line-delimited JSON.
|
||||||
|
|
||||||
|
Port crash isolation: if the Haskell process crashes, the BEAM
|
||||||
|
VM continues running. The GenServer will restart the port.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use GenServer
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
@default_binary "shoresh-engine"
|
||||||
|
@call_timeout 5_000
|
||||||
|
|
||||||
|
# --- Client API ---
|
||||||
|
|
||||||
|
def start_link(opts \\ []) do
|
||||||
|
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Send a process request to the Shoresh Engine.
|
||||||
|
Construction spec §3.1 — operation "process".
|
||||||
|
"""
|
||||||
|
@spec process(String.t(), map()) :: {:ok, map()} | {:error, String.t()}
|
||||||
|
def process(text, context \\ %{}) do
|
||||||
|
request = build_request("process", text, context)
|
||||||
|
call_engine(request)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Send a separate request to the Shoresh Engine.
|
||||||
|
Construction spec §3.1 — operation "separate".
|
||||||
|
"""
|
||||||
|
@spec separate(String.t(), map()) :: {:ok, map()} | {:error, String.t()}
|
||||||
|
def separate(text, context \\ %{}) do
|
||||||
|
request = build_request("separate", text, context)
|
||||||
|
call_engine(request)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Send a name request to the Shoresh Engine.
|
||||||
|
Construction spec §3.1 — operation "name".
|
||||||
|
"""
|
||||||
|
@spec name(String.t(), map()) :: {:ok, map()} | {:error, String.t()}
|
||||||
|
def name(text, context \\ %{}) do
|
||||||
|
request = build_request("name", text, context)
|
||||||
|
call_engine(request)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Send an evaluate request to the Shoresh Engine.
|
||||||
|
Construction spec §3.1 — operation "evaluate".
|
||||||
|
"""
|
||||||
|
@spec evaluate(String.t(), map()) :: {:ok, map()} | {:error, String.t()}
|
||||||
|
def evaluate(text, context \\ %{}) do
|
||||||
|
request = build_request("evaluate", text, context)
|
||||||
|
call_engine(request)
|
||||||
|
end
|
||||||
|
|
||||||
|
# --- GenServer callbacks ---
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def init(opts) do
|
||||||
|
binary = Keyword.get(opts, :binary, @default_binary)
|
||||||
|
cwd = Keyword.get(opts, :cwd, nil)
|
||||||
|
|
||||||
|
port = open_port(binary, cwd)
|
||||||
|
|
||||||
|
{:ok, %{port: port, binary: binary, cwd: cwd, pending: nil}}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call({:request, json_line}, from, state) do
|
||||||
|
Port.command(state.port, json_line <> "\n")
|
||||||
|
{:noreply, %{state | pending: from}}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info({port, {:data, {:eol, data}}}, %{port: port, pending: from} = state)
|
||||||
|
when from != nil do
|
||||||
|
response = data |> IO.iodata_to_binary() |> Jason.decode()
|
||||||
|
|
||||||
|
reply =
|
||||||
|
case response do
|
||||||
|
{:ok, %{"status" => "ok", "result" => result}} ->
|
||||||
|
{:ok, result}
|
||||||
|
|
||||||
|
{:ok, %{"status" => "error", "error" => error}} ->
|
||||||
|
{:error, "Engine error: #{error["code"]} — #{error["message"]}"}
|
||||||
|
|
||||||
|
{:error, decode_error} ->
|
||||||
|
{:error, "JSON decode error: #{inspect(decode_error)}"}
|
||||||
|
end
|
||||||
|
|
||||||
|
GenServer.reply(from, reply)
|
||||||
|
{:noreply, %{state | pending: nil}}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({port, {:exit_status, status}}, %{port: port} = state) do
|
||||||
|
Logger.error("ShoreshEnginePort: process exited with status #{status}")
|
||||||
|
|
||||||
|
if state.pending do
|
||||||
|
GenServer.reply(state.pending, {:error, "Engine process exited (status #{status})"})
|
||||||
|
end
|
||||||
|
|
||||||
|
# Restart the port
|
||||||
|
new_port = open_port(state.binary, state.cwd)
|
||||||
|
{:noreply, %{state | port: new_port, pending: nil}}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({port, {:data, {:noeol, _partial}}}, %{port: port} = state) do
|
||||||
|
# Partial line — wait for more data
|
||||||
|
{:noreply, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info(msg, state) do
|
||||||
|
Logger.warning("ShoreshEnginePort: unexpected message: #{inspect(msg)}")
|
||||||
|
{:noreply, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def terminate(_reason, state) do
|
||||||
|
Port.close(state.port)
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
# --- Private ---
|
||||||
|
|
||||||
|
defp open_port(binary, cwd) do
|
||||||
|
port_opts = [
|
||||||
|
:binary,
|
||||||
|
:exit_status,
|
||||||
|
{:line, 1_048_576},
|
||||||
|
{:args, ["server"]}
|
||||||
|
]
|
||||||
|
|
||||||
|
port_opts =
|
||||||
|
if cwd do
|
||||||
|
[{:cd, cwd} | port_opts]
|
||||||
|
else
|
||||||
|
port_opts
|
||||||
|
end
|
||||||
|
|
||||||
|
Port.open({:spawn_executable, binary}, port_opts)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp build_request(operation, text, context) do
|
||||||
|
request = %{
|
||||||
|
"operation" => operation,
|
||||||
|
"input" => %{
|
||||||
|
"text" => text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add scope from context if present
|
||||||
|
request =
|
||||||
|
if Map.has_key?(context, :verse) do
|
||||||
|
verse = context.verse
|
||||||
|
put_in(request, ["input", "scope"], %{
|
||||||
|
"book" => to_string(verse.book),
|
||||||
|
"chapter" => verse.chapter,
|
||||||
|
"verse" => verse.verse
|
||||||
|
})
|
||||||
|
else
|
||||||
|
request
|
||||||
|
end
|
||||||
|
|
||||||
|
Jason.encode!(request)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp call_engine(json_line) do
|
||||||
|
GenServer.call(__MODULE__, {:request, json_line}, @call_timeout)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -4,73 +4,102 @@ defmodule VavPipeline.TalmudicEngineClient do
|
|||||||
|
|
||||||
Construction spec §3.3:
|
Construction spec §3.3:
|
||||||
Protocol: JSON over Unix domain socket
|
Protocol: JSON over Unix domain socket
|
||||||
Socket: /var/run/shorashim/talmudic.sock
|
|
||||||
|
|
||||||
Phase 2: Stub that returns placeholder responses.
|
Delegates to TalmudicEngineSocket (the live Prolog process).
|
||||||
Phase 3 (Step 14): Full socket connection to talmudic_engine.
|
Falls back to stub responses if socket is unavailable.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
# Phase 3: Unix domain socket path
|
|
||||||
# @socket_path Application.compile_env(
|
|
||||||
# :vav_pipeline,
|
|
||||||
# :talmudic_socket,
|
|
||||||
# "/var/run/shorashim/talmudic.sock"
|
|
||||||
# )
|
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Send an interpretation request to the Talmudic Engine.
|
Send an interpretation request to the Talmudic Engine.
|
||||||
|
|
||||||
Phase 2: Returns a stub response.
|
|
||||||
Phase 3: Sends JSON over Unix socket.
|
|
||||||
"""
|
"""
|
||||||
@spec interpret(String.t(), map()) :: {:ok, map()} | {:error, String.t()}
|
@spec interpret(String.t(), map()) :: {:ok, map()} | {:error, String.t()}
|
||||||
def interpret(case_ref, _context) do
|
def interpret(case_ref, context) do
|
||||||
Logger.info("TalmudicEngineClient: interpret stub for #{case_ref}")
|
case call_socket(:interpret, case_ref, context) do
|
||||||
|
{:ok, result} ->
|
||||||
|
{:ok, result}
|
||||||
|
|
||||||
{:ok,
|
{:error, reason} ->
|
||||||
%{
|
Logger.warning("TalmudicEngineClient: socket unavailable (#{reason}), using stub")
|
||||||
status: :teiku,
|
{:ok, stub_interpretation(case_ref)}
|
||||||
interpretation: %{
|
end
|
||||||
text: "Phase 2 stub — Talmudic Engine not yet connected",
|
|
||||||
prolog_form: "stub(#{case_ref})",
|
|
||||||
grounding: [],
|
|
||||||
confidence: :low
|
|
||||||
},
|
|
||||||
challenge: nil
|
|
||||||
}}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Send a challenge request.
|
Send a challenge request.
|
||||||
"""
|
"""
|
||||||
@spec challenge(String.t(), map()) :: {:ok, map()} | {:error, String.t()}
|
@spec challenge(String.t(), map()) :: {:ok, map()} | {:error, String.t()}
|
||||||
def challenge(case_ref, _context) do
|
def challenge(case_ref, context) do
|
||||||
Logger.info("TalmudicEngineClient: challenge stub for #{case_ref}")
|
case call_socket(:challenge, case_ref, context) do
|
||||||
|
{:ok, result} ->
|
||||||
|
{:ok, result}
|
||||||
|
|
||||||
{:ok,
|
{:error, reason} ->
|
||||||
%{
|
Logger.warning("TalmudicEngineClient: socket unavailable (#{reason}), using stub")
|
||||||
status: :teiku,
|
{:ok, stub_challenge(case_ref)}
|
||||||
challenge: %{
|
end
|
||||||
kushya: "Phase 2 stub — no challenge generated",
|
|
||||||
contradicting: nil,
|
|
||||||
strength: :weak
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Send a resolution request.
|
Send a resolution request.
|
||||||
"""
|
"""
|
||||||
@spec resolve(String.t(), map()) :: {:ok, map()} | {:error, String.t()}
|
@spec resolve(String.t(), map()) :: {:ok, map()} | {:error, String.t()}
|
||||||
def resolve(case_ref, _context) do
|
def resolve(case_ref, context) do
|
||||||
Logger.info("TalmudicEngineClient: resolve stub for #{case_ref}")
|
case call_socket(:resolve, case_ref, context) do
|
||||||
|
{:ok, result} ->
|
||||||
|
{:ok, result}
|
||||||
|
|
||||||
{:ok,
|
{:error, reason} ->
|
||||||
%{
|
Logger.warning("TalmudicEngineClient: socket unavailable (#{reason}), using stub")
|
||||||
status: :teiku,
|
{:ok, stub_resolution(case_ref)}
|
||||||
resolution: "Phase 2 stub — resolution deferred"
|
end
|
||||||
}}
|
end
|
||||||
|
|
||||||
|
# --- Private ---
|
||||||
|
|
||||||
|
defp call_socket(operation, case_ref, context) do
|
||||||
|
if Process.whereis(VavPipeline.TalmudicEngineSocket) do
|
||||||
|
apply(VavPipeline.TalmudicEngineSocket, operation, [case_ref, context])
|
||||||
|
else
|
||||||
|
{:error, "socket not started"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp stub_interpretation(case_ref) do
|
||||||
|
%{
|
||||||
|
"status" => "teiku",
|
||||||
|
"interpretation" => %{
|
||||||
|
"text" => "Talmudic Engine not connected — teiku",
|
||||||
|
"prolog_form" => "stub(#{case_ref})",
|
||||||
|
"grounding" => [],
|
||||||
|
"confidence" => "low"
|
||||||
|
},
|
||||||
|
"challenge" => nil
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp stub_challenge(_case_ref) do
|
||||||
|
%{
|
||||||
|
"status" => "teiku",
|
||||||
|
"challenge" => %{
|
||||||
|
"kushya" => "No challenge generated — engine not connected",
|
||||||
|
"contradicting" => "",
|
||||||
|
"strength" => "weak"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp stub_resolution(case_ref) do
|
||||||
|
%{
|
||||||
|
"status" => "teiku",
|
||||||
|
"interpretation" => %{
|
||||||
|
"text" => "Resolution deferred — engine not connected",
|
||||||
|
"prolog_form" => "stub(#{case_ref})",
|
||||||
|
"grounding" => [],
|
||||||
|
"confidence" => "low"
|
||||||
|
},
|
||||||
|
"challenge" => nil
|
||||||
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
defmodule VavPipeline.TalmudicEngineSocket do
|
||||||
|
@moduledoc """
|
||||||
|
Unix domain socket client to the SWI-Prolog Talmudic Engine.
|
||||||
|
|
||||||
|
Step 14 (Architecture §4): "Connect components: routes to
|
||||||
|
Prolog reasoning."
|
||||||
|
|
||||||
|
Construction spec §3.3: JSON over Unix domain socket.
|
||||||
|
Socket: /tmp/shorashim/talmudic.sock (dev)
|
||||||
|
/var/run/shorashim/talmudic.sock (production)
|
||||||
|
|
||||||
|
Connection model: connect, send one JSON line, read one JSON
|
||||||
|
line response, keep connection for subsequent requests.
|
||||||
|
Reconnects on connection loss.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use GenServer
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
@default_socket_path "/tmp/shorashim/talmudic.sock"
|
||||||
|
@call_timeout 10_000
|
||||||
|
@connect_timeout 5_000
|
||||||
|
|
||||||
|
# --- Client API ---
|
||||||
|
|
||||||
|
def start_link(opts \\ []) do
|
||||||
|
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Send an interpret request to the Talmudic Engine.
|
||||||
|
Construction spec §3.3 — action "interpret".
|
||||||
|
"""
|
||||||
|
@spec interpret(String.t(), map()) :: {:ok, map()} | {:error, String.t()}
|
||||||
|
def interpret(case_ref, context) do
|
||||||
|
request = build_request("interpret", case_ref, context)
|
||||||
|
call_engine(request)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Send a challenge request to the Talmudic Engine.
|
||||||
|
Construction spec §3.3 — action "challenge".
|
||||||
|
"""
|
||||||
|
@spec challenge(String.t(), map()) :: {:ok, map()} | {:error, String.t()}
|
||||||
|
def challenge(case_ref, context) do
|
||||||
|
request = build_request("challenge", case_ref, context)
|
||||||
|
call_engine(request)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Send a resolve request to the Talmudic Engine.
|
||||||
|
Construction spec §3.3 — action "resolve".
|
||||||
|
"""
|
||||||
|
@spec resolve(String.t(), map()) :: {:ok, map()} | {:error, String.t()}
|
||||||
|
def resolve(case_ref, context) do
|
||||||
|
request = build_request("resolve", case_ref, context)
|
||||||
|
call_engine(request)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Send a halt_check request to the Talmudic Engine.
|
||||||
|
"""
|
||||||
|
@spec halt_check(map()) :: {:ok, map()} | {:error, String.t()}
|
||||||
|
def halt_check(action) do
|
||||||
|
request = Jason.encode!(%{
|
||||||
|
"action" => "halt_check",
|
||||||
|
"check" => action
|
||||||
|
})
|
||||||
|
call_engine(request)
|
||||||
|
end
|
||||||
|
|
||||||
|
# --- GenServer callbacks ---
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def init(opts) do
|
||||||
|
socket_path = Keyword.get(opts, :socket_path, @default_socket_path)
|
||||||
|
{:ok, %{socket_path: socket_path, socket: nil}}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call({:request, json_line}, _from, state) do
|
||||||
|
case ensure_connected(state) do
|
||||||
|
{:ok, state} ->
|
||||||
|
case send_and_receive(state.socket, json_line) do
|
||||||
|
{:ok, response} ->
|
||||||
|
{:reply, {:ok, response}, state}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.warning("TalmudicEngineSocket: send/receive failed: #{inspect(reason)}")
|
||||||
|
# Connection broken — close and retry once
|
||||||
|
:gen_tcp.close(state.socket)
|
||||||
|
state = %{state | socket: nil}
|
||||||
|
|
||||||
|
case ensure_connected(state) do
|
||||||
|
{:ok, state} ->
|
||||||
|
case send_and_receive(state.socket, json_line) do
|
||||||
|
{:ok, response} -> {:reply, {:ok, response}, state}
|
||||||
|
{:error, reason2} -> {:reply, {:error, "Retry failed: #{inspect(reason2)}"}, %{state | socket: nil}}
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, reason2} ->
|
||||||
|
{:reply, {:error, "Reconnect failed: #{inspect(reason2)}"}, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:reply, {:error, "Cannot connect: #{inspect(reason)}"}, state}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def terminate(_reason, state) do
|
||||||
|
if state.socket do
|
||||||
|
:gen_tcp.close(state.socket)
|
||||||
|
end
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
# --- Private ---
|
||||||
|
|
||||||
|
defp ensure_connected(%{socket: nil, socket_path: path} = state) do
|
||||||
|
case :gen_tcp.connect({:local, path}, 0, [:binary, active: false, packet: :line], @connect_timeout) do
|
||||||
|
{:ok, socket} ->
|
||||||
|
{:ok, %{state | socket: socket}}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp ensure_connected(state), do: {:ok, state}
|
||||||
|
|
||||||
|
defp send_and_receive(socket, json_line) do
|
||||||
|
case :gen_tcp.send(socket, json_line <> "\n") do
|
||||||
|
:ok ->
|
||||||
|
case :gen_tcp.recv(socket, 0, @call_timeout) do
|
||||||
|
{:ok, data} ->
|
||||||
|
line = String.trim_trailing(data, "\n")
|
||||||
|
case Jason.decode(line) do
|
||||||
|
{:ok, parsed} -> {:ok, parsed}
|
||||||
|
{:error, err} -> {:error, "JSON decode: #{inspect(err)}"}
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp build_request(action, case_ref, context) do
|
||||||
|
roots = Map.get(context, :context_roots, [])
|
||||||
|
question = Map.get(context, :question, "")
|
||||||
|
|
||||||
|
Jason.encode!(%{
|
||||||
|
"action" => action,
|
||||||
|
"case" => %{
|
||||||
|
"ref" => case_ref,
|
||||||
|
"question" => question,
|
||||||
|
"context_roots" => roots
|
||||||
|
}
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
defp call_engine(json_line) do
|
||||||
|
GenServer.call(__MODULE__, {:request, json_line}, @call_timeout)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -21,7 +21,8 @@ defmodule VavPipeline.MixProject do
|
|||||||
# Construction spec §1.5 — exact version pins
|
# Construction spec §1.5 — exact version pins
|
||||||
defp deps do
|
defp deps do
|
||||||
[
|
[
|
||||||
{:jason, "~> 1.4.1"}
|
{:jason, "~> 1.4.1"},
|
||||||
|
{:sanhedrin, path: "../sanhedrin", manager: :rebar3}
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
%{
|
%{
|
||||||
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
|
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
|
||||||
|
"jsx": {:hex, :jsx, "3.1.0", "d12516baa0bb23a59bb35dccaf02a1bd08243fcbb9efe24f2d9d056ccff71268", [:rebar3], [], "hexpm", "0c5cc8fdc11b53cc25cf65ac6705ad39e54ecc56d1c22e4adb8f5a53fb9427f3"},
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user