feat(agent): connect agents to real BOC data
- Add AgentDataProvider with real DB queries for finance, CRM, sales, HR - Inject live data into Anthropic prompts for context-aware responses - Reuse existing ledgerDB connection for agent data access - Agents now respond with actual numbers from the system
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -36,10 +37,11 @@ type AgentChatResponse struct {
|
||||
type AgentOrchestrator struct {
|
||||
anthropicKey string
|
||||
apiEndpoint string
|
||||
dataProvider *AgentDataProvider
|
||||
}
|
||||
|
||||
// NewAgentOrchestrator creates a new agent orchestrator
|
||||
func NewAgentOrchestrator() *AgentOrchestrator {
|
||||
func NewAgentOrchestrator(db, ledgerDB *sql.DB) *AgentOrchestrator {
|
||||
key := os.Getenv("ANTHROPIC_API_KEY")
|
||||
if key == "" {
|
||||
log.Warn().Msg("ANTHROPIC_API_KEY not set, agent will use mock responses")
|
||||
@@ -48,6 +50,7 @@ func NewAgentOrchestrator() *AgentOrchestrator {
|
||||
return &AgentOrchestrator{
|
||||
anthropicKey: key,
|
||||
apiEndpoint: "https://api.anthropic.com/v1/messages",
|
||||
dataProvider: NewAgentDataProvider(db, ledgerDB),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,8 +84,20 @@ func (o *AgentOrchestrator) HandleAgentChat(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
// Hämta real-time data för agenten
|
||||
dataContext := ""
|
||||
if o.dataProvider != nil {
|
||||
dataContext = o.dataProvider.FormatDataForPrompt(req.Rum)
|
||||
}
|
||||
|
||||
// Bygg enhanced prompt med data
|
||||
enhancedPrompt := req.SystemPrompt
|
||||
if dataContext != "" {
|
||||
enhancedPrompt = req.SystemPrompt + dataContext
|
||||
}
|
||||
|
||||
// Call Anthropic API
|
||||
svar, err := o.callAnthropic(req.SystemPrompt, req.Meddelanden)
|
||||
svar, err := o.callAnthropic(enhancedPrompt, req.Meddelanden)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("rum", req.Rum).Msg("Agent chat failed")
|
||||
// Fallback to mock
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// AgentDataProvider hämtar riktig data från BOC för agenterna
|
||||
type AgentDataProvider struct {
|
||||
db *sql.DB
|
||||
ledgerDB *sql.DB
|
||||
amosURL string
|
||||
}
|
||||
|
||||
// NewAgentDataProvider skapar en ny data provider
|
||||
func NewAgentDataProvider(db, ledgerDB *sql.DB) *AgentDataProvider {
|
||||
return &AgentDataProvider{
|
||||
db: db,
|
||||
ledgerDB: ledgerDB,
|
||||
amosURL: os.Getenv("AMOS_BASE_URL"),
|
||||
}
|
||||
}
|
||||
|
||||
// FinanceData innehåller finansiell data för Finance Agent
|
||||
type FinanceData struct {
|
||||
Balance *BalanceSnapshot `json:"balance,omitempty"`
|
||||
Income *IncomeSnapshot `json:"income,omitempty"`
|
||||
Cashflow *CashflowSummary `json:"cashflow,omitempty"`
|
||||
Invoices []InvoiceSummary `json:"invoices,omitempty"`
|
||||
Period string `json:"period"`
|
||||
GeneratedAt string `json:"generated_at"`
|
||||
}
|
||||
|
||||
type BalanceSnapshot struct {
|
||||
TotalAssets float64 `json:"total_assets"`
|
||||
TotalLiabilities float64 `json:"total_liabilities"`
|
||||
TotalEquity float64 `json:"total_equity"`
|
||||
LiquidityRatio float64 `json:"liquidity_ratio"`
|
||||
}
|
||||
|
||||
type IncomeSnapshot struct {
|
||||
Revenue float64 `json:"revenue"`
|
||||
Expenses float64 `json:"expenses"`
|
||||
NetIncome float64 `json:"net_income"`
|
||||
Margin float64 `json:"margin"`
|
||||
}
|
||||
|
||||
type CashflowSummary struct {
|
||||
Inflow float64 `json:"inflow"`
|
||||
Outflow float64 `json:"outflow"`
|
||||
Net float64 `json:"net"`
|
||||
}
|
||||
|
||||
type InvoiceSummary struct {
|
||||
ID string `json:"id"`
|
||||
Customer string `json:"customer"`
|
||||
Amount float64 `json:"amount"`
|
||||
Status string `json:"status"`
|
||||
DueDate string `json:"due_date"`
|
||||
DaysOverdue int `json:"days_overdue,omitempty"`
|
||||
}
|
||||
|
||||
// CRMData innehåller CRM-data för CRM Agent
|
||||
type CRMData struct {
|
||||
CustomerCount int `json:"customer_count"`
|
||||
LeadCount int `json:"lead_count"`
|
||||
PipelineValue float64 `json:"pipeline_value"`
|
||||
RecentCustomers []CustomerBrief `json:"recent_customers,omitempty"`
|
||||
RecentLeads []LeadBrief `json:"recent_leads,omitempty"`
|
||||
GeneratedAt string `json:"generated_at"`
|
||||
}
|
||||
|
||||
type CustomerBrief struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Company string `json:"company"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type LeadBrief struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// SalesData innehåller säljdata för Sales Agent
|
||||
type SalesData struct {
|
||||
TotalDeals int `json:"total_deals"`
|
||||
MRR float64 `json:"mrr"`
|
||||
ARR float64 `json:"arr"`
|
||||
PipelineStages []StageBrief `json:"pipeline_stages,omitempty"`
|
||||
RecentDeals []DealBrief `json:"recent_deals,omitempty"`
|
||||
GeneratedAt string `json:"generated_at"`
|
||||
}
|
||||
|
||||
type StageBrief struct {
|
||||
Name string `json:"name"`
|
||||
Deals int `json:"deals"`
|
||||
Value float64 `json:"value"`
|
||||
}
|
||||
|
||||
type DealBrief struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Value float64 `json:"value"`
|
||||
Stage string `json:"stage"`
|
||||
}
|
||||
|
||||
// HRData innehåller HR-data för HR Agent
|
||||
type HRData struct {
|
||||
EmployeeCount int `json:"employee_count"`
|
||||
ActiveLeaves int `json:"active_leaves"`
|
||||
Departments []DeptSummary `json:"departments,omitempty"`
|
||||
RecentHires []EmployeeBrief `json:"recent_hires,omitempty"`
|
||||
GeneratedAt string `json:"generated_at"`
|
||||
}
|
||||
|
||||
type DeptSummary struct {
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type EmployeeBrief struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Department string `json:"department"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// GetFinanceData hämtar finansiell data
|
||||
func (p *AgentDataProvider) GetFinanceData() (*FinanceData, error) {
|
||||
data := &FinanceData{
|
||||
Period: time.Now().Format("2006-01"),
|
||||
GeneratedAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
// Hämta balansräkning
|
||||
if p.ledgerDB != nil {
|
||||
var assets, liabilities, equity float64
|
||||
err := p.ledgerDB.QueryRow(`
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN account_code < 2000 THEN balance ELSE 0 END), 0) as assets,
|
||||
COALESCE(SUM(CASE WHEN account_code >= 2000 AND account_code < 3000 THEN balance ELSE 0 END), 0) as liabilities,
|
||||
COALESCE(SUM(CASE WHEN account_code >= 3000 THEN balance ELSE 0 END), 0) as equity
|
||||
FROM account_balances
|
||||
WHERE period = $1
|
||||
`, data.Period).Scan(&assets, &liabilities, &equity)
|
||||
if err == nil {
|
||||
data.Balance = &BalanceSnapshot{
|
||||
TotalAssets: assets,
|
||||
TotalLiabilities: liabilities,
|
||||
TotalEquity: equity,
|
||||
LiquidityRatio: assets / liabilities * 100,
|
||||
}
|
||||
}
|
||||
|
||||
// Hämta resultaträkning
|
||||
var revenue, expenses float64
|
||||
err = p.ledgerDB.QueryRow(`
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN account_code >= 3000 AND account_code < 4000 THEN credit - debit ELSE 0 END), 0) as revenue,
|
||||
COALESCE(SUM(CASE WHEN account_code >= 4000 AND account_code < 8000 THEN debit - credit ELSE 0 END), 0) as expenses
|
||||
FROM journal_entries
|
||||
WHERE DATE_TRUNC('month', entry_date) = DATE_TRUNC('month', CURRENT_DATE)
|
||||
`).Scan(&revenue, &expenses)
|
||||
if err == nil {
|
||||
netIncome := revenue - expenses
|
||||
margin := 0.0
|
||||
if revenue > 0 {
|
||||
margin = (netIncome / revenue) * 100
|
||||
}
|
||||
data.Income = &IncomeSnapshot{
|
||||
Revenue: revenue,
|
||||
Expenses: expenses,
|
||||
NetIncome: netIncome,
|
||||
Margin: margin,
|
||||
}
|
||||
}
|
||||
|
||||
// Hämta fakturor
|
||||
rows, err := p.ledgerDB.Query(`
|
||||
SELECT id, customer_name, amount, status, due_date,
|
||||
CASE WHEN due_date < CURRENT_DATE AND status != 'paid'
|
||||
THEN EXTRACT(DAY FROM CURRENT_DATE - due_date)::int
|
||||
ELSE 0 END as days_overdue
|
||||
FROM invoices
|
||||
WHERE created_at > CURRENT_DATE - INTERVAL '90 days'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 10
|
||||
`)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var inv InvoiceSummary
|
||||
rows.Scan(&inv.ID, &inv.Customer, &inv.Amount, &inv.Status, &inv.DueDate, &inv.DaysOverdue)
|
||||
data.Invoices = append(data.Invoices, inv)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// GetCRMData hämtar CRM-data
|
||||
func (p *AgentDataProvider) GetCRMData() (*CRMData, error) {
|
||||
data := &CRMData{
|
||||
GeneratedAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
if p.db != nil {
|
||||
// Kundantal
|
||||
p.db.QueryRow("SELECT COUNT(*) FROM customers WHERE status = 'active'").Scan(&data.CustomerCount)
|
||||
|
||||
// Leadantal
|
||||
p.db.QueryRow("SELECT COUNT(*) FROM leads WHERE status != 'converted'").Scan(&data.LeadCount)
|
||||
|
||||
// Pipeline-värde
|
||||
p.db.QueryRow("SELECT COALESCE(SUM(value), 0) FROM pipeline_deals WHERE status = 'open'").Scan(&data.PipelineValue)
|
||||
|
||||
// Senaste kunder
|
||||
rows, err := p.db.Query(`
|
||||
SELECT id, name, company, status
|
||||
FROM customers
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 5
|
||||
`)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var c CustomerBrief
|
||||
rows.Scan(&c.ID, &c.Name, &c.Company, &c.Status)
|
||||
data.RecentCustomers = append(data.RecentCustomers, c)
|
||||
}
|
||||
}
|
||||
|
||||
// Senaste leads
|
||||
rows, err = p.db.Query(`
|
||||
SELECT id, name, source, status
|
||||
FROM leads
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 5
|
||||
`)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var l LeadBrief
|
||||
rows.Scan(&l.ID, &l.Name, &l.Source, &l.Status)
|
||||
data.RecentLeads = append(data.RecentLeads, l)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// GetSalesData hämtar säljdata
|
||||
func (p *AgentDataProvider) GetSalesData() (*SalesData, error) {
|
||||
data := &SalesData{
|
||||
GeneratedAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
if p.db != nil {
|
||||
// Totalt antal deals
|
||||
p.db.QueryRow("SELECT COUNT(*) FROM sales_deals").Scan(&data.TotalDeals)
|
||||
|
||||
// MRR
|
||||
p.db.QueryRow("SELECT COALESCE(SUM(monthly_value), 0) FROM sales_deals WHERE status = 'active'").Scan(&data.MRR)
|
||||
|
||||
// ARR
|
||||
data.ARR = data.MRR * 12
|
||||
|
||||
// Pipeline-stages
|
||||
rows, err := p.db.Query(`
|
||||
SELECT stage_name, COUNT(*), COALESCE(SUM(value), 0)
|
||||
FROM sales_deals
|
||||
WHERE status = 'open'
|
||||
GROUP BY stage_name
|
||||
ORDER BY stage_name
|
||||
`)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var s StageBrief
|
||||
rows.Scan(&s.Name, &s.Deals, &s.Value)
|
||||
data.PipelineStages = append(data.PipelineStages, s)
|
||||
}
|
||||
}
|
||||
|
||||
// Senaste deals
|
||||
rows, err = p.db.Query(`
|
||||
SELECT id, name, value, stage
|
||||
FROM sales_deals
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 5
|
||||
`)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var d DealBrief
|
||||
rows.Scan(&d.ID, &d.Name, &d.Value, &d.Stage)
|
||||
data.RecentDeals = append(data.RecentDeals, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// GetHRData hämtar HR-data
|
||||
func (p *AgentDataProvider) GetHRData() (*HRData, error) {
|
||||
data := &HRData{
|
||||
GeneratedAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
if p.db != nil {
|
||||
// Antal anställda
|
||||
p.db.QueryRow("SELECT COUNT(*) FROM employees WHERE status = 'active'").Scan(&data.EmployeeCount)
|
||||
|
||||
// Aktiva ledigheter
|
||||
p.db.QueryRow(`
|
||||
SELECT COUNT(*) FROM leaves
|
||||
WHERE status = 'approved'
|
||||
AND start_date <= CURRENT_DATE
|
||||
AND end_date >= CURRENT_DATE
|
||||
`).Scan(&data.ActiveLeaves)
|
||||
|
||||
// Avdelningar
|
||||
rows, err := p.db.Query(`
|
||||
SELECT department, COUNT(*)
|
||||
FROM employees
|
||||
WHERE status = 'active'
|
||||
GROUP BY department
|
||||
`)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var d DeptSummary
|
||||
rows.Scan(&d.Name, &d.Count)
|
||||
data.Departments = append(data.Departments, d)
|
||||
}
|
||||
}
|
||||
|
||||
// Senaste anställningar
|
||||
rows, err = p.db.Query(`
|
||||
SELECT id, first_name || ' ' || last_name as name, department, status
|
||||
FROM employees
|
||||
ORDER BY start_date DESC
|
||||
LIMIT 5
|
||||
`)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var e EmployeeBrief
|
||||
rows.Scan(&e.ID, &e.Name, &e.Department, &e.Status)
|
||||
data.RecentHires = append(data.RecentHires, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// GetDataForAgent hämtar data baserat på agent-typ
|
||||
func (p *AgentDataProvider) GetDataForAgent(rum string) (map[string]interface{}, error) {
|
||||
switch rum {
|
||||
case "finance":
|
||||
data, err := p.GetFinanceData()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("rum", rum).Msg("Failed to get finance data")
|
||||
return nil, err
|
||||
}
|
||||
return structToMap(data), nil
|
||||
case "crm":
|
||||
data, err := p.GetCRMData()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("rum", rum).Msg("Failed to get CRM data")
|
||||
return nil, err
|
||||
}
|
||||
return structToMap(data), nil
|
||||
case "sales":
|
||||
data, err := p.GetSalesData()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("rum", rum).Msg("Failed to get sales data")
|
||||
return nil, err
|
||||
}
|
||||
return structToMap(data), nil
|
||||
case "hr":
|
||||
data, err := p.GetHRData()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("rum", rum).Msg("Failed to get HR data")
|
||||
return nil, err
|
||||
}
|
||||
return structToMap(data), nil
|
||||
default:
|
||||
return map[string]interface{}{
|
||||
"message": "No real-time data available for this agent type",
|
||||
"rum": rum,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// structToMap konverterar en struct till map via JSON
|
||||
func structToMap(v interface{}) map[string]interface{} {
|
||||
var result map[string]interface{}
|
||||
b, _ := json.Marshal(v)
|
||||
json.Unmarshal(b, &result)
|
||||
return result
|
||||
}
|
||||
|
||||
// FormatDataForPrompt formaterar data för Anthropic prompt
|
||||
func (p *AgentDataProvider) FormatDataForPrompt(rum string) string {
|
||||
data, err := p.GetDataForAgent(rum)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
jsonData, _ := json.MarshalIndent(data, "", " ")
|
||||
return fmt.Sprintf("\n\nAKTUELL DATA FRÅN SYSTEMET:\n%s\n\nAnvänd denna data för att ge exakta, datadrivna svar. Om data saknas för en specifik fråga, be användaren specificera.", string(jsonData))
|
||||
}
|
||||
+3
-3
@@ -118,9 +118,9 @@ func main() {
|
||||
autoEngine := automation.NewEngine(database, logger)
|
||||
autoH := handlers.NewAutomationHandler(database, autoEngine)
|
||||
|
||||
// Agent Orchestrator
|
||||
agentOrchestrator := handlers.NewAgentOrchestrator()
|
||||
logger.Info().Msg("Agent Orchestrator initialized")
|
||||
// Agent Orchestrator — med datakoppling till ledger (återanvänd befintlig ledgerDB)
|
||||
agentOrchestrator := handlers.NewAgentOrchestrator(database, ledgerDB)
|
||||
logger.Info().Msg("Agent Orchestrator initialized with real-time data")
|
||||
|
||||
// Auth: JWTService med förbättrad validering
|
||||
jwtService := auth.NewJWTService(cfg.JWTSecret, "boc-auth", "boc")
|
||||
|
||||
Reference in New Issue
Block a user