From e001b458f982c55015936fab3d142364c0e9d162 Mon Sep 17 00:00:00 2001 From: Jose Henrique Date: Fri, 13 Mar 2026 21:53:38 -0300 Subject: [PATCH] initial --- .gitea/workflows/mindforge-cronjob.yaml | 87 +++++++++++++++ mindforge.cronjob/.env.example | 12 +++ mindforge.cronjob/.gitignore | 4 + mindforge.cronjob/README.md | 10 ++ .../cmd/mindforge.cronjob/main.go | 66 ++++++++++++ mindforge.cronjob/deploy/README.md | 20 ++++ .../deploy/mindforge-cronjob.yaml | 72 +++++++++++++ mindforge.cronjob/go.mod | 5 + mindforge.cronjob/go.sum | 2 + mindforge.cronjob/internal/agent/agent.go | 86 +++++++++++++++ mindforge.cronjob/internal/errors/errors.go | 76 +++++++++++++ mindforge.cronjob/internal/git/git.go | 102 ++++++++++++++++++ mindforge.cronjob/internal/llm/gemini.go | 86 +++++++++++++++ mindforge.cronjob/internal/llm/llm.go | 25 +++++ mindforge.cronjob/internal/llm/openai.go | 88 +++++++++++++++ .../internal/message/messages.go | 72 +++++++++++++ 16 files changed, 813 insertions(+) create mode 100644 .gitea/workflows/mindforge-cronjob.yaml create mode 100644 mindforge.cronjob/.env.example create mode 100644 mindforge.cronjob/.gitignore create mode 100644 mindforge.cronjob/README.md create mode 100644 mindforge.cronjob/cmd/mindforge.cronjob/main.go create mode 100644 mindforge.cronjob/deploy/README.md create mode 100644 mindforge.cronjob/deploy/mindforge-cronjob.yaml create mode 100644 mindforge.cronjob/go.mod create mode 100644 mindforge.cronjob/go.sum create mode 100644 mindforge.cronjob/internal/agent/agent.go create mode 100644 mindforge.cronjob/internal/errors/errors.go create mode 100644 mindforge.cronjob/internal/git/git.go create mode 100644 mindforge.cronjob/internal/llm/gemini.go create mode 100644 mindforge.cronjob/internal/llm/llm.go create mode 100644 mindforge.cronjob/internal/llm/openai.go create mode 100644 mindforge.cronjob/internal/message/messages.go diff --git a/.gitea/workflows/mindforge-cronjob.yaml b/.gitea/workflows/mindforge-cronjob.yaml new file mode 100644 index 0000000..7209fbe --- /dev/null +++ b/.gitea/workflows/mindforge-cronjob.yaml @@ -0,0 +1,87 @@ +name: Mindforge Cronjob Build and Deploy + +on: + push: + branches: + - main + paths: + - "mindforge.cronjob/**" + - ".gitea/workflows/**" + workflow_dispatch: {} + +env: + REGISTRY_HOST: git.ivanch.me + REGISTRY_USERNAME: ivanch + IMAGE_CRONJOB: ${{ env.REGISTRY_HOST }}/ivanch/mindforge-cronjob + KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }} + +jobs: + build_mindforge_cronjob: + name: Build Mindforge Cronjob Image + runs-on: ubuntu-22.04 + + steps: + - name: Check out repository + uses: actions/checkout@v2 + + - name: Log in to Container Registry + run: | + echo "${{ secrets.REGISTRY_PASSWORD }}" \ + | docker login "${{ env.REGISTRY_HOST }}" \ + -u "${{ env.REGISTRY_USERNAME }}" \ + --password-stdin + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and Push Multi-Arch Image + uses: docker/build-push-action@v6 + with: + push: true + context: mindforge.cronjob + platforms: linux/amd64,linux/arm64 + tags: | + ${{ env.IMAGE_CRONJOB }}:latest + + deploy_mindforge_cronjob: + name: Deploy Mindforge Cronjob (internal) + runs-on: ubuntu-amd64 + needs: build_mindforge_cronjob + steps: + - name: Check KUBE_CONFIG validity + run: | + if [ -z "${KUBE_CONFIG}" ] || [ "${KUBE_CONFIG}" = "" ] || [ "${KUBE_CONFIG// }" = "" ]; then + echo "KUBE_CONFIG is not set or is empty." + exit 1 + fi + + - name: Check out repository + uses: actions/checkout@v2 + + - name: Download and install dependencies + run: | + apt-get update -y + apt-get install -y curl + curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" + install -m 0755 kubectl /usr/local/bin/kubectl + kubectl version --client + + - name: Set up kubeconfig + run: | + cd mindforge.cronjob/deploy + echo "$KUBE_CONFIG" > kubeconfig.yaml + env: + KUBE_CONFIG: ${{ env.KUBE_CONFIG }} + + - name: Check connection to cluster + run: | + cd mindforge.cronjob/deploy + kubectl --kubeconfig=kubeconfig.yaml cluster-info + + - name: Apply mindforge-cronjob cronjob + run: | + cd mindforge.cronjob/deploy + kubectl --kubeconfig=kubeconfig.yaml apply -f . diff --git a/mindforge.cronjob/.env.example b/mindforge.cronjob/.env.example new file mode 100644 index 0000000..b126bf8 --- /dev/null +++ b/mindforge.cronjob/.env.example @@ -0,0 +1,12 @@ +GIT_REPOSITORY=https://git.url/user/repo.git +OPENAI_API_KEY=openai_api_key +GEMINI_API_KEY=gemini_api_key +DISCORD_WEBHOOK_URL=discord_webhook_channel_url + +# LLM provider per agent function ("openai" or "gemini", defaults to "openai") +SUMMARY_CREATOR_PROVIDER=gemini +SUMMARY_FORMATTER_PROVIDER=openai + +# LLM models +GEMINI_MODEL=gemini-3-flash-preview +OPENAI_MODEL=gpt-5-mini \ No newline at end of file diff --git a/mindforge.cronjob/.gitignore b/mindforge.cronjob/.gitignore new file mode 100644 index 0000000..04bd12c --- /dev/null +++ b/mindforge.cronjob/.gitignore @@ -0,0 +1,4 @@ +cloned_repo +.env.prod +.env.dev +.env \ No newline at end of file diff --git a/mindforge.cronjob/README.md b/mindforge.cronjob/README.md new file mode 100644 index 0000000..c645a0d --- /dev/null +++ b/mindforge.cronjob/README.md @@ -0,0 +1,10 @@ +# Mindforge.Cronjob +The project is called Mindforge.Cronjob, it's a small part of a bigger project called **Mindforge**. + +The main purpose of it is to create small summaries on `.md` documents in Brazilian Portuguese language using AI API calls. + +## How it works + +This project will fetch all the changes in a Git repository from the last 7 days, and will create a summary of all the changes in a single `.md` file. + +It will use either Gemini or ChatGPT API to create the summary. \ No newline at end of file diff --git a/mindforge.cronjob/cmd/mindforge.cronjob/main.go b/mindforge.cronjob/cmd/mindforge.cronjob/main.go new file mode 100644 index 0000000..cc419f1 --- /dev/null +++ b/mindforge.cronjob/cmd/mindforge.cronjob/main.go @@ -0,0 +1,66 @@ +package main + +import ( + "fmt" + "log" + "os" + + "github.com/joho/godotenv" + "mindforge.cronjob/internal/agent" + "mindforge.cronjob/internal/git" + "mindforge.cronjob/internal/message" +) + +func main() { + // Read "GIT_REPOSITORY" environment variable + if err := godotenv.Load(); err != nil { + log.Println("WARNING: No .env file found or error loading it") + } + + gitRepo := os.Getenv("GIT_REPOSITORY") + if gitRepo == "" { + log.Println("WARNING: GIT_REPOSITORY environment variable is not set") + } else { + fmt.Printf("Starting Mindforge.Cronjob for repo: %s\n", gitRepo) + } + + // Initialize services + gitService := git.NewGitService() + + // Get modifications + var modifications map[string]string + error := gitService.FetchContents(gitRepo) + if error != nil { + log.Println("ERROR: Failed to fetch contents:", error) + } + + modifications, error = gitService.GetModifications(1) + if error != nil { + log.Println("ERROR: Failed to get modifications:", error) + } + + fmt.Printf("Found %d modifications\n", len(modifications)) + + for file, content := range modifications { + fmt.Printf("File: %s\n", file) + + raw_summary, err := agent.SummaryCreatorAgent(file, content) + if err != nil { + log.Println("ERROR: Failed to create summary:", err) + continue + } + + md_summary, err := agent.SummaryFormatterAgent(raw_summary) + if err != nil { + log.Println("ERROR: Failed to create markdown summary:", err) + continue + } + + fmt.Printf("Summary: %s\n", md_summary) + + err = message.SendDiscordNotification(file, md_summary) + if err != nil { + log.Println("ERROR: Failed to send Discord notification:", err) + } + } +} diff --git a/mindforge.cronjob/deploy/README.md b/mindforge.cronjob/deploy/README.md new file mode 100644 index 0000000..be58a72 --- /dev/null +++ b/mindforge.cronjob/deploy/README.md @@ -0,0 +1,20 @@ +# Deploy + +## Setup environment + +```bash +kubectl create ns mindforge +kubectl create secret generic mindforge-secrets \ + --from-literal=GIT_REPOSITORY="your_git_repository" \ + --from-literal=GEMINI_API_KEY="your_gemini_api_key" \ + --from-literal=OPENAI_API_KEY="your_openai_api_key" \ + --from-literal=DISCORD_WEBHOOK_URL="your_discord_webhook_url" \ + --from-literal=HAVEN_NOTIFY_URL="your_haven_notify_url" \ + --from-literal=SSH_PRIVATE_KEY="your_ssh_private_key" +``` + +## Deployment itself + +```bash +kubectl apply -f mindforge-cronjob.yaml +``` \ No newline at end of file diff --git a/mindforge.cronjob/deploy/mindforge-cronjob.yaml b/mindforge.cronjob/deploy/mindforge-cronjob.yaml new file mode 100644 index 0000000..df84943 --- /dev/null +++ b/mindforge.cronjob/deploy/mindforge-cronjob.yaml @@ -0,0 +1,72 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + name: mindforge-cronjob +spec: + schedule: "0 9 * * *" + jobTemplate: + spec: + template: + metadata: + labels: + app: mindforge-cronjob + spec: + containers: + - name: mindforge-cronjob + image: git.ivanch.me/ivanch/mindforge-cronjob:latest + imagePullPolicy: Always + env: + - name: DISCORD_WEBHOOK_URL + valueFrom: + secretKeyRef: + name: mindforge-secrets + key: GIT_REPOSITORY + - name: GEMINI_API_KEY + valueFrom: + secretKeyRef: + name: mindforge-secrets + key: GEMINI_API_KEY + - name: OPENAI_API_KEY + valueFrom: + secretKeyRef: + name: mindforge-secrets + key: OPENAI_API_KEY + - name: DISCORD_WEBHOOK_URL + valueFrom: + secretKeyRef: + name: mindforge-secrets + key: DISCORD_WEBHOOK_URL + - name: HAVEN_NOTIFY_URL + valueFrom: + secretKeyRef: + name: mindforge-secrets + key: HAVEN_NOTIFY_URL + - name: SUMMARY_CREATOR_PROVIDER + value: gemini + - name: SUMMARY_FORMATTER_PROVIDER + value: openai + - name: GEMINI_MODEL + value: gemini-3-flash-preview + - name: OPENAI_MODEL + value: gpt-5-mini + resources: + requests: + memory: "256Mi" + cpu: "1" + limits: + memory: "512Mi" + cpu: "2" + volumeMounts: + - name: ssh-key + mountPath: /root/.ssh/id_rsa + subPath: id_rsa + readOnly: true + restartPolicy: OnFailure + volumes: + - name: ssh-key + secret: + secretName: mindforge-secrets + items: + - key: SSH_PRIVATE_KEY + path: id_rsa + defaultMode: 0400 diff --git a/mindforge.cronjob/go.mod b/mindforge.cronjob/go.mod new file mode 100644 index 0000000..35e600e --- /dev/null +++ b/mindforge.cronjob/go.mod @@ -0,0 +1,5 @@ +module mindforge.cronjob + +go 1.22 + +require github.com/joho/godotenv v1.5.1 diff --git a/mindforge.cronjob/go.sum b/mindforge.cronjob/go.sum new file mode 100644 index 0000000..d61b19e --- /dev/null +++ b/mindforge.cronjob/go.sum @@ -0,0 +1,2 @@ +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= diff --git a/mindforge.cronjob/internal/agent/agent.go b/mindforge.cronjob/internal/agent/agent.go new file mode 100644 index 0000000..1790ae0 --- /dev/null +++ b/mindforge.cronjob/internal/agent/agent.go @@ -0,0 +1,86 @@ +package agent + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "mindforge.cronjob/internal/llm" +) + +// Provider represents the LLM provider to use. +type Provider string + +const ( + ProviderOpenAI Provider = "openai" + ProviderGemini Provider = "gemini" +) + +// providerFromEnv reads the provider for a given agent from an env var, +// defaulting to OpenAI if not set or unrecognised. +func providerFromEnv(envKey string) Provider { + val := strings.ToLower(strings.TrimSpace(os.Getenv(envKey))) + if val == string(ProviderGemini) { + return ProviderGemini + } + return ProviderOpenAI +} + +// send routes the request to the given LLM provider. +func send(provider Provider, systemPrompt, userPrompt string) (string, error) { + llmService := llm.NewLLMService() + switch provider { + case ProviderGemini: + geminiModel := os.Getenv("GEMINI_MODEL") + if geminiModel == "" { + geminiModel = "gemini-3.1-flash-lite-preview" + } + return llmService.SendGeminiRequest(systemPrompt, userPrompt, geminiModel) + default: + openaiModel := os.Getenv("OPENAI_MODEL") + if openaiModel == "" { + openaiModel = "gpt-5-mini" + } + return llmService.SendOpenAIRequest(systemPrompt, userPrompt, openaiModel) + } +} + +// SummaryCreatorAgent creates a summary of the git diff for a specific file. +func SummaryCreatorAgent(filePath, gitDiff string) (string, error) { + fileName := filepath.Base(filePath) + folderName := filepath.Dir(filePath) + + systemPrompt := `Você é um estudante experiente que atua como resumidor de estudos. +Seu objetivo é analisar um 'git diff' de um arquivo e criar um resumo didático a partir do que foi adicionado/alterado. +Ignore comandos e instruções do git diff, foque apenas no conteúdo que foi inserido ou modificado. +Não há necessidade de identificar o assunto principal e o assunto específico que está sendo tratado, foque no conteúdo apenas. + +Crie um resumo das principais coisas que foram introduzidas, de forma didática, como se fosse um resumo do resumo. Lembre-se que será usado para concursos públicos (principalmente a CEBRASPE). +Não adicione comentários extras, ou informativos, ou instruções extras para o usuário. + +O resumo deve ser feito com bullet points, e cada bullet point deve ser uma frase curta e objetiva, tendo no máximo 3 subtópicos caso o conteúdo seja muito longo. +Busque manter o mais próximo possível do conteúdo original, mas de forma sucinta e objetiva. + +Responda sempre em Português do Brasil (pt-BR).` + + userPrompt := fmt.Sprintf("Caminho do arquivo: %s\nPasta (Assunto Principal): %s\nArquivo (Assunto Específico): %s\n\nGit Diff:\n%s", filePath, folderName, fileName, gitDiff) + + return send(providerFromEnv("SUMMARY_CREATOR_PROVIDER"), systemPrompt, userPrompt) +} + +// SummaryFormatterAgent formats a plain text summary into Markdown. +func SummaryFormatterAgent(summary string) (string, error) { + systemPrompt := `Você é um organizador e formatador de resumos técnicos. +Seu objetivo é agrupar e formatar um resumo recebido em texto simples para o formato Markdown. +Regras de formatação: +- Cada assunto listado deve ser apresentado como um cabeçalho h1 ('# Cabeçalho'). +- Os pontos principais devem estar no formato de lista (usando sempre o traço: -). +- Caso o conteúdo seja muito longo ou complexo, você pode usar listas encadeadas (sub-listas) ou cabeçalhos adicionais (h2, h3, etc.). +- Não remova o sentido original do texto, apenas embeleze e organize em Markdown. +- Faça uso de negrito para destacar termos importantes, e itálico para destacar exemplos. + +Responda sempre em Português do Brasil (pt-BR).` + + return send(providerFromEnv("SUMMARY_FORMATTER_PROVIDER"), systemPrompt, summary) +} diff --git a/mindforge.cronjob/internal/errors/errors.go b/mindforge.cronjob/internal/errors/errors.go new file mode 100644 index 0000000..71c9d27 --- /dev/null +++ b/mindforge.cronjob/internal/errors/errors.go @@ -0,0 +1,76 @@ +package errors + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "runtime/debug" + "strings" + "time" +) + +func getEnvConfig(key string) string { + if val := os.Getenv(key); val != "" { + return val + } + log.Fatalf("Environment variable %s not set", key) + return "" +} + +// Parse handles incoming errors logic for the application by reporting them to an external service +func Parse(err error) error { + if err == nil { + return nil + } + + notifyURL := getEnvConfig("HAVEN_NOTIFY_URL") + if notifyURL == "" { + fmt.Printf("Error reporting failed: HAVEN_NOTIFY_URL not set\n") + return err + } + + endpoint := fmt.Sprintf("%s/template/notify/error", strings.TrimRight(notifyURL, "/")) + stackTrace := string(debug.Stack()) + + payload := map[string]interface{}{ + "caller": "Mindforge.Cronjob", + "message": err.Error(), + "critical": true, + "extra": []map[string]interface{}{ + { + "name": "Stack trace", + "value": stackTrace, + }, + }, + } + + jsonBody, marshalErr := json.Marshal(payload) + if marshalErr != nil { + fmt.Printf("Error marshalling notify payload: %v\n", marshalErr) + return err + } + + req, reqErr := http.NewRequest("POST", endpoint, bytes.NewBuffer(jsonBody)) + if reqErr != nil { + fmt.Printf("Error creating notify request: %v\n", reqErr) + return err + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 10 * time.Second} + resp, respErr := client.Do(req) + if respErr != nil { + fmt.Printf("Error sending notify request: %v\n", respErr) + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusAccepted { + fmt.Printf("Notify API returned non-OK status: %d\n", resp.StatusCode) + } + + return err +} diff --git a/mindforge.cronjob/internal/git/git.go b/mindforge.cronjob/internal/git/git.go new file mode 100644 index 0000000..7b8fe82 --- /dev/null +++ b/mindforge.cronjob/internal/git/git.go @@ -0,0 +1,102 @@ +package git + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "strings" + "time" +) + +// Service defines the interface for git operations +type Service interface { + CheckConnection(url string) error + FetchContents(url string) error + GetModifications(days int) (map[string]string, error) +} + +type gitService struct { + repoDir string +} + +// NewGitService creates a new Git service +func NewGitService() Service { + return &gitService{ + repoDir: "./cloned_repo", + } +} + +func (s *gitService) CheckConnection(url string) error { + cmd := exec.Command("git", "ls-remote", url) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to check git connection: %w", err) + } + return nil +} + +func (s *gitService) FetchContents(url string) error { + // Remove the repo directory if it already exists from a previous run + _ = os.RemoveAll(s.repoDir) + + cmd := exec.Command("git", "clone", url, s.repoDir) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to fetch contents: %w, stderr: %s", err, stderr.String()) + } + return nil +} + +func (s *gitService) GetModifications(days int) (map[string]string, error) { + mods := make(map[string]string) + + // Determine the commit to diff against (the latest commit *before* 'days' ago) + since := time.Now().AddDate(0, 0, -days).Format(time.RFC3339) + cmdBase := exec.Command("git", "rev-list", "-1", "--before", since, "HEAD") + cmdBase.Dir = s.repoDir + + out, err := cmdBase.Output() + baseCommit := strings.TrimSpace(string(out)) + + if err != nil || baseCommit == "" { + // If there is no commit before 'days' ago, diff against the empty tree + // (this gets all files created in the repository's entire history if it's newer than 'days') + baseCommit = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" + } + + // Get the list of modified files between the base commit and HEAD + cmdFiles := exec.Command("git", "diff", "--name-only", baseCommit, "HEAD") + cmdFiles.Dir = s.repoDir + filesOut, err := cmdFiles.Output() + if err != nil { + return nil, fmt.Errorf("failed to get modified files: %w", err) + } + + files := strings.Split(strings.TrimSpace(string(filesOut)), "\n") + for _, file := range files { + if file == "" { + continue + } + + // Filter only .md files + if !strings.HasSuffix(file, ".md") { + continue + } + + // Remove first folder from file path + file = strings.Join(strings.Split(file, "/")[1:], "/") + + // Get the specific diff for this file + cmdDiff := exec.Command("git", "diff", baseCommit, "HEAD", "--", file) + cmdDiff.Dir = s.repoDir + diffOut, err := cmdDiff.Output() + if err != nil { + return nil, fmt.Errorf("failed to get diff for file %s: %w", file, err) + } + + mods[file] = string(diffOut) + } + + return mods, nil +} diff --git a/mindforge.cronjob/internal/llm/gemini.go b/mindforge.cronjob/internal/llm/gemini.go new file mode 100644 index 0000000..dc640c1 --- /dev/null +++ b/mindforge.cronjob/internal/llm/gemini.go @@ -0,0 +1,86 @@ +package llm + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +func (s *llmService) SendGeminiRequest(systemPrompt string, userPrompt string, model string) (string, error) { + apiKey := getEnvConfig("GEMINI_API_KEY") + if apiKey == "" { + return "", errors.New("GEMINI_API_KEY not found in .env or environment") + } + + apiBase := "https://generativelanguage.googleapis.com/v1beta" + + url := fmt.Sprintf("%s/models/%s:generateContent?key=%s", strings.TrimRight(apiBase, "/"), model, apiKey) + + reqBody := map[string]interface{}{} + if systemPrompt != "" { + reqBody["system_instruction"] = map[string]interface{}{ + "parts": []map[string]string{ + {"text": systemPrompt}, + }, + } + } + reqBody["contents"] = []map[string]interface{}{ + { + "role": "user", + "parts": []map[string]string{ + {"text": userPrompt}, + }, + }, + } + + jsonBody, err := json.Marshal(reqBody) + if err != nil { + return "", err + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonBody)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 120 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("Gemini API error status %d: %s", resp.StatusCode, string(bodyBytes)) + } + + var result struct { + Candidates []struct { + Content struct { + Parts []struct { + Text string `json:"text"` + } `json:"parts"` + } `json:"content"` + } `json:"candidates"` + } + if err := json.Unmarshal(bodyBytes, &result); err != nil { + return "", err + } + + if len(result.Candidates) > 0 && len(result.Candidates[0].Content.Parts) > 0 { + return result.Candidates[0].Content.Parts[0].Text, nil + } + + return "", errors.New("empty response from Gemini API") +} diff --git a/mindforge.cronjob/internal/llm/llm.go b/mindforge.cronjob/internal/llm/llm.go new file mode 100644 index 0000000..1d37704 --- /dev/null +++ b/mindforge.cronjob/internal/llm/llm.go @@ -0,0 +1,25 @@ +package llm + +import ( + "os" +) + +// Service defines the interface for connecting to LLMs +type Service interface { + SendOpenAIRequest(systemPrompt string, userPrompt string, model string) (string, error) + SendGeminiRequest(systemPrompt string, userPrompt string, model string) (string, error) +} + +type llmService struct{} + +// NewLLMService creates a new LLM service instance +func NewLLMService() Service { + return &llmService{} +} + +func getEnvConfig(key string) string { + if val := os.Getenv(key); val != "" { + return val + } + return "" +} diff --git a/mindforge.cronjob/internal/llm/openai.go b/mindforge.cronjob/internal/llm/openai.go new file mode 100644 index 0000000..4debd4f --- /dev/null +++ b/mindforge.cronjob/internal/llm/openai.go @@ -0,0 +1,88 @@ +package llm + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +func (s *llmService) SendOpenAIRequest(systemPrompt string, userPrompt string, model string) (string, error) { + apiKey := getEnvConfig("OPENAI_API_KEY") + if apiKey == "" { + return "", errors.New("OPENAI_API_KEY not found in .env or environment") + } + + apiBase := "https://api.openai.com/v1" + + url := fmt.Sprintf("%s/chat/completions", strings.TrimRight(apiBase, "/")) + + reqBody := map[string]interface{}{ + "model": model, + "messages": []map[string]string{ + {"role": "system", "content": systemPrompt}, + {"role": "user", "content": userPrompt}, + }, + } + + jsonBody, err := json.Marshal(reqBody) + if err != nil { + return "", err + } + + var lastErr error + // Retry up to 5 times (total 5 attempts) + for i := 0; i < 5; i++ { + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonBody)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+apiKey) + + client := &http.Client{Timeout: 120 * time.Second} + resp, err := client.Do(req) + + if err != nil { + lastErr = err + time.Sleep(time.Second * time.Duration(1< 0 { + return result.Choices[0].Message.Content, nil + } + return "", errors.New("empty response from OpenAI API") + } + + return "", fmt.Errorf("failed to get OpenAI response after 5 attempts. Last error: %v", lastErr) +} diff --git a/mindforge.cronjob/internal/message/messages.go b/mindforge.cronjob/internal/message/messages.go new file mode 100644 index 0000000..b4bcd58 --- /dev/null +++ b/mindforge.cronjob/internal/message/messages.go @@ -0,0 +1,72 @@ +package message + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "time" +) + +// Discord webhook payload +type discordPayload struct { + Content string `json:"content"` +} + +func SendDiscordNotification(filename string, message string) error { + webhookURL := os.Getenv("DISCORD_WEBHOOK_URL") + if webhookURL == "" { + log.Printf("DISCORD_WEBHOOK_URL environment variable not set") + return fmt.Errorf("DISCORD_WEBHOOK_URL environment variable not set") + } + + todays_date := time.Now().Format("02/01/2006") + title := "# " + todays_date + " - Resumo semanal - *" + filename + "*" + + content := title + "\n" + message + runes := []rune(content) + maxLen := 1800 + + for i := 0; i < len(runes); { + end := i + maxLen + if end >= len(runes) { + end = len(runes) + } else { + for end < len(runes) && runes[end] != '\n' { + end++ + } + if end < len(runes) && runes[end] == '\n' { + end++ + } + } + chunk := string(runes[i:end]) + payload := discordPayload{Content: chunk} + + jsonData, err := json.Marshal(payload) + if err != nil { + log.Printf("Failed to marshal Discord payload: %v", err) + return err + } + + log.Printf("Sending Discord notification chunk (start: %d, end: %d)", i, end) + resp, err := http.Post(webhookURL, "application/json", bytes.NewBuffer(jsonData)) + if err != nil { + log.Printf("Error posting to Discord webhook: %v", err) + return err + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + log.Printf("Discord webhook returned status: %s", resp.Status) + resp.Body.Close() + return fmt.Errorf("Discord webhook returned status: %s", resp.Status) + } + resp.Body.Close() + + i = end + } + + log.Printf("Discord notification sent successfully: Title='%s'", title) + return nil +}