Files
RIT-nexus/chatbot-service/main.go

424 lines
10 KiB
Go

package main
import (
"encoding/json"
"fmt"
"log"
"math"
"net/http"
"os"
"regexp"
"strings"
)
type FAQ struct {
Keywords []string `json:"keywords"`
Question string `json:"question"`
Questions []string `json:"questions"`
Answer string `json:"answer"`
}
type IndexedDoc struct {
FAQIndex int
Question string
}
type ChatRequest struct {
Message string `json:"message"`
}
type ChatResponse struct {
Answer string `json:"answer"`
MatchedQuestion string `json:"matched_question,omitempty"`
Confidence float64 `json:"confidence"`
}
var (
faqs []FAQ
indexedDocs []IndexedDoc
idf map[string]float64
docVectors []map[string]float64
docNorms []float64
vocabSet map[string]bool
)
var synonymMap = map[string]string{
"timings": "hours",
"timing": "hours",
"schedule": "hours",
"time": "hours",
"fees": "fee",
"payment": "fee",
"pay": "fee",
"canteen": "cafeteria",
"mess": "cafeteria",
"food": "cafeteria",
"bus": "transport",
"buses": "transport",
"route": "transport",
"routes": "transport",
"admission": "admissions",
"enroll": "admissions",
"join": "admissions",
"phone": "contact",
"number": "contact",
"call": "contact",
"email": "mail",
"emailaddress": "mail",
"course": "courses",
"departments": "courses",
"branches": "courses",
"programs": "courses",
"hostels": "hostel",
"rooms": "hostel",
"stay": "hostel",
"accommodation": "hostel",
}
// Stopwords set to filter out noise
var stopwords = map[string]bool{
"a": true, "about": true, "above": true, "after": true, "again": true, "against": true, "all": true, "am": true,
"an": true, "and": true, "any": true, "are": true, "as": true, "at": true, "be": true, "because": true,
"been": true, "before": true, "being": true, "below": true, "between": true, "both": true, "but": true, "by": true,
"can": true, "could": true, "did": true, "do": true, "does": true, "doing": true, "down": true, "during": true,
"each": true, "few": true, "for": true, "from": true, "further": true, "had": true, "has": true, "have": true,
"having": true, "he": true, "her": true, "here": true, "hers": true, "herself": true, "him": true, "himself": true,
"his": true, "how": true, "i": true, "if": true, "in": true, "into": true, "is": true, "it": true, "its": true,
"itself": true, "me": true, "more": true, "most": true, "my": true, "myself": true, "no": true, "nor": true,
"not": true, "of": true, "off": true, "on": true, "once": true, "only": true, "or": true, "other": true,
"our": true, "ours": true, "ourselves": true, "out": true, "over": true, "own": true, "same": true, "she": true,
"should": true, "so": true, "some": true, "such": true, "than": true, "that": true, "the": true, "their": true,
"theirs": true, "them": true, "themselves": true, "then": true, "there": true, "these": true, "they": true,
"this": true, "those": true, "through": true, "to": true, "too": true, "under": true, "until": true, "up": true,
"very": true, "was": true, "we": true, "were": true, "what": true, "when": true, "where": true, "which": true,
"while": true, "who": true, "whom": true, "why": true, "with": true, "you": true, "your": true, "yours": true,
"yourself": true, "yourselves": true,
}
var cleanRegex = regexp.MustCompile(`[^a-z0-9\s]`)
func tokenize(text string) []string {
text = strings.ToLower(text)
text = cleanRegex.ReplaceAllString(text, " ")
words := strings.Fields(text)
var tokens []string
for _, word := range words {
// Resolve synonyms
if syn, exists := synonymMap[word]; exists {
word = syn
}
if !stopwords[word] && len(word) > 1 {
tokens = append(tokens, word)
}
}
return tokens
}
func levenshtein(s, t string) int {
sLen := len(s)
tLen := len(t)
if sLen == 0 {
return tLen
}
if tLen == 0 {
return sLen
}
d := make([][]int, sLen+1)
for i := range d {
d[i] = make([]int, tLen+1)
d[i][0] = i
}
for j := range d[0] {
d[0][j] = j
}
for i := 1; i <= sLen; i++ {
for j := 1; j <= tLen; j++ {
cost := 1
if s[i-1] == t[j-1] {
cost = 0
}
d[i][j] = minInt(d[i-1][j]+1, minInt(d[i][j-1]+1, d[i-1][j-1]+cost))
}
}
return d[sLen][tLen]
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func correctToken(token string) string {
if vocabSet[token] {
return token
}
if len(token) < 4 {
return token
}
bestWord := token
bestDist := 999
for vocabWord := range vocabSet {
diff := len(vocabWord) - len(token)
if diff < 0 {
diff = -diff
}
if diff > 2 {
continue
}
dist := levenshtein(token, vocabWord)
if dist < bestDist {
bestDist = dist
bestWord = vocabWord
}
}
maxAllowedDist := 1
if len(token) >= 6 {
maxAllowedDist = 2
}
if bestDist <= maxAllowedDist {
return bestWord
}
return token
}
func tokenizeQuery(query string) []string {
tokens := tokenize(query)
var corrected []string
for _, token := range tokens {
corrected = append(corrected, correctToken(token))
}
return corrected
}
func initTFIDF() {
// Build indexedDocs
indexedDocs = nil
for i, faq := range faqs {
if faq.Question != "" {
indexedDocs = append(indexedDocs, IndexedDoc{FAQIndex: i, Question: faq.Question})
}
for _, q := range faq.Questions {
if q != "" {
indexedDocs = append(indexedDocs, IndexedDoc{FAQIndex: i, Question: q})
}
}
}
docFrequencies := make(map[string]int)
allTokens := make([][]string, len(indexedDocs))
for i, doc := range indexedDocs {
faq := faqs[doc.FAQIndex]
docText := doc.Question + " " + strings.Join(faq.Keywords, " ")
tokens := tokenize(docText)
allTokens[i] = tokens
uniqueTokens := make(map[string]bool)
for _, token := range tokens {
uniqueTokens[token] = true
}
for token := range uniqueTokens {
docFrequencies[token]++
}
}
// Populate vocabSet
vocabSet = make(map[string]bool)
for _, tokens := range allTokens {
for _, token := range tokens {
vocabSet[token] = true
}
}
// Calculate IDF for each term
idf = make(map[string]float64)
numDocs := float64(len(indexedDocs))
for token, df := range docFrequencies {
idf[token] = math.Log(1.0 + (numDocs / float64(df)))
}
// Compute TF-IDF vectors for indexed docs
docVectors = make([]map[string]float64, len(indexedDocs))
docNorms = make([]float64, len(indexedDocs))
for i, tokens := range allTokens {
tfMap := make(map[string]float64)
for _, token := range tokens {
tfMap[token]++
}
vector := make(map[string]float64)
var sqSum float64
for token, tf := range tfMap {
tfidfVal := tf * idf[token]
vector[token] = tfidfVal
sqSum += tfidfVal * tfidfVal
}
docVectors[i] = vector
docNorms[i] = math.Sqrt(sqSum)
}
log.Printf("TF-IDF Chatbot engine initialized successfully with %d Q&As (%d indexed question variants).", len(faqs), len(indexedDocs))
}
func getBestMatch(query string) (int, float64, string) {
queryTokens := tokenizeQuery(query)
if len(queryTokens) == 0 {
return -1, 0.0, ""
}
// Compute TF for query
queryTF := make(map[string]float64)
for _, token := range queryTokens {
queryTF[token]++
}
// Compute TF-IDF vector for query
queryVector := make(map[string]float64)
var querySqSum float64
for token, tf := range queryTF {
if idfVal, exists := idf[token]; exists {
tfidfVal := tf * idfVal
queryVector[token] = tfidfVal
querySqSum += tfidfVal * tfidfVal
}
}
queryNorm := math.Sqrt(querySqSum)
if queryNorm == 0 {
return -1, 0.0, ""
}
bestFAQIdx := -1
bestScore := -1.0
var bestMatchedQuestion string
for i, docVector := range docVectors {
doc := indexedDocs[i]
faq := faqs[doc.FAQIndex]
var dotProduct float64
for token, qVal := range queryVector {
if dVal, exists := docVector[token]; exists {
dotProduct += qVal * dVal
}
}
var cosineSim float64
if docNorms[i] > 0 {
cosineSim = dotProduct / (queryNorm * docNorms[i])
}
// Apply exact keyword boosting
keywordMatches := 0
for _, kw := range faq.Keywords {
for _, qTok := range queryTokens {
if strings.ToLower(kw) == qTok {
keywordMatches++
}
}
}
// A boost of 0.15 for each exact keyword match
boost := float64(keywordMatches) * 0.15
score := cosineSim + boost
if score > bestScore {
bestScore = score
bestFAQIdx = doc.FAQIndex
bestMatchedQuestion = doc.Question
}
}
return bestFAQIdx, bestScore, bestMatchedQuestion
}
func handleChat(w http.ResponseWriter, r *http.Request) {
// Add CORS headers
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req ChatRequest
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
bestIdx, score, matchedQuestion := getBestMatch(req.Message)
threshold := 0.18
var resp ChatResponse
if bestIdx != -1 && score >= threshold {
resp = ChatResponse{
Answer: faqs[bestIdx].Answer,
MatchedQuestion: matchedQuestion,
Confidence: score,
}
} else {
resp = ChatResponse{
Answer: "I'm sorry, I couldn't find an answer to your question about RIT Chennai. Please try rephrasing your question or contact our administrative office at +91 8925977445 or mail@ritchennai.edu.in.",
Confidence: score,
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
func handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"UP"}`))
}
func main() {
// Load FAQs
file, err := os.ReadFile("qna.json")
if err != nil {
log.Fatalf("Failed to read qna.json: %v", err)
}
err = json.Unmarshal(file, &faqs)
if err != nil {
log.Fatalf("Failed to parse qna.json: %v", err)
}
initTFIDF()
port := os.Getenv("PORT")
if port == "" {
port = "8081"
}
http.HandleFunc("/api/chat", handleChat)
http.HandleFunc("/api/health", handleHealth)
log.Printf("Chatbot service listening on port %s...", port)
if err := http.ListenAndServe(fmt.Sprintf(":%s", port), nil); err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}