alpha version

This commit is contained in:
2025-02-03 17:49:29 -05:00
commit dac9e95d12
144 changed files with 5694 additions and 0 deletions

153
workers/parser/claude.go Normal file
View File

@ -0,0 +1,153 @@
package parser
import (
"context"
"errors"
"io"
"log/slog"
"os"
"sync/atomic"
"github.com/alex-savin/go-receipt-tracker/bus"
"github.com/alex-savin/go-receipt-tracker/config"
"github.com/alex-savin/go-receipt-tracker/utils"
"github.com/liushuangls/go-anthropic/v2"
)
type Claude struct {
id string
model string
opts *config.Parser
client *anthropic.Client
subscriptions map[string]chan bus.Event //
bus *bus.Bus
log *slog.Logger
cancel context.CancelFunc //
end uint32 // ensure the close methods are only called once
}
func NewClaude(cfg *config.Parser, bus *bus.Bus) *Claude {
ai := &Claude{
id: cfg.Type, // "claude",
model: cfg.Model, // "gpt-4o-mini"
opts: cfg,
bus: bus,
}
return ai
}
func (ai *Claude) ID() string {
return ai.id
}
func (ai *Claude) Type() string {
return "parser"
}
func (ai *Claude) Init(log *slog.Logger) error {
ai.client = anthropic.NewClient(ai.opts.ApiKey)
ai.log = log
return nil
}
func (ai *Claude) Serve() {
if atomic.LoadUint32(&ai.end) == 1 {
return
}
ai.subscribe("parser:" + ai.ID())
ai.subscribe("parser:*")
ctx, cancel := context.WithCancel(context.Background())
ai.cancel = cancel
go ai.eventLoop(ctx)
}
func (ai *Claude) Stop() {
}
func (ai *Claude) subscribe(chn string) error {
s, err := ai.bus.Subscribe(chn, ai.Type()+":"+ai.ID())
if err != nil {
ai.log.Error("couldn't subscribe to a channel", "channel", chn, "error", err.Error())
return err
}
ai.subscriptions[chn] = s
return nil
}
// eventLoop loops forever
func (ai *Claude) eventLoop(ctx context.Context) {
ai.log.Debug(ai.ID() + " communication event loop started")
defer ai.log.Debug(ai.ID() + " communication event loop halted")
for {
for chn, ch := range ai.subscriptions {
select {
case event := <-ch:
ai.log.Debug("got a new message to a channel", "channel", chn)
for _, msg := range event.Payload.([]*bus.Message) {
ai.log.Debug("publishing mqtt message", "message", msg)
}
case <-ctx.Done():
ai.log.Info("stopping " + ai.ID() + " communication event loop")
return
}
}
}
}
func (ai *Claude) Recognize(img string) (res string, err error) {
image := "./" + img + ".jpg"
imageMediaType := "image/jpeg"
imageFile, err := os.Open(image)
if err != nil {
return "", err
}
imageData, err := io.ReadAll(imageFile)
if err != nil {
return "", err
}
resp, err := ai.client.CreateMessages(context.Background(), anthropic.MessagesRequest{
Model: anthropic.ModelClaude3Opus20240229,
Messages: []anthropic.Message{
{
Role: anthropic.RoleUser,
Content: []anthropic.MessageContent{
anthropic.NewImageMessageContent(anthropic.MessageContentSource{
Type: "base64",
MediaType: imageMediaType,
Data: imageData,
}),
anthropic.NewTextMessageContent(config.Request),
},
},
},
MaxTokens: 1000,
})
if err != nil {
var e *anthropic.APIError
if errors.As(err, &e) {
ai.log.Error("cannot recognize a receipt", "type", e.Type, "message", e.Message)
} else {
ai.log.Error("cannot recognize a receipt", "Messages error: %v\n", err)
}
return "", err
}
if !utils.IsJSON(*resp.Content[0].Text) {
ai.log.Error("Claude returned not valid JSON", "%+v", resp.Content[0].Text)
return "", err
}
ai.log.Debug("recognition output", "%+v", resp.Content[0].Text)
return *resp.Content[0].Text, nil
}

