Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions pkg/app/ask.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ import (
)

func GetBibleAsk(env def.SessionData) def.SessionData {
return GetBibleAskWithContext(env, nil)
}

func GetBibleAskWithContext(env def.SessionData, contextVerses []string) def.SessionData {
if len(env.Msg.Message) > 0 {
config := utils.DeserializeUserConfig(env.User.Config)

Expand All @@ -22,6 +26,7 @@ func GetBibleAsk(env def.SessionData) def.SessionData {
User: UserContext{
Version: config.Version,
},
Verses: contextVerses,
},
}

Expand Down
206 changes: 143 additions & 63 deletions pkg/app/bible_reference.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,63 +110,15 @@ func init() {
// ParseBibleReference parses a string to identify and normalize a Bible reference.
// It returns the normalized reference string and a boolean indicating validity.
func ParseBibleReference(input string) (string, bool) {
trimmedInput := strings.TrimSpace(input)
if trimmedInput == "" {
ref, consumedLen, ok := ParseBibleReferenceFromStart(input)
if !ok {
return "", false
}
lowerInput := strings.ToLower(trimmedInput)

var foundBook string
var bookName string
var remainder string

// 1. Try exact match (Greedy)
for _, key := range sortedBookKeys {
if strings.HasPrefix(lowerInput, key) {
matchLen := len(key)
if len(lowerInput) > matchLen {
nextChar := lowerInput[matchLen]
if isLetter(nextChar) {
continue
}
}

foundBook = key
bookName = BibleBooks[key]
remainder = strings.TrimSpace(trimmedInput[matchLen:])
break
}
}

// 2. If no exact match, try fuzzy matching
if foundBook == "" {
fBook, matchLen := findFuzzyMatch(trimmedInput) // Pass original case for splitting, but logic handles case
if fBook != "" {
bookName = fBook
foundBook = fBook // Mark as found
remainder = strings.TrimSpace(trimmedInput[matchLen:])
}
}

if foundBook == "" {
// Verify that we consumed the entire string (ignoring whitespace)
if len(strings.TrimSpace(input[consumedLen:])) > 0 {
return "", false
}

// Check remainder
if remainder == "" {
if SingleChapterBooks[bookName] {
return bookName, true
}
// Multi-chapter book defaults to chapter 1
return bookName + " 1", true
}

// Remainder validation
if isValidReferenceSyntax(remainder) {
return bookName + " " + remainder, true
}

return "", false
return ref, true
}

func findFuzzyMatch(input string) (string, int) {
Expand Down Expand Up @@ -246,19 +198,147 @@ func isLetter(c byte) bool {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
}

func isValidReferenceSyntax(s string) bool {
hasDigit := false
for _, r := range s {
switch {
case r >= '0' && r <= '9':
hasDigit = true
case r == ' ' || r == ':' || r == '-' || r == '.' || r == ',' || r == '\t':
// ExtractBibleReferences extracts all valid Bible references from a text.
func ExtractBibleReferences(input string) []string {
var refs []string

startIdx := 0
length := len(input)

for startIdx < length {
// If we are at a whitespace, advance.
if input[startIdx] == ' ' || input[startIdx] == '\t' || input[startIdx] == '\n' || input[startIdx] == '\r' {
startIdx++
continue
}

ref, consumed, ok := ParseBibleReferenceFromStart(input[startIdx:])
if ok {
refs = append(refs, ref)
startIdx += consumed
} else {
// Advance to next word
nextSpace := strings.IndexAny(input[startIdx:], " \t\n\r")
if nextSpace == -1 {
break
}
startIdx += nextSpace
}
}
return refs
}

// ParseBibleReferenceFromStart attempts to parse a Bible reference at the beginning of the string.
// Returns the normalized reference, the length of text consumed from input, and whether a match was found.
func ParseBibleReferenceFromStart(input string) (string, int, bool) {
// 1. Skip leading whitespace
startOffset := 0
for startOffset < len(input) && (input[startOffset] == ' ' || input[startOffset] == '\t' || input[startOffset] == '\n' || input[startOffset] == '\r') {
startOffset++
}
if startOffset == len(input) {
return "", 0, false
}

currentInput := input[startOffset:]
lowerInput := strings.ToLower(currentInput)

var foundBook string
var bookName string
var matchLen int // Length in currentInput

// 1. Try exact match (Greedy)
for _, key := range sortedBookKeys {
if strings.HasPrefix(lowerInput, key) {
mLen := len(key)
// Ensure whole word match
if len(lowerInput) > mLen {
nextChar := lowerInput[mLen]
if isLetter(nextChar) {
continue
}
}

foundBook = key
bookName = BibleBooks[key]
matchLen = mLen
break
}
}

// 2. If no exact match, try fuzzy matching
if foundBook == "" {
fBook, mLen := findFuzzyMatch(currentInput)
if fBook != "" {
bookName = fBook
foundBook = fBook
matchLen = mLen
}
}

if foundBook == "" {
return "", 0, false
}

// We found a book. Now parse the numbers (remainder).
remainderStart := matchLen
// Skip spaces after book
for remainderStart < len(currentInput) && (currentInput[remainderStart] == ' ' || currentInput[remainderStart] == '\t') {
remainderStart++
}

remainder := currentInput[remainderStart:]

// Consume valid reference syntax
syntax, syntaxLen := consumeReferenceSyntax(remainder)

totalConsumed := startOffset + remainderStart + syntaxLen

if syntax == "" {
if SingleChapterBooks[bookName] {
return bookName, totalConsumed, true
}
// Multi-chapter book defaults to chapter 1
return bookName + " 1", totalConsumed, true
}

if hasDigit(syntax) {
return bookName + " " + syntax, totalConsumed, true
}

if SingleChapterBooks[bookName] {
return bookName, startOffset + matchLen, true // Don't consume the invalid syntax
}
return bookName + " 1", startOffset + matchLen, true
}

func consumeReferenceSyntax(s string) (string, int) {
lastDigit := -1

for i, r := range s {
if r >= '0' && r <= '9' {
lastDigit = i
} else if r == ':' || r == '-' || r == '.' || r == ',' || r == ' ' || r == '\t' {
continue
default:
return false // Invalid character
} else {
break
}
}

if lastDigit == -1 {
return "", 0
}

return s[:lastDigit+1], lastDigit+1
}

func hasDigit(s string) bool {
for _, r := range s {
if r >= '0' && r <= '9' {
return true
}
}
return hasDigit
return false
}

func levenshteinDistance(s1, s2 string) int {
Expand Down
28 changes: 28 additions & 0 deletions pkg/app/bible_reference_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,31 @@ func TestParseBibleReference(t *testing.T) {
}
}
}

func TestExtractBibleReferences(t *testing.T) {
tests := []struct {
input string
expected []string
}{
{"Read John 3:16", []string{"John 3:16"}},
{"Compare Gen 1:1 and Ex 20", []string{"Genesis 1:1", "Exodus 20"}},
{"What does it say in Mark 5?", []string{"Mark 5"}},
{"I like Genesis", []string{"Genesis 1"}}, // Defaults to 1? Yes, per current logic.
{"No references here", nil},
{"John said hello", []string{"John 1"}}, // False positive risk, but per logic.
{"Read 1 John 3 and 2 John", []string{"1 John 3", "2 John"}},
}

for _, tt := range tests {
result := ExtractBibleReferences(tt.input)
if len(result) != len(tt.expected) {
t.Errorf("ExtractBibleReferences(%q) length = %d, want %d", tt.input, len(result), len(tt.expected))
continue
}
for i, ref := range result {
if ref != tt.expected[i] {
t.Errorf("ExtractBibleReferences(%q)[%d] = %q, want %q", tt.input, i, ref, tt.expected[i])
}
}
}
}
5 changes: 4 additions & 1 deletion pkg/app/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,11 @@ func ProcessCommand(env def.SessionData, bot platform.Platform) def.SessionData
break
case CMD_CLOSE:
env = CloseAction(env)
default:
case CMD_PASSAGE:
env = GetBiblePassage(env)
break
default:
env = ProcessNaturalLanguage(env)
}

return env
Expand Down
1 change: 1 addition & 0 deletions pkg/app/const.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const CMD_TMS = "tms"
const CMD_CLOSE = "close"
const CMD_SEARCH = "search"
const CMD_ASK = "ask"
const CMD_PASSAGE = "passage"

const ADM_CMD_DUMP = "admin_dump"
const ADM_MIGRATE = "admin_migrate"
34 changes: 34 additions & 0 deletions pkg/app/natural_language.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package app

import (
"strings"

"github.com/julwrites/BotPlatform/pkg/def"
)

func ProcessNaturalLanguage(env def.SessionData) def.SessionData {
msg := env.Msg.Message

// 1. Check if it is a Bible Reference (Only a verse)
// ParseBibleReference checks for exact match of reference syntax
if _, ok := ParseBibleReference(msg); ok {
return GetBiblePassage(env)
}

// 2. Check if it contains references
// If it contains references, we assume it's a query about them, so we Ask.
refs := ExtractBibleReferences(msg)
if len(refs) > 0 {
return GetBibleAskWithContext(env, refs)
}

// 3. Check for "short phrase" (Search)
// Definition: < 5 words and no question mark?
words := strings.Fields(msg)
if len(words) < 5 && !strings.Contains(msg, "?") {
return GetBibleSearch(env)
}

// 4. Assume Query Prompt (Ask)
return GetBibleAskWithContext(env, nil)
}
Loading
Loading