Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): llm integration for cli #236

Draft
wants to merge 39 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
fba04b6
setting foundation for entry for llm integration
andrewrust-virtru Jul 23, 2024
7f69cae
quick pass on structure of llm intake code in go with dynamic models
andrewrust-virtru Jul 23, 2024
c31f55c
chat TUI starts and exits, but model is not accessible
andrewrust-virtru Jul 24, 2024
9d2487c
removing old code comments
andrewrust-virtru Jul 24, 2024
00f3453
llama3 model works using 'chat' cmd
andrewrust-virtru Jul 24, 2024
1c58ba9
cleaned up code function declarations
andrewrust-virtru Jul 24, 2024
8c55341
added basic token and time tracker per req
andrewrust-virtru Jul 25, 2024
dd7628b
cleaning up unused go modules
andrewrust-virtru Jul 25, 2024
d7defda
added sanitization wrapper to input prompt
andrewrust-virtru Jul 25, 2024
2ab33dd
slightly more nuanced and complete intro prompt
andrewrust-virtru Jul 25, 2024
2d0e94c
renamed to llm_sanitizer.go and prompt
andrewrust-virtru Jul 25, 2024
67ca351
added loading animation for better ui during idle periods
andrewrust-virtru Jul 30, 2024
42df4fa
moved model configs to chat_config.json file temporarily
andrewrust-virtru Jul 30, 2024
e739fac
temporarily removed chat_config.json out of .gitignore
andrewrust-virtru Jul 30, 2024
22e4598
moved chat files into dedicated directory, fixing imports
andrewrust-virtru Aug 1, 2024
fbab4aa
chat commands now moved to /pkg/chat
andrewrust-virtru Aug 1, 2024
a622c19
removed bad chatCmd import and managed chat via cmd/chat.go
andrewrust-virtru Aug 1, 2024
f3a3479
adding log/ directory to gitignore
andrewrust-virtru Aug 1, 2024
4e21627
changed configurations from JSON to YAML
andrewrust-virtru Aug 1, 2024
a918ed5
added more exit criteria and graceful endings
andrewrust-virtru Aug 1, 2024
b851348
minor formatting changes to yaml and example file linking
andrewrust-virtru Aug 1, 2024
f801a58
graceful and verbose entry to now working
andrewrust-virtru Aug 1, 2024
c2a19ec
added token limit to configurations
andrewrust-virtru Aug 4, 2024
2887003
added verbosity to control output
andrewrust-virtru Aug 4, 2024
d66c5f6
added time before first token statistics
andrewrust-virtru Aug 4, 2024
e443324
moved statistics code into performance.go file
andrewrust-virtru Aug 4, 2024
607d751
added --ask invocation capability
andrewrust-virtru Aug 4, 2024
166a517
cleaning up and adding comments
andrewrust-virtru Aug 4, 2024
a5682e0
updated prompts.go to ref_questions
andrewrust-virtru Aug 4, 2024
6935fac
updated Q&As
andrewrust-virtru Aug 6, 2024
a24d1f6
added GPU toggle, perhaps already using GPU? not sure
andrewrust-virtru Aug 6, 2024
6a3b1d0
keyword extractor running syncronously with full LLM call
andrewrust-virtru Aug 6, 2024
a1810bb
adjusted verbosity and changed all functions to PascalCase
andrewrust-virtru Aug 6, 2024
dcd0631
ui animation fix and default config loading fix
andrewrust-virtru Aug 6, 2024
1636542
fixing bug with deault config file
andrewrust-virtru Aug 6, 2024
1b7bc91
re-simpified config file getting
andrewrust-virtru Aug 6, 2024
719ce77
minor UI improvment
andrewrust-virtru Aug 6, 2024
9c375b8
updated useGPU to num_GPU per ollama specs
andrewrust-virtru Aug 6, 2024
4285e34
switched config file to dev example file to pass unit tests
andrewrust-virtru Aug 6, 2024
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ bin/.DS_Store
target/
.vscode/launch.json
otdfctl.yaml
pkg/chat/log/*
pkg/chat/log/temp/*

# Ignore the tructl binary
otdfctl
Expand Down
20 changes: 20 additions & 0 deletions cmd/chat.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package cmd

import (
"fmt"
"os"

"github.com/opentdf/otdfctl/pkg/chat"
)

func init() {
// Load in configs from YAML file - change to `otdfctl.yaml` for production, `otdfctl-example.yaml` for development
err := chat.LoadConfig("otdfctl-example.yaml")
// err := chat.LoadConfig("otdfctl.yaml")
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading chat config: %v\n", err)
os.Exit(1)
}
chat.ConfigureChatCommand()
RootCmd.AddCommand(chat.GetChatCommand())
}
8 changes: 8 additions & 0 deletions otdfctl-example.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
output:
# acceptable formats: json or styled
format: styled
# Find full list of attributes for ollama in API documentation: https://github.com/ollama/ollama/blob/main/docs/api.md
chat:
model: "llama3.1:8b-instruct-q5_K_S"
apiUrl: "http://localhost:11434/api/generate"
logLength: 20
verbose: true
tokenLimit: 1000
numGPU: 1
94 changes: 94 additions & 0 deletions pkg/chat/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package chat

import (
"fmt"
"os"

"github.com/spf13/cobra"
"gopkg.in/yaml.v2"
)

// Configs are loaded from a otdfctl.yaml file in home directory where defaults are provided
type ChatConfig struct {
Model string `yaml:"model" default:"llama3"`
ApiURL string `yaml:"apiUrl" default:"http://localhost:11434/api/generate"`
LogLength int `yaml:"logLength" default:"100"`
Verbose bool `yaml:"verbose" default:"true"`
TokenLimit int `yaml:"tokenLimit" default:"1000"`
NumGPU int `yaml:"numGPU" default:"1"`
}

type Output struct {
Format string `yaml:"format"`
}

type Config struct {
Output Output `yaml:"output"`
Chat ChatConfig `yaml:"chat"`
}

var chatConfig Config
var ask string

func LoadConfig(filename string) error {
file, err := os.Open(filename)
if err != nil {
return fmt.Errorf("could not open config file: %v", err)
}
defer file.Close()

decoder := yaml.NewDecoder(file)
err = decoder.Decode(&chatConfig)
if err != nil {
return fmt.Errorf("could not decode config YAML: %v", err)
}

return nil
}

func ConfigureChatCommand() {
chatCmd.PersistentFlags().StringVar(&chatConfig.Chat.Model, "model", chatConfig.Chat.Model, "Model name for Ollama")
chatCmd.PersistentFlags().StringVar(&ask, "ask", "", "Ask a one-off question without entering the chat session") // --ask invocation: ./otdfctl chat --ask "[$question_here]"
}

func RunChatSession(cmd *cobra.Command, args []string) {
// Call the Setup function before starting the chat session
// Load in configs from YAML file - change to `otdfctl.yaml` for production, `otdfctl-example.yaml` for development
configFile := "otdfctl-example.yaml"
// configFile = "otdfctl.yaml"
if _, err := os.Stat(configFile); os.IsNotExist(err) {
configFile = "otdfctl-example.yaml"
}

err := Setup(configFile)

if err != nil {
fmt.Fprintf(os.Stderr, "Setup failed: %v\n", err)
return
}
logger, err := NewLogger()
if err != nil {
fmt.Fprintf(os.Stderr, "Error initializing logger: %v\n", err)
return
}
defer logger.Close()

if ask != "" {
HandleUserInput(ask, logger)
return
}

fmt.Println("Starting chat session. Type 'exit' or 'quit' to end.")
UserInputLoop(logger)
}

var chatCmd = &cobra.Command{
Use: "chat",
Short: "Start a chat session with a LLM helper aid",
Long: `This command starts an interactive chat session with a local LLM to help with setup, debugging, or generic troubleshooting`,
Run: RunChatSession,
}

func GetChatCommand() *cobra.Command {
return chatCmd
}
211 changes: 211 additions & 0 deletions pkg/chat/intake.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
package chat

import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
)

func UserInputLoop(logger *Logger) {
scanner := bufio.NewScanner(os.Stdin)
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)

for {
fmt.Print("> ")
select {
case <-sigChan:
fmt.Println("\nReceived interrupt signal. Ending chat session.")
return
default:
if !scanner.Scan() {
return
}

line := scanner.Text()
if strings.TrimSpace(line) == "exit" || strings.TrimSpace(line) == "quit" {
fmt.Println("Ending chat session.")
return
}

HandleUserInput(line, logger)
}
}
}

func HandleUserInput(input string, logger *Logger) {
sanitizedInput := SanitizeInput(input)

// if verbosity is enabled, print the sanitized input, otherwise just log it
if chatConfig.Chat.Verbose {
fmt.Printf("\n%s\n\n", sanitizedInput)
}
logger.Log(fmt.Sprintf("User: %s", input))
logger.Log(fmt.Sprintf("Sanitized: %s", sanitizedInput))

// Channels to receive results
keywordChan := make(chan []string)
apiResponseChan := make(chan *http.Response)
errorChan := make(chan error)
// Start keyword extraction in a goroutine
go func() {
keywords, err := ExtractKeywordsFromLLM(sanitizedInput)
if err != nil {
errorChan <- err
return
}
keywordChan <- keywords
}()

// Start main API call in a goroutine
go func() {
requestBody, err := CreateRequestBody(sanitizedInput)
if err != nil {
errorChan <- err
return
}

resp, err := SendRequest(requestBody)
if err != nil {
errorChan <- err
return
}
apiResponseChan <- resp
}()

done := make(chan bool)
go LoadingAnimation(done)

startTime := time.Now() // Start timing before sending the request

// Wait for both results
var keywords []string
var resp *http.Response
for i := 0; i < 2; i++ {
select {
case kw := <-keywordChan:
keywords = kw
// done <- true // Stop the loading animation
done <- true // Stop the loading animation
time.Sleep(100 * time.Millisecond) // Small delay to ensure the line is cleared
fmt.Println()
fmt.Printf("\rKeywords: [%s]\n", strings.Join(keywords, ", "))
fmt.Println()
// done <- true // Stop the loading animation
logger.Log(fmt.Sprintf("Keywords: [%s]", strings.Join(keywords, ", ")))
case r := <-apiResponseChan:
resp = r
case err := <-errorChan:
ReportError("during chat", err)
done <- true
return
}
}

defer resp.Body.Close()
ProcessResponse(resp, logger, startTime)
}

func CreateRequestBody(userInput string) ([]byte, error) {
return json.Marshal(map[string]interface{}{
"model": chatConfig.Chat.Model,
"prompt": userInput,
"stream": true,
"tokenLimit": chatConfig.Chat.TokenLimit,
"options": map[string]interface{}{
"num_gpu": chatConfig.Chat.NumGPU,
},
})
}

func SendRequest(body []byte) (*http.Response, error) {
return http.Post(chatConfig.Chat.ApiURL, "application/json", bytes.NewBuffer(body))
}

func ProcessResponse(resp *http.Response, logger *Logger, startTime time.Time) {
responseScanner := bufio.NewScanner(resp.Body)
var responseBuffer bytes.Buffer
var tokenBuffer []string
tokenCount := 0
firstTokenReceived := false

for responseScanner.Scan() {
if !firstTokenReceived {
timeBeforeFirstToken = time.Since(startTime)
firstTokenReceived = true
}

result, err := DecodeResponse(responseScanner.Bytes())
if err != nil {
ReportError("decoding response", err)
continue
}

if response, ok := result["response"]; ok {
fmt.Print(response)
responseBuffer.WriteString(fmt.Sprintf("%s", response))
tokenBuffer = append(tokenBuffer, fmt.Sprintf("%s", response))
tokenCount++
}
if done, ok := result["done"].(bool); ok && done {
fmt.Println()
break
}
TrackStats(responseScanner.Bytes())

// Log every logLength tokens
if tokenCount >= chatConfig.Chat.LogLength {
LogWithTimestamp(logger, strings.Join(tokenBuffer, ""))
tokenBuffer = tokenBuffer[:0] // Reset the buffer
tokenCount = 0
}
}

// Log any remaining tokens
if tokenCount > 0 {
LogWithTimestamp(logger, strings.Join(tokenBuffer, ""))
}

PrintAndResetStats(startTime)
}

func LogWithTimestamp(logger *Logger, message string) {
// Remove newline characters from the message
cleanedMessage := strings.ReplaceAll(message, "\n", "")
timestamp := time.Now().Format(time.RFC3339)
logger.Log(fmt.Sprintf("%s: %s", timestamp, cleanedMessage))
}

func DecodeResponse(data []byte) (map[string]interface{}, error) {
var result map[string]interface{}
err := json.Unmarshal(data, &result)
return result, err
}

func ReportError(action string, err error) {
fmt.Fprintf(os.Stderr, "Error %s: %v\n", action, err)
}

func LoadingAnimation(done chan bool) {
chars := []rune{'|', '/', '-', '\\'}
for {
select {
case <-done:
fmt.Print("\r \r") // Clear the loading animation
time.Sleep(100 * time.Millisecond) // Small delay to ensure the line is cleared
return
default:
for _, char := range chars {
fmt.Printf("\r%c", char)
time.Sleep(100 * time.Millisecond)
}
}
}
}
33 changes: 33 additions & 0 deletions pkg/chat/logger.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package chat

import (
"fmt"
"os"
"time"
)

type Logger struct {
file *os.File
}

// New log file with a timestamp in its name and returns a Logger instance.
func NewLogger() (*Logger, error) {
timestamp := time.Now().Format("20060102_150405")
filename := fmt.Sprintf("pkg/chat/log/session_%s.txt", timestamp)
file, err := os.Create(filename)
if err != nil {
return nil, fmt.Errorf("could not create log file: %v", err)
}
return &Logger{file: file}, nil
}

// Log writes a message to the log file with a timestamp.
func (l *Logger) Log(message string) error {
timestamp := time.Now().Format(time.RFC3339)
_, err := l.file.WriteString(fmt.Sprintf("%s: %s\n", timestamp, message))
return err
}

func (l *Logger) Close() {
l.file.Close()
}
Loading
Loading