153
workers/parser/gemini.go Normal file
View File

@ -0,0 +1,153 @@
package parser
import (
"context"
"fmt"
"log/slog"
"sync/atomic"
"github.com/alex-savin/go-receipt-tracker/bus"
"github.com/alex-savin/go-receipt-tracker/config"
"github.com/google/generative-ai-go/genai"
"google.golang.org/api/option"
)
type Gemini struct {
id string
model string
opts *config.Parser
client *genai.Client
subscriptions map[string]chan bus.Event //
bus *bus.Bus
log *slog.Logger
cancel context.CancelFunc //
end uint32 // ensure the close methods are only called once
}
func NewGemini(cfg *config.Parser, bus *bus.Bus) *Gemini {
ai := &Gemini{
id: cfg.Type, // "gemini",
model: cfg.Model, // "gemini-1.5-flash"
opts: cfg,
bus: bus,
}
// model := client.GenerativeModel("gemini-1.5-flash")
return ai
}
func (ai *Gemini) ID() string {
return ai.id
}
func (ai *Gemini) Type() string {
return "parser"
}
func (ai *Gemini) Init(log *slog.Logger) error {
ai.log = log
ctx := context.Background()
client, err := genai.NewClient(ctx, option.WithAPIKey(ai.opts.ApiKey))
if err != nil {
log.Error("cannot", "error", err)
}
ai.client = client
return nil
}
func (ai *Gemini) Serve() {
if atomic.LoadUint32(&ai.end) == 1 {
return
}
ai.subscribe("parser:" + ai.ID())
ai.subscribe("parser:*")
ctx, cancel := context.WithCancel(context.Background())
ai.cancel = cancel
go ai.eventLoop(ctx)
}
func (ai *Gemini) Stop() {
ai.client.Close()
}
func (ai *Gemini) subscribe(chn string) error {
s, err := ai.bus.Subscribe(chn, ai.Type()+":"+ai.ID())
if err != nil {
ai.log.Error("couldn't subscribe to a channel", "channel", chn, "error", err.Error())
return err
}
ai.subscriptions[chn] = s
return nil
}
// eventLoop loops forever
func (ai *Gemini) eventLoop(ctx context.Context) {
ai.log.Debug(ai.ID() + " communication event loop started")
defer ai.log.Debug(ai.ID() + " communication event loop halted")
for {
for chn, ch := range ai.subscriptions {
select {
case event := <-ch:
ai.log.Debug("got a new message to a channel", "channel", chn)
for _, msg := range event.Payload.([]*bus.Message) {
ai.log.Debug("publishing mqtt message", "message", msg)
}
case <-ctx.Done():
ai.log.Info("stopping " + ai.ID() + " communication event loop")
return
}
}
}
}
func (ai *Gemini) Recognize(img string) (res string, err error) {
ctx := context.Background()
image := "./" + img + ".jpg"
file, err := ai.client.UploadFileFromPath(ctx, image, nil)
if err != nil {
ai.log.Error("cannot upload a receipt file", "Messages error: %v\n", err)
}
defer ai.client.DeleteFile(ctx, file.Name)
gotFile, err := ai.client.GetFile(ctx, file.Name)
if err != nil {
ai.log.Error("cannot get uploaded file name", err)
}
fmt.Println("Got file:", gotFile.Name)
model := ai.client.GenerativeModel("gemini-1.5-flash")
resp, err := model.GenerateContent(ctx,
genai.FileData{URI: file.URI},
genai.Text(config.Request))
if err != nil {
ai.log.Error("cannot recognize a receipt", "Messages error: %v\n", err)
}
printResponse(resp)
// if !utils.IsJSON(resp.Choices[0].Message.Content) {
// ai.log.Error("OpenAI returned not valid JSON", "%+v", resp.Choices[0].Message.Content)
// return "", err
// }
return "", nil
}
func printResponse(resp *genai.GenerateContentResponse) {
for _, cand := range resp.Candidates {
if cand.Content != nil {
for _, part := range cand.Content.Parts {
fmt.Println(part)
}
}
}
fmt.Println("---")
}

