Changeset 0.11.0 (#62)
This commit is contained in:
384
server/tools/calculator.go
Normal file
384
server/tools/calculator.go
Normal file
@@ -0,0 +1,384 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register(&CalculatorTool{})
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// calculator
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type CalculatorTool struct{}
|
||||
|
||||
func (t *CalculatorTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
Name: "calculator",
|
||||
Description: "Evaluate mathematical expressions with precision. Supports +, -, *, /, ^ (power), % (modulo), parentheses, and functions: sqrt, abs, ceil, floor, round, log, log2, log10, sin, cos, tan, pi, e. Use this for any arithmetic, unit conversions, percentages, or calculations instead of doing mental math.",
|
||||
Parameters: JSONSchema(map[string]interface{}{
|
||||
"expression": Prop("string", "Mathematical expression to evaluate, e.g. '(100 * 1.08) ^ 5' or 'sqrt(144) + log10(1000)'"),
|
||||
}, []string{"expression"}),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *CalculatorTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
||||
var args struct {
|
||||
Expression string `json:"expression"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
|
||||
return "", fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
|
||||
if args.Expression == "" {
|
||||
return "", fmt.Errorf("expression is required")
|
||||
}
|
||||
|
||||
// Normalize: replace ^ with ** for power, then rewrite for Go parser
|
||||
expr := normalizeExpr(args.Expression)
|
||||
|
||||
result, err := evalExpr(expr)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("evaluation error: %w", err)
|
||||
}
|
||||
|
||||
// Format result nicely
|
||||
var display string
|
||||
if result == math.Trunc(result) && !math.IsInf(result, 0) && !math.IsNaN(result) {
|
||||
display = strconv.FormatFloat(result, 'f', 0, 64)
|
||||
} else {
|
||||
display = strconv.FormatFloat(result, 'g', 15, 64)
|
||||
}
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"expression": args.Expression,
|
||||
"result": result,
|
||||
"display": display,
|
||||
}
|
||||
|
||||
b, _ := json.Marshal(resp)
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// ── Expression Normalization ────────────────
|
||||
|
||||
func normalizeExpr(s string) string {
|
||||
// Replace common aliases
|
||||
s = strings.ReplaceAll(s, "×", "*")
|
||||
s = strings.ReplaceAll(s, "÷", "/")
|
||||
s = strings.ReplaceAll(s, "**", "^")
|
||||
return s
|
||||
}
|
||||
|
||||
// ── Recursive Descent Evaluator ─────────────
|
||||
// Supports: +, -, *, /, ^, %, unary -, parentheses, function calls, constants
|
||||
|
||||
type calcParser struct {
|
||||
input string
|
||||
pos int
|
||||
}
|
||||
|
||||
func evalExpr(input string) (float64, error) {
|
||||
p := &calcParser{input: strings.TrimSpace(input)}
|
||||
result, err := p.parseExpr()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
p.skipSpaces()
|
||||
if p.pos < len(p.input) {
|
||||
return 0, fmt.Errorf("unexpected character at position %d: %q", p.pos, string(p.input[p.pos]))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *calcParser) parseExpr() (float64, error) {
|
||||
return p.parseAddSub()
|
||||
}
|
||||
|
||||
func (p *calcParser) parseAddSub() (float64, error) {
|
||||
left, err := p.parseMulDiv()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for {
|
||||
p.skipSpaces()
|
||||
if p.pos >= len(p.input) {
|
||||
break
|
||||
}
|
||||
op := p.input[p.pos]
|
||||
if op != '+' && op != '-' {
|
||||
break
|
||||
}
|
||||
p.pos++
|
||||
right, err := p.parseMulDiv()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if op == '+' {
|
||||
left += right
|
||||
} else {
|
||||
left -= right
|
||||
}
|
||||
}
|
||||
return left, nil
|
||||
}
|
||||
|
||||
func (p *calcParser) parseMulDiv() (float64, error) {
|
||||
left, err := p.parsePower()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for {
|
||||
p.skipSpaces()
|
||||
if p.pos >= len(p.input) {
|
||||
break
|
||||
}
|
||||
op := p.input[p.pos]
|
||||
if op != '*' && op != '/' && op != '%' {
|
||||
break
|
||||
}
|
||||
p.pos++
|
||||
right, err := p.parsePower()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
switch op {
|
||||
case '*':
|
||||
left *= right
|
||||
case '/':
|
||||
if right == 0 {
|
||||
return 0, fmt.Errorf("division by zero")
|
||||
}
|
||||
left /= right
|
||||
case '%':
|
||||
if right == 0 {
|
||||
return 0, fmt.Errorf("modulo by zero")
|
||||
}
|
||||
left = math.Mod(left, right)
|
||||
}
|
||||
}
|
||||
return left, nil
|
||||
}
|
||||
|
||||
func (p *calcParser) parsePower() (float64, error) {
|
||||
base, err := p.parseUnary()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
p.skipSpaces()
|
||||
if p.pos < len(p.input) && p.input[p.pos] == '^' {
|
||||
p.pos++
|
||||
// Right-associative: 2^3^2 = 2^(3^2)
|
||||
exp, err := p.parsePower()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return math.Pow(base, exp), nil
|
||||
}
|
||||
return base, nil
|
||||
}
|
||||
|
||||
func (p *calcParser) parseUnary() (float64, error) {
|
||||
p.skipSpaces()
|
||||
if p.pos < len(p.input) && p.input[p.pos] == '-' {
|
||||
p.pos++
|
||||
val, err := p.parseUnary()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return -val, nil
|
||||
}
|
||||
if p.pos < len(p.input) && p.input[p.pos] == '+' {
|
||||
p.pos++
|
||||
return p.parseUnary()
|
||||
}
|
||||
return p.parsePrimary()
|
||||
}
|
||||
|
||||
func (p *calcParser) parsePrimary() (float64, error) {
|
||||
p.skipSpaces()
|
||||
if p.pos >= len(p.input) {
|
||||
return 0, fmt.Errorf("unexpected end of expression")
|
||||
}
|
||||
|
||||
// Parenthesized expression
|
||||
if p.input[p.pos] == '(' {
|
||||
p.pos++
|
||||
val, err := p.parseExpr()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
p.skipSpaces()
|
||||
if p.pos >= len(p.input) || p.input[p.pos] != ')' {
|
||||
return 0, fmt.Errorf("missing closing parenthesis")
|
||||
}
|
||||
p.pos++
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// Identifier (function or constant)
|
||||
if isAlpha(p.input[p.pos]) {
|
||||
name := p.parseIdent()
|
||||
return p.evalIdentifier(name)
|
||||
}
|
||||
|
||||
// Number
|
||||
return p.parseNumber()
|
||||
}
|
||||
|
||||
func (p *calcParser) parseIdent() string {
|
||||
start := p.pos
|
||||
for p.pos < len(p.input) && (isAlpha(p.input[p.pos]) || isDigit(p.input[p.pos])) {
|
||||
p.pos++
|
||||
}
|
||||
return strings.ToLower(p.input[start:p.pos])
|
||||
}
|
||||
|
||||
func (p *calcParser) evalIdentifier(name string) (float64, error) {
|
||||
// Constants
|
||||
switch name {
|
||||
case "pi":
|
||||
return math.Pi, nil
|
||||
case "e":
|
||||
return math.E, nil
|
||||
case "inf":
|
||||
return math.Inf(1), nil
|
||||
}
|
||||
|
||||
// Functions (require parenthesized argument)
|
||||
p.skipSpaces()
|
||||
if p.pos >= len(p.input) || p.input[p.pos] != '(' {
|
||||
return 0, fmt.Errorf("unknown constant %q (did you mean %s(x)?)", name, name)
|
||||
}
|
||||
p.pos++ // consume '('
|
||||
|
||||
arg, err := p.parseExpr()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Check for second argument (e.g. pow(2, 3))
|
||||
p.skipSpaces()
|
||||
var arg2 float64
|
||||
hasArg2 := false
|
||||
if p.pos < len(p.input) && p.input[p.pos] == ',' {
|
||||
p.pos++
|
||||
arg2, err = p.parseExpr()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
hasArg2 = true
|
||||
}
|
||||
|
||||
p.skipSpaces()
|
||||
if p.pos >= len(p.input) || p.input[p.pos] != ')' {
|
||||
return 0, fmt.Errorf("missing closing parenthesis for %s()", name)
|
||||
}
|
||||
p.pos++
|
||||
|
||||
switch name {
|
||||
case "sqrt":
|
||||
return math.Sqrt(arg), nil
|
||||
case "abs":
|
||||
return math.Abs(arg), nil
|
||||
case "ceil":
|
||||
return math.Ceil(arg), nil
|
||||
case "floor":
|
||||
return math.Floor(arg), nil
|
||||
case "round":
|
||||
return math.Round(arg), nil
|
||||
case "log", "ln":
|
||||
return math.Log(arg), nil
|
||||
case "log2":
|
||||
return math.Log2(arg), nil
|
||||
case "log10":
|
||||
return math.Log10(arg), nil
|
||||
case "sin":
|
||||
return math.Sin(arg), nil
|
||||
case "cos":
|
||||
return math.Cos(arg), nil
|
||||
case "tan":
|
||||
return math.Tan(arg), nil
|
||||
case "asin":
|
||||
return math.Asin(arg), nil
|
||||
case "acos":
|
||||
return math.Acos(arg), nil
|
||||
case "atan":
|
||||
return math.Atan(arg), nil
|
||||
case "exp":
|
||||
return math.Exp(arg), nil
|
||||
case "pow":
|
||||
if !hasArg2 {
|
||||
return 0, fmt.Errorf("pow() requires two arguments: pow(base, exponent)")
|
||||
}
|
||||
return math.Pow(arg, arg2), nil
|
||||
case "max":
|
||||
if !hasArg2 {
|
||||
return 0, fmt.Errorf("max() requires two arguments")
|
||||
}
|
||||
return math.Max(arg, arg2), nil
|
||||
case "min":
|
||||
if !hasArg2 {
|
||||
return 0, fmt.Errorf("min() requires two arguments")
|
||||
}
|
||||
return math.Min(arg, arg2), nil
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown function %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *calcParser) parseNumber() (float64, error) {
|
||||
start := p.pos
|
||||
if p.pos < len(p.input) && p.input[p.pos] == '.' {
|
||||
p.pos++
|
||||
}
|
||||
if p.pos >= len(p.input) || !isDigit(p.input[p.pos]) {
|
||||
if p.pos > start {
|
||||
// lone dot
|
||||
return 0, fmt.Errorf("invalid number at position %d", start)
|
||||
}
|
||||
return 0, fmt.Errorf("expected number at position %d, got %q", p.pos, string(p.input[p.pos]))
|
||||
}
|
||||
for p.pos < len(p.input) && isDigit(p.input[p.pos]) {
|
||||
p.pos++
|
||||
}
|
||||
if p.pos < len(p.input) && p.input[p.pos] == '.' {
|
||||
p.pos++
|
||||
for p.pos < len(p.input) && isDigit(p.input[p.pos]) {
|
||||
p.pos++
|
||||
}
|
||||
}
|
||||
// Scientific notation
|
||||
if p.pos < len(p.input) && (p.input[p.pos] == 'e' || p.input[p.pos] == 'E') {
|
||||
p.pos++
|
||||
if p.pos < len(p.input) && (p.input[p.pos] == '+' || p.input[p.pos] == '-') {
|
||||
p.pos++
|
||||
}
|
||||
for p.pos < len(p.input) && isDigit(p.input[p.pos]) {
|
||||
p.pos++
|
||||
}
|
||||
}
|
||||
|
||||
val, err := strconv.ParseFloat(p.input[start:p.pos], 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid number %q", p.input[start:p.pos])
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
func (p *calcParser) skipSpaces() {
|
||||
for p.pos < len(p.input) && p.input[p.pos] == ' ' {
|
||||
p.pos++
|
||||
}
|
||||
}
|
||||
|
||||
func isAlpha(b byte) bool { return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || b == '_' }
|
||||
func isDigit(b byte) bool { return b >= '0' && b <= '9' }
|
||||
|
||||
Reference in New Issue
Block a user