146
workers/parser/openai.go Normal file
View File

@ -0,0 +1,146 @@
package parser
import (
"context"
"log/slog"
"sync/atomic"
"github.com/alex-savin/go-receipt-tracker/bus"
"github.com/alex-savin/go-receipt-tracker/config"
"github.com/alex-savin/go-receipt-tracker/utils"
"github.com/sashabaranov/go-openai"
)
type OpenAi struct {
id string
model string
opts *config.Parser
client *openai.Client
subscriptions map[string]chan bus.Event //
bus *bus.Bus
log *slog.Logger
cancel context.CancelFunc //
end uint32 // ensure the close methods are only called once
}
func NewOpenAi(cfg *config.Parser, bus *bus.Bus) *OpenAi {
ai := &OpenAi{
id: cfg.Type, // "openai",
model: cfg.Model, // "gpt-4o-mini"
opts: cfg,
bus: bus,
}
return ai
}
func (ai *OpenAi) ID() string {
return ai.id
}
func (ai *OpenAi) Type() string {
return "parser"
}
func (ai *OpenAi) Init(log *slog.Logger) error {
ai.client = openai.NewClient(ai.opts.ApiKey)
ai.log = log
return nil
}
func (ai *OpenAi) Serve() {
if atomic.LoadUint32(&ai.end) == 1 {
return
}
ai.subscribe("parser:" + ai.ID())
ai.subscribe("parser:*")
ctx, cancel := context.WithCancel(context.Background())
ai.cancel = cancel
go ai.eventLoop(ctx)
}
func (ai *OpenAi) subscribe(chn string) error {
s, err := ai.bus.Subscribe(chn, ai.Type()+":"+ai.ID())
if err != nil {
ai.log.Error("couldn't subscribe to a channel", "channel", chn, "error", err.Error())
return err
}
ai.subscriptions[chn] = s
return nil
}
// eventLoop loops forever
func (ai *OpenAi) eventLoop(ctx context.Context) {
ai.log.Debug(ai.ID() + " communication event loop started")
defer ai.log.Debug(ai.ID() + " communication event loop halted")
for {
for chn, ch := range ai.subscriptions {
select {
case event := <-ch:
switch event.Payload.(type) {
case bus.Image:
ai.log.Debug("got a new message to a channel", "channel", chn)
res, err := ai.recognize(event.Payload.(bus.Image).Base64)
if err != nil {
ai.log.Error("got an error from parser ("+ai.ID()+")", "error", err)
}
err = ai.bus.Publish("telegram:publish", res)
if err != nil {
ai.log.Error("couldn't publish to a channel", "channel", "telegram:publish", "error", err.Error())
}
}
case <-ctx.Done():
ai.log.Info("stopping " + ai.ID() + " communication event loop")
return
}
}
}
}
func (ai *OpenAi) recognize(img string) (res string, err error) {
resp, err := ai.client.CreateChatCompletion(
context.Background(),
openai.ChatCompletionRequest{
Model: openai.GPT4oMini,
MaxTokens: 1000,
Messages: []openai.ChatCompletionMessage{
{
Role: openai.ChatMessageRoleUser,
MultiContent: []openai.ChatMessagePart{
{
Type: openai.ChatMessagePartTypeText,
Text: config.Request,
},
{
Type: openai.ChatMessagePartTypeImageURL,
ImageURL: &openai.ChatMessageImageURL{
URL: img,
Detail: openai.ImageURLDetailHigh,
},
},
},
},
},
},
)
if err != nil {
ai.log.Error("error during recognition process", "%+v", resp.Choices[0].Message.Content)
return "", err
}
if !utils.IsJSON(resp.Choices[0].Message.Content) {
ai.log.Error("OpenAI returned not valid JSON", "%+v", resp.Choices[0].Message.Content)
return "", err
}
ai.log.Debug("recognition output", "%+v", resp.Choices[0].Message.Content)
return resp.Choices[0].Message.Content, nil
}

6
workers/parser/parser.go Normal file
View File

@ -0,0 +1,6 @@
package parser
type Parser interface {
Recognize()
Transcribe()
}