This commit is contained in:
37
pkg/authsupport/csrf.go
Normal file
37
pkg/authsupport/csrf.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package authsupport
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// csrfTokenBytes is the amount of entropy in a CSRF token before
|
||||
// encoding. This isn't a secret that needs to resist offline brute
|
||||
// force the way a password hash does — it just needs to be
|
||||
// unguessable to a third-party site, so 128 bits is comfortably enough.
|
||||
const csrfTokenBytes = 16
|
||||
|
||||
// NewCSRFToken returns a fresh, URL-safe random token suitable for the
|
||||
// double-submit-cookie pattern: the same value is set as a cookie and
|
||||
// embedded as a hidden form field, and a request is rejected unless the
|
||||
// two match (see VerifyCSRF). Unlike session/verification tokens, the
|
||||
// raw value itself is what's compared — there's no server-side lookup,
|
||||
// so no hash-at-rest step is needed here.
|
||||
func NewCSRFToken() (string, error) {
|
||||
b := make([]byte, csrfTokenBytes)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("auth: generate csrf token: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// VerifyCSRF reports whether a cookie value and a submitted form value
|
||||
// match, in constant time. Both empty is never valid.
|
||||
func VerifyCSRF(cookieValue, formValue string) bool {
|
||||
if cookieValue == "" || formValue == "" {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(cookieValue), []byte(formValue)) == 1
|
||||
}
|
||||
110
pkg/authsupport/password.go
Normal file
110
pkg/authsupport/password.go
Normal file
@@ -0,0 +1,110 @@
|
||||
// Package auth holds enkly's password hashing, token generation, and
|
||||
// session/rate-limiting primitives.
|
||||
//
|
||||
// Password hashing deliberately avoids golang.org/x/crypto/bcrypt: this
|
||||
// module has no third-party dependencies beyond lib/pq and gomarkdown
|
||||
// (see go.mod), and pulling in x/crypto just for bcrypt isn't worth the
|
||||
// extra supply-chain surface when the stdlib's crypto/hmac + crypto/sha256
|
||||
// are enough to implement PBKDF2 (RFC 8018) directly — a well-defined,
|
||||
// widely reviewed construction, not a home-grown cipher.
|
||||
package authsupport
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// pbkdf2Iterations follows OWASP's 2023 password-storage cheat sheet
|
||||
// recommendation for PBKDF2-HMAC-SHA256 (>= 600,000 was later raised;
|
||||
// 210,000 is the still-current floor for SHA256 specifically). Bump this
|
||||
// if hashing becomes cheap enough to be a problem — old hashes keep
|
||||
// verifying fine since the iteration count travels with the hash.
|
||||
const pbkdf2Iterations = 210_000
|
||||
|
||||
const (
|
||||
saltLen = 16 // bytes
|
||||
keyLen = 32 // bytes (SHA-256 output size)
|
||||
)
|
||||
|
||||
// pbkdf2 derives a keyLen-byte key from password+salt using HMAC-SHA256
|
||||
// as the PRF, per RFC 8018 section 5.2.
|
||||
func pbkdf2(password, salt []byte, iterations, length int) []byte {
|
||||
prf := hmac.New(sha256.New, password)
|
||||
hLen := prf.Size()
|
||||
numBlocks := (length + hLen - 1) / hLen
|
||||
|
||||
var derived []byte
|
||||
buf := make([]byte, 4)
|
||||
for block := 1; block <= numBlocks; block++ {
|
||||
buf[0] = byte(block >> 24)
|
||||
buf[1] = byte(block >> 16)
|
||||
buf[2] = byte(block >> 8)
|
||||
buf[3] = byte(block)
|
||||
|
||||
prf.Reset()
|
||||
prf.Write(salt)
|
||||
prf.Write(buf)
|
||||
u := prf.Sum(nil)
|
||||
|
||||
t := make([]byte, len(u))
|
||||
copy(t, u)
|
||||
|
||||
for i := 1; i < iterations; i++ {
|
||||
prf.Reset()
|
||||
prf.Write(u)
|
||||
u = prf.Sum(nil)
|
||||
for j := range t {
|
||||
t[j] ^= u[j]
|
||||
}
|
||||
}
|
||||
derived = append(derived, t...)
|
||||
}
|
||||
return derived[:length]
|
||||
}
|
||||
|
||||
// HashPassword returns an encoded hash of the form
|
||||
// "pbkdf2-sha256$<iterations>$<base64 salt>$<base64 hash>", suitable for
|
||||
// storing directly in users.password_hash.
|
||||
func HashPassword(password string) (string, error) {
|
||||
salt := make([]byte, saltLen)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", fmt.Errorf("auth: generate salt: %w", err)
|
||||
}
|
||||
hash := pbkdf2([]byte(password), salt, pbkdf2Iterations, keyLen)
|
||||
|
||||
return fmt.Sprintf("pbkdf2-sha256$%d$%s$%s",
|
||||
pbkdf2Iterations,
|
||||
base64.RawStdEncoding.EncodeToString(salt),
|
||||
base64.RawStdEncoding.EncodeToString(hash),
|
||||
), nil
|
||||
}
|
||||
|
||||
// VerifyPassword checks a plaintext password against an encoded hash
|
||||
// produced by HashPassword, in constant time.
|
||||
func VerifyPassword(password, encoded string) bool {
|
||||
parts := strings.Split(encoded, "$")
|
||||
if len(parts) != 4 || parts[0] != "pbkdf2-sha256" {
|
||||
return false
|
||||
}
|
||||
iterations, err := strconv.Atoi(parts[1])
|
||||
if err != nil || iterations <= 0 {
|
||||
return false
|
||||
}
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[2])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
want, err := base64.RawStdEncoding.DecodeString(parts[3])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
got := pbkdf2([]byte(password), salt, iterations, len(want))
|
||||
return subtle.ConstantTimeCompare(got, want) == 1
|
||||
}
|
||||
87
pkg/authsupport/ratelimit.go
Normal file
87
pkg/authsupport/ratelimit.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package authsupport
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Limiter slows repeated attempts against a single key (an email
|
||||
// address or a client IP) instead of flatly blocking them: each
|
||||
// consecutive failure within the window makes the *next* attempt's
|
||||
// artificial delay longer, capped at maxDelay. That's enough to make
|
||||
// password-guessing or verification-email-spam scripts painfully slow
|
||||
// without needing a CAPTCHA or an IP ban list, and it degrades
|
||||
// gracefully — a genuine user who mistypes their password twice waits a
|
||||
// second, not minutes.
|
||||
//
|
||||
// State is in-memory and per-process, which is fine for enkly's
|
||||
// single-instance deployment (see docker-compose.yml); it resets on
|
||||
// restart and doesn't need a database round-trip on the hot path.
|
||||
type Limiter struct {
|
||||
mu sync.Mutex
|
||||
attempts map[string]*attemptState
|
||||
window time.Duration
|
||||
baseWait time.Duration
|
||||
maxWait time.Duration
|
||||
}
|
||||
|
||||
type attemptState struct {
|
||||
count int
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
// NewLimiter builds a Limiter. window is how long a run of failures is
|
||||
// remembered before resetting; baseWait/maxWait bound the artificial
|
||||
// delay (baseWait * 2^(failures-1), capped at maxWait).
|
||||
func NewLimiter(window, baseWait, maxWait time.Duration) *Limiter {
|
||||
return &Limiter{
|
||||
attempts: make(map[string]*attemptState),
|
||||
window: window,
|
||||
baseWait: baseWait,
|
||||
maxWait: maxWait,
|
||||
}
|
||||
}
|
||||
|
||||
// Delay returns how long the caller should sleep before processing the
|
||||
// next attempt for key, based on how many recent failures it has seen.
|
||||
// It does not itself sleep or record anything — call RecordFailure /
|
||||
// RecordSuccess to update state.
|
||||
func (l *Limiter) Delay(key string) time.Duration {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
st, ok := l.attempts[key]
|
||||
if !ok || time.Since(st.lastSeen) > l.window {
|
||||
return 0
|
||||
}
|
||||
if st.count <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
wait := l.baseWait << (st.count - 1) // baseWait * 2^(count-1)
|
||||
if wait > l.maxWait || wait <= 0 { // <=0 guards against overflow
|
||||
wait = l.maxWait
|
||||
}
|
||||
return wait
|
||||
}
|
||||
|
||||
// RecordFailure increments key's failure streak.
|
||||
func (l *Limiter) RecordFailure(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
st, ok := l.attempts[key]
|
||||
if !ok || time.Since(st.lastSeen) > l.window {
|
||||
st = &attemptState{}
|
||||
l.attempts[key] = st
|
||||
}
|
||||
st.count++
|
||||
st.lastSeen = time.Now()
|
||||
}
|
||||
|
||||
// RecordSuccess clears key's failure streak.
|
||||
func (l *Limiter) RecordSuccess(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.attempts, key)
|
||||
}
|
||||
58
pkg/authsupport/token.go
Normal file
58
pkg/authsupport/token.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package authsupport
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math/big"
|
||||
)
|
||||
|
||||
// tokenBytes is the amount of entropy in a raw token before encoding —
|
||||
// 32 bytes (256 bits) is comfortably enough that guessing is infeasible.
|
||||
const tokenBytes = 32
|
||||
|
||||
// NewToken returns a fresh, URL-safe random token (for password-reset
|
||||
// links and session cookies) and the hex-encoded SHA-256 hash of it that
|
||||
// should be persisted in its place. Only the hash ever touches the
|
||||
// database — the raw token lives solely in the email link or the
|
||||
// browser's cookie, so a DB dump alone can't be replayed.
|
||||
func NewToken() (raw string, hash string, err error) {
|
||||
b := make([]byte, tokenBytes)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", "", fmt.Errorf("auth: generate token: %w", err)
|
||||
}
|
||||
raw = base64.RawURLEncoding.EncodeToString(b)
|
||||
return raw, HashToken(raw), nil
|
||||
}
|
||||
|
||||
// codeSpace is how many distinct 6-digit codes NewCode can produce
|
||||
// (000000 through 999999).
|
||||
const codeSpace = 1_000_000
|
||||
|
||||
// NewCode returns a fresh random 6-digit numeric code — as a
|
||||
// zero-padded string, so "007123" round-trips correctly — for emailing
|
||||
// as an email-verification code, and the hex-encoded SHA-256 hash of it
|
||||
// that should be persisted in its place. Unlike NewToken's 256 bits of
|
||||
// entropy, a 6-digit code is deliberately guessable-by-brute-force if
|
||||
// nothing else stops repeated attempts; callers must pair it with an
|
||||
// attempt limit and a short expiry (see internal/db.VerifyEmailCode and
|
||||
// verifyCodeTTL) rather than relying on the code space alone.
|
||||
func NewCode() (raw string, hash string, err error) {
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(codeSpace))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("auth: generate code: %w", err)
|
||||
}
|
||||
raw = fmt.Sprintf("%06d", n.Int64())
|
||||
return raw, HashToken(raw), nil
|
||||
}
|
||||
|
||||
// HashToken hashes a raw token or code the same way NewToken/NewCode do,
|
||||
// so a value received back from the client (a reset link's ?token=, a
|
||||
// session cookie, or a typed verification code) can be looked up by its
|
||||
// hash.
|
||||
func HashToken(raw string) string {
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
44
pkg/authsupport/token_test.go
Normal file
44
pkg/authsupport/token_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package authsupport
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNewCodeShape(t *testing.T) {
|
||||
seen := make(map[string]bool)
|
||||
for i := 0; i < 200; i++ {
|
||||
raw, hash, err := NewCode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(raw) != 6 {
|
||||
t.Fatalf("code %q is not 6 digits", raw)
|
||||
}
|
||||
for _, c := range raw {
|
||||
if c < '0' || c > '9' {
|
||||
t.Fatalf("code %q has a non-digit", raw)
|
||||
}
|
||||
}
|
||||
if hash != HashToken(raw) {
|
||||
t.Fatalf("hash mismatch for %q", raw)
|
||||
}
|
||||
seen[raw] = true
|
||||
}
|
||||
// Not a strict randomness test, just a sanity check that we're not
|
||||
// somehow returning the same code every time.
|
||||
if len(seen) < 190 {
|
||||
t.Fatalf("suspiciously few distinct codes out of 200 draws: %d", len(seen))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCodeZeroPadded(t *testing.T) {
|
||||
// %06d must preserve leading zeros (e.g. a draw of 42 -> "000042"),
|
||||
// or VerifyEmailCode would compare against a mis-shapen string.
|
||||
for i := 0; i < 2000; i++ {
|
||||
raw, _, err := NewCode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(raw) != 6 {
|
||||
t.Fatalf("code %q is not zero-padded to 6 digits", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
175
pkg/effort/effort.go
Normal file
175
pkg/effort/effort.go
Normal file
@@ -0,0 +1,175 @@
|
||||
// Package effort defines enkly's manual reasoning-depth control: five
|
||||
// named levels (Low, Medium, High, Extra, Max) a caller can pick to
|
||||
// arbitrate between speed and precision for one agent turn, modeled on
|
||||
// Claude's effort parameter. It is deliberately a small, dependency-free
|
||||
// package — just a Level type plus the static Profile each one maps to
|
||||
// — so both the agent service (which actually runs a turn at a given
|
||||
// level) and any future transport (gRPC field, HTTP query param, a
|
||||
// gateway settings dropdown) can depend on it without pulling in
|
||||
// agentrun or vllmkit.
|
||||
//
|
||||
// vLLM/Qwen has no first-class notion of "effort" the way the
|
||||
// Anthropic API does (a token budget plus a reasoning-depth knob) — the
|
||||
// only dial it exposes per request is vllmkit.Client's think string
|
||||
// ("" / "true" / "false" / "low" / "medium" / "high", all of which
|
||||
// collapse to a single on/off enable_thinking toggle on the wire — see
|
||||
// vllmkit.Client.Think's doc comment) plus the ordinary generation
|
||||
// options (max_tokens, temperature, top_p). Profile is this package's
|
||||
// honest mapping of Claude's five-level vocabulary onto that smaller
|
||||
// vocabulary: Think picks the per-round reasoning-effort floor (see
|
||||
// agentrun.EffortThinkPolicy), MaxRounds caps how many tool-call
|
||||
// round-trips a turn is allowed before Low has to converge fast and Max
|
||||
// can keep investigating, and NumPredictCap bounds a single reply's
|
||||
// length so Low genuinely costs less and Max isn't truncated mid-answer.
|
||||
package effort
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Level is one of five named reasoning-depth settings, ordered from
|
||||
// fastest/cheapest to deepest/most thorough.
|
||||
type Level string
|
||||
|
||||
const (
|
||||
Low Level = "low"
|
||||
Medium Level = "medium"
|
||||
High Level = "high"
|
||||
Extra Level = "extra"
|
||||
Max Level = "max"
|
||||
)
|
||||
|
||||
// Default is used whenever no level was explicitly selected — the same
|
||||
// balance the agent struck before effort control existed.
|
||||
const Default = Medium
|
||||
|
||||
// All lists every level in ascending order of depth, for validation
|
||||
// messages and a future settings-UI dropdown.
|
||||
var All = []Level{Low, Medium, High, Extra, Max}
|
||||
|
||||
// Parse normalizes free-form text (a slash-directive argument, a query
|
||||
// param, a gRPC field once wired) into a Level. Case/space-insensitive;
|
||||
// a handful of obvious synonyms are accepted so "quick"/"fast" from the
|
||||
// fast-mode vocabulary and "deep"/"thorough" from everyday language
|
||||
// both resolve sensibly instead of erroring on a near-miss.
|
||||
func Parse(s string) (Level, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "":
|
||||
return Default, nil
|
||||
case "low", "fast", "quick", "speed":
|
||||
return Low, nil
|
||||
case "medium", "default", "normal", "balanced":
|
||||
return Medium, nil
|
||||
case "high", "deep", "careful":
|
||||
return High, nil
|
||||
case "extra", "extra-high", "extra_high", "thorough":
|
||||
return Extra, nil
|
||||
case "max", "maximum", "deepest", "ultra":
|
||||
return Max, nil
|
||||
default:
|
||||
return "", fmt.Errorf("effort: unknown level %q (want one of low, medium, high, extra, max)", s)
|
||||
}
|
||||
}
|
||||
|
||||
// Valid reports whether l is one of the five defined levels — distinct
|
||||
// from Parse accepting synonyms: Valid is for checking an already
|
||||
// canonical value (e.g. one stored verbatim), Parse is for user input.
|
||||
func (l Level) Valid() bool {
|
||||
switch l {
|
||||
case Low, Medium, High, Extra, Max:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Profile is the concrete tuning one Level resolves to. Every field has
|
||||
// a conservative, locally-reasonable value — there's no remote
|
||||
// "effort=max" service to defer to the way the Anthropic API has, so
|
||||
// these numbers are enkly's own calibration for enkly's vLLM
|
||||
// deployment, meant to be adjusted (see cmd/server/main.go's
|
||||
// ENKLY_EFFORT_* overrides) rather than treated as gospel.
|
||||
type Profile struct {
|
||||
// Level this profile was resolved from, for logging.
|
||||
Level Level
|
||||
// Think is the thinking-effort floor passed to the chat model on
|
||||
// every round of the turn (see vllmkit.Client's Think field). A
|
||||
// per-round policy (agentrun.EffortThinkPolicy) may still ask for
|
||||
// more on round 0 or less on a retry — this is the floor beneath
|
||||
// which it won't go, not an override of every round alike.
|
||||
Think string
|
||||
// MaxRounds caps tool-call round-trips for a turn run at this
|
||||
// level. Low converges fast on purpose; Max gets the same ceiling
|
||||
// dispatch_workflow's own sub-runs use, so a Max-effort turn can
|
||||
// still delegate broad work instead of exhausting its own budget
|
||||
// investigating serially.
|
||||
MaxRounds int
|
||||
// NumPredictCap bounds a single model reply's length in tokens (0
|
||||
// = no cap beyond the client's own configured default). Only Low
|
||||
// sets one — the other levels trust the model to stop when it's
|
||||
// done rather than truncating a legitimately long answer.
|
||||
NumPredictCap int
|
||||
// ContextBudgetChars overrides agentrun's default context-budget
|
||||
// threshold (see agentrun.Agent.ContextBudgetChars) for a turn run
|
||||
// at this level — Max keeps more tool-result history verbatim
|
||||
// before compacting, trading context budget for fewer "re-call the
|
||||
// tool if you need it again" gaps on a long investigation.
|
||||
ContextBudgetChars int
|
||||
// AdaptiveThinking, when true, lets the per-round policy raise
|
||||
// effort above Think on a round that looks like it needs it (an
|
||||
// error result, a round deep into a long turn) instead of holding
|
||||
// every round at exactly Think — see agentrun.AdaptiveThinkPolicy.
|
||||
// Low and Medium leave it off (predictable cost); High and above
|
||||
// turn it on (predictable quality).
|
||||
AdaptiveThinking bool
|
||||
// MaxParallelSubagents is the ceiling dispatch_workflow enforces
|
||||
// for a single dispatch_workflow call made during a turn run at
|
||||
// this level — see toolexec's workflow orchestrator. Low effort
|
||||
// still allows some parallel fan-out (a migration-shaped request
|
||||
// at Low effort is still a migration), just a smaller one.
|
||||
MaxParallelSubagents int
|
||||
}
|
||||
|
||||
// defaultContextBudgetChars mirrors agentrun's own unexported default
|
||||
// (contextBudgetChars) so Resolve's Medium profile reproduces the
|
||||
// agent's pre-effort-control behavior exactly. Keeping one literal
|
||||
// value here rather than importing agentrun (which would create an
|
||||
// import cycle: agentrun -> effort -> agentrun) is deliberate — see
|
||||
// agentrun.Agent.ContextBudgetChars's doc comment for the other half of
|
||||
// this contract.
|
||||
const defaultContextBudgetChars = 60_000
|
||||
|
||||
// Resolve returns the Profile for l. An invalid l (never produced by
|
||||
// Parse, but possible from a stored/decoded value) resolves as Medium
|
||||
// rather than panicking or erroring — effort control degrading to "the
|
||||
// behavior enkly always had" is always a safe failure mode.
|
||||
func (l Level) Resolve() Profile {
|
||||
switch l {
|
||||
case Low:
|
||||
return Profile{
|
||||
Level: Low, Think: "low", MaxRounds: 6, NumPredictCap: 700,
|
||||
ContextBudgetChars: defaultContextBudgetChars / 2, AdaptiveThinking: false, MaxParallelSubagents: 16,
|
||||
}
|
||||
case High:
|
||||
return Profile{
|
||||
Level: High, Think: "high", MaxRounds: 16,
|
||||
ContextBudgetChars: defaultContextBudgetChars * 2, AdaptiveThinking: true, MaxParallelSubagents: 96,
|
||||
}
|
||||
case Extra:
|
||||
return Profile{
|
||||
Level: Extra, Think: "high", MaxRounds: 24,
|
||||
ContextBudgetChars: defaultContextBudgetChars * 4, AdaptiveThinking: true, MaxParallelSubagents: 192,
|
||||
}
|
||||
case Max:
|
||||
return Profile{
|
||||
Level: Max, Think: "high", MaxRounds: 32,
|
||||
ContextBudgetChars: defaultContextBudgetChars * 8, AdaptiveThinking: true, MaxParallelSubagents: 256,
|
||||
}
|
||||
default: // Medium and anything unrecognized
|
||||
return Profile{
|
||||
Level: Medium, Think: "", MaxRounds: 12,
|
||||
ContextBudgetChars: defaultContextBudgetChars, AdaptiveThinking: false, MaxParallelSubagents: 48,
|
||||
}
|
||||
}
|
||||
}
|
||||
102
pkg/effort/effort_test.go
Normal file
102
pkg/effort/effort_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package effort
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want Level
|
||||
wantErr bool
|
||||
}{
|
||||
{"", Default, false},
|
||||
{"low", Low, false},
|
||||
{" LOW ", Low, false},
|
||||
{"fast", Low, false},
|
||||
{"medium", Medium, false},
|
||||
{"default", Medium, false},
|
||||
{"high", High, false},
|
||||
{"deep", High, false},
|
||||
{"extra", Extra, false},
|
||||
{"thorough", Extra, false},
|
||||
{"max", Max, false},
|
||||
{"ultra", Max, false},
|
||||
{"bogus", "", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, err := Parse(tc.in)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("Parse(%q) = %q, nil; want an error", tc.in, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("Parse(%q) returned unexpected error: %v", tc.in, err)
|
||||
continue
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("Parse(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValid(t *testing.T) {
|
||||
for _, l := range All {
|
||||
if !l.Valid() {
|
||||
t.Errorf("%q should be Valid", l)
|
||||
}
|
||||
}
|
||||
if Level("bogus").Valid() {
|
||||
t.Error(`Level("bogus").Valid() = true, want false`)
|
||||
}
|
||||
if Level("").Valid() {
|
||||
t.Error(`Level("").Valid() = true, want false`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOrdering(t *testing.T) {
|
||||
// Depth should be monotonic across the five levels for every knob
|
||||
// that's supposed to scale with effort — a regression here (e.g.
|
||||
// Extra accidentally configured with a smaller budget than High)
|
||||
// would quietly break the "arbitrate between speed and precision"
|
||||
// contract the whole feature exists to provide.
|
||||
levels := []Level{Low, Medium, High, Extra, Max}
|
||||
var prevRounds, prevBudget, prevParallel int
|
||||
for i, l := range levels {
|
||||
p := l.Resolve()
|
||||
if p.Level != l {
|
||||
t.Errorf("%s.Resolve().Level = %s, want %s", l, p.Level, l)
|
||||
}
|
||||
if i > 0 {
|
||||
if p.MaxRounds < prevRounds {
|
||||
t.Errorf("%s.MaxRounds (%d) < %s.MaxRounds (%d); want non-decreasing", l, p.MaxRounds, levels[i-1], prevRounds)
|
||||
}
|
||||
if p.ContextBudgetChars < prevBudget {
|
||||
t.Errorf("%s.ContextBudgetChars (%d) < %s.ContextBudgetChars (%d); want non-decreasing", l, p.ContextBudgetChars, levels[i-1], prevBudget)
|
||||
}
|
||||
if p.MaxParallelSubagents < prevParallel {
|
||||
t.Errorf("%s.MaxParallelSubagents (%d) < %s.MaxParallelSubagents (%d); want non-decreasing", l, p.MaxParallelSubagents, levels[i-1], prevParallel)
|
||||
}
|
||||
}
|
||||
prevRounds, prevBudget, prevParallel = p.MaxRounds, p.ContextBudgetChars, p.MaxParallelSubagents
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInvalidFallsBackToMedium(t *testing.T) {
|
||||
got := Level("not-a-real-level").Resolve()
|
||||
want := Medium.Resolve()
|
||||
if got != want {
|
||||
t.Errorf("invalid Level.Resolve() = %+v, want Medium's profile %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLowHasOutputCapOthersDont(t *testing.T) {
|
||||
if Low.Resolve().NumPredictCap == 0 {
|
||||
t.Error("Low should set a NumPredictCap")
|
||||
}
|
||||
for _, l := range []Level{Medium, High, Extra, Max} {
|
||||
if got := l.Resolve().NumPredictCap; got != 0 {
|
||||
t.Errorf("%s.NumPredictCap = %d, want 0 (uncapped)", l, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
105
pkg/entitlements/entitlements.go
Normal file
105
pkg/entitlements/entitlements.go
Normal file
@@ -0,0 +1,105 @@
|
||||
// Package entitlements resolves which premium agent capabilities a
|
||||
// given subscription plan unlocks — today, specifically dynamic
|
||||
// workflows (dispatch_workflow's parallel multi-sub-agent
|
||||
// orchestration). Effort control and fast mode are deliberately NOT
|
||||
// gated here: per the product brief, those two stay available on every
|
||||
// plan, so there is nothing for this package to decide about them.
|
||||
//
|
||||
// Why this is its own small package rather than a field on billing's
|
||||
// domain.Plan: the agent service never imports billing's domain
|
||||
// package directly (see ARCHITECTURE.md's layering rules — Agent calls
|
||||
// Billing only over gRPC, as a saga coordinator, and billing's
|
||||
// CheckAndIncrementUsageResponse already hands back the plan as a
|
||||
// plain string, PlanName, specifically so a caller doesn't need
|
||||
// billing's own types to make a decision from it). Keeping the
|
||||
// plan-name -> capability mapping here, keyed by that same string,
|
||||
// lets the agent service gate a feature without a new cross-service
|
||||
// dependency, and lets ops retarget which plan names unlock dynamic
|
||||
// workflows via one env var (see DynamicWorkflowPlansEnv) with no code
|
||||
// change — which matters because, as of this package's introduction,
|
||||
// billing's own plan catalog (services/billing/internal/domain/
|
||||
// subscription.go) only names four tiers (free/basic/pro/premium), not
|
||||
// the Enterprise/Team/Max vocabulary dynamic workflows are meant to
|
||||
// ship on. "premium" — today's top tier — is the default stand-in for
|
||||
// all three until billing's catalog is extended with real Team/
|
||||
// Enterprise/Max entries (a Catalog append plus a migration touching
|
||||
// the `plan` CHECK constraint — see that file's own doc comment — not
|
||||
// a change this package needs to know about).
|
||||
package entitlements
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DynamicWorkflowPlansEnv is the environment variable
|
||||
// cmd/server/main.go reads to build a Set's plan allowlist — a
|
||||
// comma-separated list of plan names (matched case-insensitively
|
||||
// against billing's CheckAndIncrementUsageResponse.PlanName), e.g.
|
||||
// "premium,team,enterprise,max". Unset or empty means "use
|
||||
// DefaultDynamicWorkflowPlans".
|
||||
const DynamicWorkflowPlansEnv = "ENKLY_DYNAMIC_WORKFLOW_PLANS"
|
||||
|
||||
// DefaultDynamicWorkflowPlans is used when DynamicWorkflowPlansEnv is
|
||||
// unset — "premium" is billing's existing top tier; see the package
|
||||
// doc comment for why that's today's honest stand-in for
|
||||
// Enterprise/Team/Max.
|
||||
var DefaultDynamicWorkflowPlans = []string{"premium"}
|
||||
|
||||
// Catalog is the resolved plan allowlist — built once at startup (see
|
||||
// ParsePlanList) and consulted per request via Resolve. Kept as a
|
||||
// small value type rather than a package-level global so a test (or a
|
||||
// future per-tenant override) can construct its own without mutating
|
||||
// shared state.
|
||||
type Catalog struct {
|
||||
dynamicWorkflowPlans map[string]bool
|
||||
}
|
||||
|
||||
// ParsePlanList builds a Catalog from a comma-separated plan list (see
|
||||
// DynamicWorkflowPlansEnv's format); an empty/whitespace-only raw
|
||||
// falls back to DefaultDynamicWorkflowPlans.
|
||||
func ParsePlanList(raw string) Catalog {
|
||||
fields := strings.Split(raw, ",")
|
||||
names := make([]string, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
if f = strings.ToLower(strings.TrimSpace(f)); f != "" {
|
||||
names = append(names, f)
|
||||
}
|
||||
}
|
||||
if len(names) == 0 {
|
||||
names = DefaultDynamicWorkflowPlans
|
||||
}
|
||||
set := make(map[string]bool, len(names))
|
||||
for _, n := range names {
|
||||
set[n] = true
|
||||
}
|
||||
return Catalog{dynamicWorkflowPlans: set}
|
||||
}
|
||||
|
||||
// Set is one user's resolved capabilities for one turn — what
|
||||
// application.Service computes once (from the plan name billing's
|
||||
// CheckAndIncrementUsage already returned) and attaches to the run
|
||||
// context via toolexec.WithEntitlements, for dispatch_workflow to read
|
||||
// back without any gRPC call of its own.
|
||||
type Set struct {
|
||||
// DynamicWorkflows reports whether dispatch_workflow is usable this
|
||||
// turn.
|
||||
DynamicWorkflows bool
|
||||
// PlanName is the plan the decision was made from, carried along
|
||||
// purely so a denial message can name it (e.g. "not available on
|
||||
// the Free plan") without a second lookup.
|
||||
PlanName string
|
||||
}
|
||||
|
||||
// Resolve decides a Set for planName against c's allowlist. An unknown
|
||||
// or empty planName (a billing lookup that failed open, or a caller
|
||||
// that never set one) resolves as not entitled — a gated feature
|
||||
// failing closed on missing information is the safe default, unlike
|
||||
// the daily-message-quota check in application.Service.StartTurn,
|
||||
// which deliberately fails *open* because getting that wrong costs
|
||||
// someone a chat message, not an unintended unlock of a paid feature.
|
||||
func (c Catalog) Resolve(planName string) Set {
|
||||
return Set{
|
||||
DynamicWorkflows: c.dynamicWorkflowPlans[strings.ToLower(strings.TrimSpace(planName))],
|
||||
PlanName: planName,
|
||||
}
|
||||
}
|
||||
79
pkg/envkit/dotenv.go
Normal file
79
pkg/envkit/dotenv.go
Normal file
@@ -0,0 +1,79 @@
|
||||
// Package config loads ENKLY_* configuration from a ".env" file into the
|
||||
// process environment, so `os.Getenv` calls throughout the app (see
|
||||
// getenv() in cmd/server/main.go) pick up values from ".env" the same
|
||||
// way they'd pick up real environment variables — without adding a
|
||||
// third-party dependency like godotenv (this module intentionally keeps
|
||||
// go.mod to lib/pq + gomarkdown; see the comment on that file).
|
||||
package envkit
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// LoadDotEnv reads simple KEY=VALUE lines from path and calls
|
||||
// os.Setenv for each one whose key isn't already set in the real
|
||||
// environment — actual environment variables always win, so
|
||||
// `ENKLY_SMTP_PASSWORD=x make run` still overrides whatever ".env"
|
||||
// says. Missing files are not an error: ".env" is optional (e.g. in
|
||||
// docker-compose.yml, config comes from `environment:` instead).
|
||||
//
|
||||
// Supported syntax, deliberately small:
|
||||
//
|
||||
// KEY=value
|
||||
// KEY="value with spaces, or a # that isn't a comment"
|
||||
// KEY='value'
|
||||
// # full-line comments and blank lines are ignored
|
||||
// export KEY=value (a leading "export " is stripped, for .env files
|
||||
// written to be `source`-able in a shell too)
|
||||
//
|
||||
// No variable interpolation, no multi-line values.
|
||||
func LoadDotEnv(path string) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("config: open %s: %w", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
lineNo := 0
|
||||
for scanner.Scan() {
|
||||
lineNo++
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
line = strings.TrimPrefix(line, "export ")
|
||||
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
return fmt.Errorf("config: %s:%d: expected KEY=VALUE, got %q", path, lineNo, line)
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
value = strings.TrimSpace(value)
|
||||
if key == "" {
|
||||
return fmt.Errorf("config: %s:%d: empty key", path, lineNo)
|
||||
}
|
||||
|
||||
// Strip a single matching pair of surrounding quotes, if present.
|
||||
if len(value) >= 2 {
|
||||
first, last := value[0], value[len(value)-1]
|
||||
if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
|
||||
value = value[1 : len(value)-1]
|
||||
}
|
||||
}
|
||||
|
||||
if _, alreadySet := os.LookupEnv(key); alreadySet {
|
||||
continue // real environment variables take precedence
|
||||
}
|
||||
if err := os.Setenv(key, value); err != nil {
|
||||
return fmt.Errorf("config: set %s: %w", key, err)
|
||||
}
|
||||
}
|
||||
return scanner.Err()
|
||||
}
|
||||
60
pkg/envkit/getenv.go
Normal file
60
pkg/envkit/getenv.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package envkit
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// String returns the environment variable key, or fallback if unset/empty.
|
||||
// Same semantics as the getenv() helper every cmd/server/main.go in the
|
||||
// monolith defined locally — hoisted here once since every microservice
|
||||
// now needs it.
|
||||
func String(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// Int parses the environment variable key as an integer, or returns
|
||||
// fallback if unset or unparseable.
|
||||
func Int(key string, fallback int) int {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Float parses the environment variable key as a float64, or returns
|
||||
// fallback if unset or unparseable.
|
||||
func Float(key string, fallback float64) float64 {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
f, err := strconv.ParseFloat(v, 64)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// Bool parses the environment variable key as a bool ("true"/"1"/"yes",
|
||||
// case-insensitive, or their opposites), or returns fallback if unset
|
||||
// or unparseable.
|
||||
func Bool(key string, fallback bool) bool {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
b, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return b
|
||||
}
|
||||
159
pkg/eventbus/bus.go
Normal file
159
pkg/eventbus/bus.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package eventbus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
// Bus is a small, reconnect-free wrapper over one amqp091 connection and
|
||||
// channel. It declares ExchangeName as a durable topic exchange on
|
||||
// connect so publishers and consumers never race over who creates it
|
||||
// first.
|
||||
//
|
||||
// Deliberately minimal: no automatic reconnect/retry loop, no publisher
|
||||
// confirms, no dead-letter exchange. Those are real production
|
||||
// concerns, but adding them here would be infrastructure plumbing
|
||||
// unrelated to the DDD restructuring this package exists to
|
||||
// demonstrate — see the "Operational hardening" section of
|
||||
// ARCHITECTURE.md for what a production rollout should add before this
|
||||
// goes live.
|
||||
type Bus struct {
|
||||
conn *amqp.Connection
|
||||
channel *amqp.Channel
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// Connect dials url (an amqp:// URI) and declares the shared topology.
|
||||
func Connect(url string, logger *slog.Logger) (*Bus, error) {
|
||||
conn, err := amqp.Dial(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dial rabbitmq: %w", err)
|
||||
}
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("open channel: %w", err)
|
||||
}
|
||||
if err := ch.ExchangeDeclare(ExchangeName, "topic", true, false, false, false, nil); err != nil {
|
||||
_ = ch.Close()
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("declare exchange: %w", err)
|
||||
}
|
||||
return &Bus{conn: conn, channel: ch, logger: logger}, nil
|
||||
}
|
||||
|
||||
// Close releases the underlying channel and connection.
|
||||
func (b *Bus) Close() error {
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
if err := b.channel.Close(); err != nil {
|
||||
_ = b.conn.Close()
|
||||
return err
|
||||
}
|
||||
return b.conn.Close()
|
||||
}
|
||||
|
||||
// Envelope is the JSON structure every event is published as. OccurredAt
|
||||
// and RoutingKey are set by Publish; Payload is whatever event-specific
|
||||
// struct the caller passes (see pkg/eventbus/events).
|
||||
type Envelope struct {
|
||||
RoutingKey string `json:"routing_key"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
|
||||
// Publish marshals payload and sends it to ExchangeName with the given
|
||||
// routing key, as a persistent message (survives a broker restart).
|
||||
// Fire-and-forget: the caller's own request/response flow (over gRPC)
|
||||
// must never block on this succeeding — a publish failure is logged,
|
||||
// not propagated, exactly like the monolith's own "log, don't fail the
|
||||
// request" stance on e.g. a cleanup-loop error.
|
||||
func (b *Bus) Publish(ctx context.Context, routingKey string, payload any) error {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal event payload: %w", err)
|
||||
}
|
||||
env := Envelope{RoutingKey: routingKey, OccurredAt: time.Now().UTC(), Payload: body}
|
||||
envBody, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal envelope: %w", err)
|
||||
}
|
||||
return b.channel.PublishWithContext(ctx, ExchangeName, routingKey, false, false, amqp.Publishing{
|
||||
ContentType: "application/json",
|
||||
DeliveryMode: amqp.Persistent,
|
||||
Timestamp: time.Now(),
|
||||
Body: envBody,
|
||||
})
|
||||
}
|
||||
|
||||
// PublishBestEffort calls Publish and logs (rather than returns) any
|
||||
// error — the shape every call site in this codebase actually wants,
|
||||
// since a notification being late or lost must never fail the
|
||||
// synchronous gRPC call that triggered it.
|
||||
func (b *Bus) PublishBestEffort(ctx context.Context, routingKey string, payload any) {
|
||||
if err := b.Publish(ctx, routingKey, payload); err != nil {
|
||||
b.logger.Error("publish event", "routing_key", routingKey, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Handler processes one decoded event. Returning an error nacks the
|
||||
// delivery with requeue=true; a consumer that can't make progress
|
||||
// should log and return nil instead of spinning forever on one poison
|
||||
// message (the notification service does exactly this — see
|
||||
// services/notification/internal/infrastructure/eventing).
|
||||
type Handler func(ctx context.Context, env Envelope) error
|
||||
|
||||
// Consume declares binding.Queue durable, binds it to every routing key
|
||||
// in binding.RoutingKeys, and runs handler for each delivery until ctx
|
||||
// is cancelled. Blocks the calling goroutine.
|
||||
func (b *Bus) Consume(ctx context.Context, binding QueueBinding, handler Handler) error {
|
||||
q, err := b.channel.QueueDeclare(binding.Queue, true, false, false, false, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("declare queue %s: %w", binding.Queue, err)
|
||||
}
|
||||
for _, key := range binding.RoutingKeys {
|
||||
if err := b.channel.QueueBind(q.Name, key, ExchangeName, false, nil); err != nil {
|
||||
return fmt.Errorf("bind queue %s to %s: %w", binding.Queue, key, err)
|
||||
}
|
||||
}
|
||||
// Process one message at a time per consumer goroutine — the
|
||||
// notification service's send-an-email handler is I/O bound on
|
||||
// SMTP, not CPU bound, so this keeps ordering simple without
|
||||
// needing a worker pool for the volumes this is sized for.
|
||||
if err := b.channel.Qos(1, 0, false); err != nil {
|
||||
return fmt.Errorf("set qos: %w", err)
|
||||
}
|
||||
deliveries, err := b.channel.Consume(q.Name, "", false, false, false, false, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("consume %s: %w", q.Name, err)
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case d, ok := <-deliveries:
|
||||
if !ok {
|
||||
return fmt.Errorf("delivery channel closed for queue %s", q.Name)
|
||||
}
|
||||
var env Envelope
|
||||
if err := json.Unmarshal(d.Body, &env); err != nil {
|
||||
b.logger.Error("decode event envelope", "queue", q.Name, "err", err)
|
||||
_ = d.Nack(false, false) // malformed — don't requeue
|
||||
continue
|
||||
}
|
||||
if err := handler(ctx, env); err != nil {
|
||||
b.logger.Error("handle event", "queue", q.Name, "routing_key", env.RoutingKey, "err", err)
|
||||
_ = d.Nack(false, true)
|
||||
continue
|
||||
}
|
||||
_ = d.Ack(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
14
pkg/eventbus/events/billing_events.go
Normal file
14
pkg/eventbus/events/billing_events.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package events
|
||||
|
||||
// SubscriptionChanged is published by billing whenever applyStripeEvent
|
||||
// (now billing's internal/application use case of the same name)
|
||||
// commits a plan/status change from a Stripe webhook. No consumer in
|
||||
// this port of the monolith needs it synchronously — it's published so
|
||||
// a future consumer (e.g. a usage-analytics or dunning-email service)
|
||||
// can react without billing needing to know about it.
|
||||
type SubscriptionChanged struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Plan string `json:"plan"`
|
||||
Status string `json:"status"`
|
||||
EventType string `json:"stripe_event_type"` // e.g. "customer.subscription.updated"
|
||||
}
|
||||
12
pkg/eventbus/events/conversation_events.go
Normal file
12
pkg/eventbus/events/conversation_events.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package events
|
||||
|
||||
// MessageCreated is published by conversation on every db.AddMessage
|
||||
// equivalent. Unused by any consumer in this port — present so the
|
||||
// pattern (every aggregate commit gets an event, even before a consumer
|
||||
// exists for it) is consistent across contexts; see
|
||||
// pkg/eventbus.RoutingKeyMessageCreated.
|
||||
type MessageCreated struct {
|
||||
ConversationID int64 `json:"conversation_id"`
|
||||
MessageID int64 `json:"message_id"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
52
pkg/eventbus/events/iam_events.go
Normal file
52
pkg/eventbus/events/iam_events.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Package events holds the JSON payload shape for every event listed in
|
||||
// pkg/eventbus's routing-key constants. Each type is versioned
|
||||
// implicitly by its routing key (a breaking change gets a new key, e.g.
|
||||
// "iam.user.registered.v2") rather than by a field in the struct, to
|
||||
// keep consumers' decoding code simple.
|
||||
package events
|
||||
|
||||
import "time"
|
||||
|
||||
// VerificationCodeIssued is published by iam whenever a 6-digit email
|
||||
// verification code is minted — at signup (Handlers.Signup) and on
|
||||
// resend (Handlers.ResendVerification). The notification service is its
|
||||
// only consumer today (pkg/eventbus.RoutingKeyVerificationCodeIssued).
|
||||
//
|
||||
// Carrying the raw code on the bus (rather than just a code ID) is a
|
||||
// deliberate, documented trade-off: it lets the email's content stay
|
||||
// exactly what the monolith sent without notification needing its own
|
||||
// read access to iam's verification_codes table (which would violate
|
||||
// "each service owns its own tables"). This is safe only because
|
||||
// RabbitMQ sits on the private service network, never reachable from
|
||||
// outside the cluster — see ARCHITECTURE.md's "Trust boundary of the
|
||||
// event bus" note.
|
||||
type VerificationCodeIssued struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Code string `json:"code"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// PasswordResetRequested is published by iam from Handlers.ForgotPassword.
|
||||
type PasswordResetRequested struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
ResetLink string `json:"reset_link"` // already absolute (BaseURL + path + token), matching h.absURL
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// UserRegistered is published once a new account row is committed
|
||||
// (before email verification). Reserved for consumers that care about
|
||||
// the account existing at all rather than about delivering mail — e.g.
|
||||
// an analytics or growth service added later.
|
||||
type UserRegistered struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// UserEmailVerified is published when VerifyEmailCode succeeds.
|
||||
type UserEmailVerified struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
63
pkg/eventbus/topology.go
Normal file
63
pkg/eventbus/topology.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// Package eventbus is enkly's thin wrapper around RabbitMQ (via
|
||||
// github.com/rabbitmq/amqp091-go) for every *asynchronous*,
|
||||
// fire-and-forget cross-context notification — the counterpart to the
|
||||
// synchronous, request/response calls services make over gRPC (see
|
||||
// api/proto). Per-service request/response traffic (e.g. the agent
|
||||
// service asking billing "is this user over quota?") always uses gRPC;
|
||||
// RabbitMQ is reserved for events a publisher doesn't need an answer to,
|
||||
// mirroring the rule stated in the task: "gRPC for synchronous
|
||||
// communication, RabbitMQ for asynchronous communication."
|
||||
//
|
||||
// Topology: one durable topic exchange, "enkly.events". Each event is
|
||||
// published with a routing key of the form "<context>.<aggregate>.
|
||||
// <event>" (e.g. "iam.user.registered"); a consumer binds a durable
|
||||
// queue to the routing keys it cares about. Using a topic exchange
|
||||
// rather than one queue per consumer lets a new consumer be added later
|
||||
// (e.g. an analytics service) without touching the publisher.
|
||||
package eventbus
|
||||
|
||||
// ExchangeName is the single durable topic exchange every service
|
||||
// publishes domain events to.
|
||||
const ExchangeName = "enkly.events"
|
||||
|
||||
// Routing keys. Each constant is "<owning context>.<aggregate>.<event,
|
||||
// past tense>" — only the owning bounded context ever publishes to its
|
||||
// own keys (see each service's internal/infrastructure/eventing
|
||||
// package), but any service may subscribe.
|
||||
const (
|
||||
// Published by iam (services/iam) — see internal/handlers/auth.go's
|
||||
// sendVerificationEmail/sendResetEmail, now decoupled from the
|
||||
// synchronous request path.
|
||||
RoutingKeyVerificationCodeIssued = "iam.user.verification_code_issued"
|
||||
RoutingKeyPasswordResetRequested = "iam.user.password_reset_requested"
|
||||
RoutingKeyUserRegistered = "iam.user.registered"
|
||||
RoutingKeyUserEmailVerified = "iam.user.email_verified"
|
||||
|
||||
// Published by billing (services/billing).
|
||||
RoutingKeySubscriptionChanged = "billing.subscription.changed"
|
||||
|
||||
// Published by conversation (services/conversation) — not consumed
|
||||
// by anything in this port of the monolith, but kept so a future
|
||||
// consumer (search indexing, analytics, audit) has something to
|
||||
// bind to without a producer-side change.
|
||||
RoutingKeyMessageCreated = "conversation.message.created"
|
||||
)
|
||||
|
||||
// QueueBinding names the durable queue a consumer should declare and
|
||||
// the routing key(s) it should bind, so every consumer's topology is
|
||||
// declared in one place next to the keys it reads.
|
||||
type QueueBinding struct {
|
||||
Queue string
|
||||
RoutingKeys []string
|
||||
}
|
||||
|
||||
// NotificationQueueBindings is the notification service's subscription
|
||||
// (services/notification/internal/infrastructure/eventing) — every
|
||||
// event that results in an outbound email.
|
||||
var NotificationQueueBindings = QueueBinding{
|
||||
Queue: "enkly.notification.verification_and_reset",
|
||||
RoutingKeys: []string{
|
||||
RoutingKeyVerificationCodeIssued,
|
||||
RoutingKeyPasswordResetRequested,
|
||||
},
|
||||
}
|
||||
24
pkg/grpcerr/grpcerr.go
Normal file
24
pkg/grpcerr/grpcerr.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Package grpcerr has the handful of status-coded error constructors
|
||||
// every service's interfaces/grpc package uses, so "not found" maps to
|
||||
// codes.NotFound the same way in all eight of them instead of each
|
||||
// service inventing its own convention.
|
||||
package grpcerr
|
||||
|
||||
import (
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func NotFound(what string) error { return status.Errorf(codes.NotFound, "%s not found", what) }
|
||||
func InvalidArgument(msg string) error { return status.Error(codes.InvalidArgument, msg) }
|
||||
func FailedPrecondition(msg string) error { return status.Error(codes.FailedPrecondition, msg) }
|
||||
func ResourceExhausted(msg string) error { return status.Error(codes.ResourceExhausted, msg) }
|
||||
func Internal(err error) error { return status.Error(codes.Internal, err.Error()) }
|
||||
func Unauthenticated(msg string) error { return status.Error(codes.Unauthenticated, msg) }
|
||||
|
||||
// Unavailable maps a dependency that is reachable-in-principle but not
|
||||
// reachable right now (a vLLM deployment stuck Pending/restarting, a
|
||||
// downstream service mid-rollout) onto codes.Unavailable instead of
|
||||
// codes.Internal — gRPC clients and well-behaved gateways treat
|
||||
// Unavailable as safe-to-retry, which an opaque Internal never is.
|
||||
func Unavailable(err error) error { return status.Error(codes.Unavailable, err.Error()) }
|
||||
26
pkg/i18n/embed.go
Normal file
26
pkg/i18n/embed.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
//go:embed locales/*.json
|
||||
var localeFiles embed.FS
|
||||
|
||||
// catalogFS is the raw JSON for each locale in Supported, read once at
|
||||
// package init time (see i18n.go's init). Keeping the embed.FS read
|
||||
// here, separate from the JSON-decoding in init, means adding a
|
||||
// locale is purely data: list it in Supported above, drop its file
|
||||
// under locales/, and this loop picks it up — no new Go code.
|
||||
var catalogFS = func() map[Locale][]byte {
|
||||
out := make(map[Locale][]byte, len(Supported))
|
||||
for _, info := range Supported {
|
||||
data, err := localeFiles.ReadFile(fmt.Sprintf("locales/%s.json", info.Code))
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("i18n: locale %q listed in Supported but locales/%s.json is missing: %v", info.Code, info.Code, err))
|
||||
}
|
||||
out[info.Code] = data
|
||||
}
|
||||
return out
|
||||
}()
|
||||
166
pkg/i18n/i18n.go
Normal file
166
pkg/i18n/i18n.go
Normal file
@@ -0,0 +1,166 @@
|
||||
// Package i18n is enkly's shared translation catalog: the single
|
||||
// source of truth, for every service, of which UI languages exist and
|
||||
// what their strings are. It is deliberately tiny and dependency-free
|
||||
// (encoding/json + embed, nothing else) so both the gateway (which
|
||||
// renders the HTML chrome a visitor reads) and the agent service
|
||||
// (which needs the same locale codes and display names to tell the
|
||||
// model which language the visitor has chosen — see
|
||||
// agentrun.WithPreferredLanguage) can import it without pulling either
|
||||
// service into the other's build.
|
||||
//
|
||||
// This is UI-chrome localization — buttons, labels, system-generated
|
||||
// notices ("you've reached today's message limit") — not a substitute
|
||||
// for the agent's own language behavior: the model already detects
|
||||
// and mirrors whatever language a user types in conversation (see
|
||||
// agentrun.SystemPrompt's "## Language" section). The two compose: a
|
||||
// visitor who has never touched the language switcher still gets
|
||||
// replies in their own language because the model notices; a visitor
|
||||
// who *has* set a UI language gets the chrome in that language too,
|
||||
// and the agent treats it as their standing preference (see
|
||||
// agentrun.WithPreferredLanguage's doc comment for exactly how the
|
||||
// two signals are reconciled when they disagree).
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Locale is a BCP-47-ish language tag — in practice just the short
|
||||
// codes listed in Supported below ("en", "fr", …). It's a defined
|
||||
// type rather than a bare string so a caller can't accidentally pass
|
||||
// a translation key where a locale was expected, or vice versa.
|
||||
type Locale string
|
||||
|
||||
// Default is used whenever no locale can be determined at all (no
|
||||
// cookie, no Accept-Language header, no account preference) and as
|
||||
// the fallback catalog for any key missing from a more specific
|
||||
// locale's translation file.
|
||||
const Default Locale = "en"
|
||||
|
||||
// Info is one supported locale's code and its own display name (in
|
||||
// that language, not in the viewer's — "Français", not "French" —
|
||||
// which is how every language picker from a browser's own settings to
|
||||
// Claude Code's `/language` command does it).
|
||||
type Info struct {
|
||||
Code Locale
|
||||
Name string
|
||||
}
|
||||
|
||||
// Supported lists every locale enkly ships a catalog for, in the
|
||||
// order a language switcher should present them. Adding a language is
|
||||
// a two-line change: a new entry here, naming the file added under
|
||||
// locales/, and the entry in catalogs below the embed directive.
|
||||
//
|
||||
// enkly's first deployments are French West African (see the FCFA
|
||||
// pricing in the billing service), so French ships complete; the rest
|
||||
// cover enough of the chrome (nav, composer, auth, errors) to be
|
||||
// usable, falling back to English for anything not yet translated —
|
||||
// see T's doc comment. Extending any of them to full coverage is a
|
||||
// matter of filling in locales/<code>.json, not touching any Go code.
|
||||
var Supported = []Info{
|
||||
{Code: "en", Name: "English"},
|
||||
{Code: "fr", Name: "Français"},
|
||||
{Code: "es", Name: "Español"},
|
||||
{Code: "de", Name: "Deutsch"},
|
||||
{Code: "pt", Name: "Português"},
|
||||
}
|
||||
|
||||
// IsSupported reports whether code names a locale enkly ships a
|
||||
// catalog for.
|
||||
func IsSupported(code string) bool {
|
||||
for _, info := range Supported {
|
||||
if string(info.Code) == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ParseLocale returns code as a Locale if it's one enkly supports,
|
||||
// else Default. Use this anywhere an untrusted string (a cookie
|
||||
// value, a query parameter, one tag out of an Accept-Language header)
|
||||
// needs to become a Locale — it never returns an unsupported value.
|
||||
func ParseLocale(code string) Locale {
|
||||
if IsSupported(code) {
|
||||
return Locale(code)
|
||||
}
|
||||
return Default
|
||||
}
|
||||
|
||||
// NameOf returns a locale's own display name ("Français" for "fr"),
|
||||
// or its bare code if it isn't one of Supported (shouldn't happen for
|
||||
// a Locale that came from ParseLocale, but T and callers that build a
|
||||
// prompt string from a Locale value want something printable either
|
||||
// way).
|
||||
func NameOf(l Locale) string {
|
||||
for _, info := range Supported {
|
||||
if info.Code == l {
|
||||
return info.Name
|
||||
}
|
||||
}
|
||||
return string(l)
|
||||
}
|
||||
|
||||
var (
|
||||
mu sync.RWMutex
|
||||
missLog = map[string]bool{} // "locale/key" already warned about, so a hot template doesn't spam the log
|
||||
logger = slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||
catalogs map[Locale]map[string]string
|
||||
)
|
||||
|
||||
func init() {
|
||||
catalogs = make(map[Locale]map[string]string, len(catalogFS))
|
||||
for locale, raw := range catalogFS {
|
||||
var strings map[string]string
|
||||
if err := json.Unmarshal(raw, &strings); err != nil {
|
||||
// A malformed embedded catalog is a build-time mistake, not
|
||||
// a runtime condition any caller can recover from — fail
|
||||
// loudly at process start rather than silently serving
|
||||
// blank strings in production.
|
||||
panic(fmt.Sprintf("i18n: locale %q: invalid catalog JSON: %v", locale, err))
|
||||
}
|
||||
catalogs[locale] = strings
|
||||
}
|
||||
}
|
||||
|
||||
// T looks up key in locale's catalog and, if args is non-empty, runs
|
||||
// the result through fmt.Sprintf (so a catalog entry can contain %s /
|
||||
// %d verbs the same way Go source would). A key missing from locale
|
||||
// falls back to the Default catalog, and a key missing from Default
|
||||
// too falls back to the key itself — so a template that references a
|
||||
// not-yet-translated string degrades to showing its own key (an
|
||||
// unmistakable "translate me" placeholder during development) instead
|
||||
// of a blank space or a panic in production. Each distinct miss is
|
||||
// logged once per process, not once per request.
|
||||
func T(locale Locale, key string, args ...any) string {
|
||||
mu.RLock()
|
||||
s, ok := catalogs[locale][key]
|
||||
if !ok {
|
||||
s, ok = catalogs[Default][key]
|
||||
}
|
||||
mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
warnMissing(locale, key)
|
||||
s = key
|
||||
}
|
||||
if len(args) == 0 {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprintf(s, args...)
|
||||
}
|
||||
|
||||
func warnMissing(locale Locale, key string) {
|
||||
id := string(locale) + "/" + key
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if missLog[id] {
|
||||
return
|
||||
}
|
||||
missLog[id] = true
|
||||
logger.Warn("i18n: missing translation key", "locale", locale, "key", key)
|
||||
}
|
||||
54
pkg/i18n/i18n_test.go
Normal file
54
pkg/i18n/i18n_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package i18n
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSupportedLocalesLoad(t *testing.T) {
|
||||
for _, info := range Supported {
|
||||
cat, ok := catalogs[info.Code]
|
||||
if !ok || len(cat) == 0 {
|
||||
t.Fatalf("locale %q: catalog missing or empty", info.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnglishHasEveryKeyUsedElsewhere(t *testing.T) {
|
||||
// English is the fallback catalog (see T's doc comment), so every
|
||||
// key any other locale defines must also exist in English — a
|
||||
// locale-only key would be silently unreachable from the fallback
|
||||
// path and is almost certainly a typo.
|
||||
en := catalogs[Default]
|
||||
for _, info := range Supported {
|
||||
if info.Code == Default {
|
||||
continue
|
||||
}
|
||||
for key := range catalogs[info.Code] {
|
||||
if _, ok := en[key]; !ok {
|
||||
t.Errorf("locale %q defines key %q that is missing from %q (the fallback catalog)", info.Code, key, Default)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTFallsBackToEnglishThenToKey(t *testing.T) {
|
||||
if got := T("fr", "nav.new_chat"); got != "Nouvelle discussion" {
|
||||
t.Errorf("fr nav.new_chat = %q", got)
|
||||
}
|
||||
// "nav.delete" only exists in a handful of locales' full set; pick
|
||||
// a locale/key combination guaranteed to be English-only to prove
|
||||
// the fallback path, without hard-coding which keys those are.
|
||||
if got := T("es", "billing.quota_exceeded", "Pro", 200); got == "billing.quota_exceeded" {
|
||||
t.Errorf("expected fallback to the English catalog, got the raw key back")
|
||||
}
|
||||
if got := T(Default, "this.key.does.not.exist"); got != "this.key.does.not.exist" {
|
||||
t.Errorf("unknown key should fall back to itself, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLocale(t *testing.T) {
|
||||
if got := ParseLocale("fr"); got != "fr" {
|
||||
t.Errorf("ParseLocale(fr) = %q", got)
|
||||
}
|
||||
if got := ParseLocale("xx-not-a-locale"); got != Default {
|
||||
t.Errorf("ParseLocale(unsupported) = %q, want Default", got)
|
||||
}
|
||||
}
|
||||
74
pkg/i18n/locales/de.json
Normal file
74
pkg/i18n/locales/de.json
Normal file
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"nav.new_chat": "Neuer Chat",
|
||||
"nav.conversations": "Unterhaltungen",
|
||||
"nav.no_conversations": "Noch keine Unterhaltungen.",
|
||||
"nav.plan_suffix": "Tarif",
|
||||
"nav.manage": "Verwalten",
|
||||
"nav.log_out": "Abmelden",
|
||||
"nav.theme_light": "Hell",
|
||||
"nav.theme_dark": "Dunkel",
|
||||
"nav.theme_auto": "Automatisch",
|
||||
"nav.language": "Sprache",
|
||||
"nav.search_placeholder": "Chats, Dateien, Aktionen durchsuchen…",
|
||||
"nav.agent_ready": "Agent bereit",
|
||||
"nav.rename_save": "Namen speichern",
|
||||
"nav.delete": "Löschen",
|
||||
"nav.close_sidebar": "Seitenleiste schließen",
|
||||
"nav.open_sidebar": "Seitenleiste öffnen",
|
||||
|
||||
"hero.title": "Was bauen wir heute?",
|
||||
"hero.subtitle": "enkly arbeitet direkt in deinem Arbeitsbereich: liest, durchsucht, bearbeitet und führt Code aus und zeigt jeden Schritt.",
|
||||
"hero.start_with": "Beginne mit",
|
||||
"hero.tools_heading": "Was enkly nutzen kann",
|
||||
"hero.explore_heading": "Mehr entdecken",
|
||||
|
||||
"starter.tour_title": "Arbeitsbereich erkunden",
|
||||
"starter.tour_desc": "Ordner, Dienste und Einstiegspunkte kartieren.",
|
||||
"starter.trace_title": "Eine Funktion verfolgen",
|
||||
"starter.trace_desc": "Eine Anfrage durch den Code verfolgen.",
|
||||
"starter.fix_title": "Einen Fehler beheben",
|
||||
"starter.fix_desc": "Beschreibe ihn, enkly findet und behebt ihn.",
|
||||
"starter.tests_title": "Tests ausführen",
|
||||
"starter.tests_desc": "Testsuite ausführen und Fehlschläge erklären.",
|
||||
|
||||
"tool.read": "Dateien lesen",
|
||||
"tool.write": "Schreiben und bearbeiten",
|
||||
"tool.search": "Codebasis durchsuchen",
|
||||
"tool.run": "Erlaubte Befehle ausführen",
|
||||
"tool.subagents": "Subagenten",
|
||||
|
||||
"explore.tests": "Tests schreiben für…",
|
||||
"explore.review": "Code überprüfen",
|
||||
"explore.explain": "Eine Datei erklären",
|
||||
"explore.document": "Ein Dokument entwerfen",
|
||||
"explore.refactor": "Refaktorisieren",
|
||||
|
||||
"composer.placeholder": "Bitte enkly, etwas zu bauen, zu reparieren oder zu erklären…",
|
||||
"composer.send": "Senden",
|
||||
"composer.attach": "Dateien oder Fotos hinzufügen",
|
||||
"composer.hint_send": "senden",
|
||||
"composer.hint_newline": "neue Zeile",
|
||||
"composer.hint_drop": "Dateien ablegen, Screenshots einfügen oder ein .zip-Projekt hinzufügen",
|
||||
|
||||
"auth.login_title": "Anmelden",
|
||||
"auth.login_button": "Anmelden",
|
||||
"auth.email_label": "E-Mail",
|
||||
"auth.password_label": "Passwort",
|
||||
"auth.confirm_password_label": "Passwort bestätigen",
|
||||
"auth.no_account": "Noch kein Konto?",
|
||||
"auth.signup_link": "Registrieren",
|
||||
"auth.signup_title": "Konto erstellen",
|
||||
"auth.signup_button": "Registrieren",
|
||||
"auth.have_account": "Bereits ein Konto?",
|
||||
"auth.login_link": "Anmelden",
|
||||
"auth.forgot_link": "Passwort vergessen?",
|
||||
"auth.verify_title": "E-Mail bestätigen",
|
||||
"auth.verify_code_label": "6-stelliger Code",
|
||||
"auth.verify_button": "Bestätigen",
|
||||
"auth.resend_link": "Code erneut senden",
|
||||
"auth.forgot_title": "Passwort zurücksetzen",
|
||||
"auth.forgot_button": "Link zum Zurücksetzen senden",
|
||||
"auth.back_to_login": "Zurück zur Anmeldung",
|
||||
"auth.reset_title": "Neues Passwort wählen",
|
||||
"auth.reset_button": "Passwort zurücksetzen"
|
||||
}
|
||||
104
pkg/i18n/locales/en.json
Normal file
104
pkg/i18n/locales/en.json
Normal file
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"nav.new_chat": "New chat",
|
||||
"nav.conversations": "Conversations",
|
||||
"nav.no_conversations": "No conversations yet.",
|
||||
"nav.plan_suffix": "plan",
|
||||
"nav.manage": "Manage",
|
||||
"nav.log_out": "Log out",
|
||||
"nav.theme_light": "Light",
|
||||
"nav.theme_dark": "Dark",
|
||||
"nav.theme_auto": "Auto",
|
||||
"nav.language": "Language",
|
||||
"nav.search_placeholder": "Search chats, files, actions…",
|
||||
"nav.agent_ready": "Agent ready",
|
||||
"nav.rename_save": "Save name",
|
||||
"nav.delete": "Delete",
|
||||
"nav.close_sidebar": "Close sidebar",
|
||||
"nav.open_sidebar": "Open sidebar",
|
||||
|
||||
"hero.title": "What should we build today?",
|
||||
"hero.subtitle": "enkly works inside your workspace: it reads, searches, edits and runs code, then shows every step.",
|
||||
"hero.start_with": "Start with",
|
||||
"hero.tools_heading": "What enkly can use",
|
||||
"hero.explore_heading": "More to explore",
|
||||
|
||||
"starter.tour_title": "Tour this workspace",
|
||||
"starter.tour_desc": "Map the folders, services and entry points.",
|
||||
"starter.trace_title": "Trace a feature",
|
||||
"starter.trace_desc": "Follow a request through the codebase.",
|
||||
"starter.fix_title": "Fix a bug",
|
||||
"starter.fix_desc": "Describe it, enkly finds and patches it.",
|
||||
"starter.tests_title": "Run the tests",
|
||||
"starter.tests_desc": "Execute the suite and explain what broke.",
|
||||
|
||||
"tool.read": "Read files",
|
||||
"tool.write": "Write and edit",
|
||||
"tool.search": "Search the codebase",
|
||||
"tool.run": "Run allowlisted commands",
|
||||
"tool.subagents": "Sub-agents",
|
||||
|
||||
"explore.tests": "Write tests for…",
|
||||
"explore.review": "Review code",
|
||||
"explore.explain": "Explain a file",
|
||||
"explore.document": "Draft a document",
|
||||
"explore.refactor": "Refactor",
|
||||
|
||||
"composer.placeholder": "Ask enkly to build, fix, or explain anything…",
|
||||
"composer.send": "Send",
|
||||
"composer.attach": "Add files or photos",
|
||||
"composer.hint_send": "send",
|
||||
"composer.hint_newline": "new line",
|
||||
"composer.hint_drop": "Drop files, paste screenshots or add a .zip project",
|
||||
|
||||
"auth.login_title": "Log in",
|
||||
"auth.login_button": "Log in",
|
||||
"auth.email_label": "Email",
|
||||
"auth.password_label": "Password",
|
||||
"auth.confirm_password_label": "Confirm password",
|
||||
"auth.no_account": "Don't have an account?",
|
||||
"auth.signup_link": "Sign up",
|
||||
"auth.signup_title": "Create your account",
|
||||
"auth.signup_button": "Sign up",
|
||||
"auth.have_account": "Already have an account?",
|
||||
"auth.login_link": "Log in",
|
||||
"auth.forgot_link": "Forgot your password?",
|
||||
"auth.verify_title": "Verify your email",
|
||||
"auth.verify_code_label": "6-digit code",
|
||||
"auth.verify_button": "Verify",
|
||||
"auth.resend_link": "Resend code",
|
||||
"auth.forgot_title": "Reset your password",
|
||||
"auth.forgot_button": "Send reset link",
|
||||
"auth.back_to_login": "Back to log in",
|
||||
"auth.reset_title": "Choose a new password",
|
||||
"auth.reset_button": "Reset password",
|
||||
|
||||
"errors.generic": "Something went wrong. Please try again.",
|
||||
"errors.invalid_email": "Enter a valid email address.",
|
||||
"errors.password_min": "Password must be at least %d characters.",
|
||||
"errors.password_max": "Password must be at most %d characters.",
|
||||
"errors.password_mismatch": "Passwords don't match.",
|
||||
"errors.incorrect_credentials": "Incorrect email or password.",
|
||||
"errors.invalid_code": "Enter the 6-digit code.",
|
||||
"errors.code_incorrect": "That code is incorrect or has expired.",
|
||||
"errors.reset_link_invalid": "That reset link is invalid or has expired. Request a new one.",
|
||||
"errors.csrf": "Invalid or missing security token — reload the page and try again.",
|
||||
|
||||
"info.check_inbox": "Check your inbox for a 6-digit verification code.",
|
||||
"info.mail_not_configured": "Email isn't configured on this server — check the server log for your verification code.",
|
||||
"info.resend_sent": "If that account needs verifying, a new code is on its way.",
|
||||
"info.email_verified": "Your email is verified — log in to continue.",
|
||||
"info.password_reset_done": "Your password has been reset — log in with your new password.",
|
||||
"info.forgot_sent": "If that address has an account, a reset link is on its way.",
|
||||
|
||||
"chat.type_message_first": "Type a message first.",
|
||||
"chat.message_too_long": "That message is too long (limit %d characters).",
|
||||
"chat.rate_limited": "Too many requests — slow down a little.",
|
||||
"chat.conversation_gone": "That conversation no longer exists. Start a new chat.",
|
||||
"chat.conversation_busy": "enkly is still replying in this conversation — wait for it to finish, or press Stop.",
|
||||
"chat.too_many_streams": "You have several replies in progress already. Wait for one to finish or press Stop.",
|
||||
"chat.save_failed": "Couldn't save your message. Please try again.",
|
||||
"chat.load_failed": "Couldn't load the conversation. Please try again.",
|
||||
"chat.send_failed": "Something went wrong. Please try again.",
|
||||
|
||||
"billing.quota_exceeded": "You've reached the %s plan's limit of %d messages today. Upgrade on the Subscription page for more."
|
||||
}
|
||||
74
pkg/i18n/locales/es.json
Normal file
74
pkg/i18n/locales/es.json
Normal file
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"nav.new_chat": "Nueva conversación",
|
||||
"nav.conversations": "Conversaciones",
|
||||
"nav.no_conversations": "Aún no hay conversaciones.",
|
||||
"nav.plan_suffix": "plan",
|
||||
"nav.manage": "Gestionar",
|
||||
"nav.log_out": "Cerrar sesión",
|
||||
"nav.theme_light": "Claro",
|
||||
"nav.theme_dark": "Oscuro",
|
||||
"nav.theme_auto": "Auto",
|
||||
"nav.language": "Idioma",
|
||||
"nav.search_placeholder": "Buscar chats, archivos, acciones…",
|
||||
"nav.agent_ready": "Agente listo",
|
||||
"nav.rename_save": "Guardar nombre",
|
||||
"nav.delete": "Eliminar",
|
||||
"nav.close_sidebar": "Cerrar barra lateral",
|
||||
"nav.open_sidebar": "Abrir barra lateral",
|
||||
|
||||
"hero.title": "¿Qué construimos hoy?",
|
||||
"hero.subtitle": "enkly trabaja dentro de tu espacio de trabajo: lee, busca, edita y ejecuta código, y muestra cada paso.",
|
||||
"hero.start_with": "Empieza con",
|
||||
"hero.tools_heading": "Lo que enkly puede usar",
|
||||
"hero.explore_heading": "Más para explorar",
|
||||
|
||||
"starter.tour_title": "Recorrer este espacio de trabajo",
|
||||
"starter.tour_desc": "Mapea las carpetas, servicios y puntos de entrada.",
|
||||
"starter.trace_title": "Rastrear una funcionalidad",
|
||||
"starter.trace_desc": "Sigue una solicitud a través del código.",
|
||||
"starter.fix_title": "Corregir un error",
|
||||
"starter.fix_desc": "Descríbelo y enkly lo encuentra y lo corrige.",
|
||||
"starter.tests_title": "Ejecutar las pruebas",
|
||||
"starter.tests_desc": "Ejecuta la suite y explica qué falló.",
|
||||
|
||||
"tool.read": "Leer archivos",
|
||||
"tool.write": "Escribir y editar",
|
||||
"tool.search": "Buscar en el código",
|
||||
"tool.run": "Ejecutar comandos permitidos",
|
||||
"tool.subagents": "Subagentes",
|
||||
|
||||
"explore.tests": "Escribir pruebas para…",
|
||||
"explore.review": "Revisar código",
|
||||
"explore.explain": "Explicar un archivo",
|
||||
"explore.document": "Redactar un documento",
|
||||
"explore.refactor": "Refactorizar",
|
||||
|
||||
"composer.placeholder": "Pide a enkly que construya, corrija o explique cualquier cosa…",
|
||||
"composer.send": "Enviar",
|
||||
"composer.attach": "Añadir archivos o fotos",
|
||||
"composer.hint_send": "enviar",
|
||||
"composer.hint_newline": "nueva línea",
|
||||
"composer.hint_drop": "Suelta archivos, pega capturas de pantalla o añade un proyecto .zip",
|
||||
|
||||
"auth.login_title": "Iniciar sesión",
|
||||
"auth.login_button": "Iniciar sesión",
|
||||
"auth.email_label": "Correo electrónico",
|
||||
"auth.password_label": "Contraseña",
|
||||
"auth.confirm_password_label": "Confirmar contraseña",
|
||||
"auth.no_account": "¿No tienes una cuenta?",
|
||||
"auth.signup_link": "Regístrate",
|
||||
"auth.signup_title": "Crea tu cuenta",
|
||||
"auth.signup_button": "Registrarse",
|
||||
"auth.have_account": "¿Ya tienes una cuenta?",
|
||||
"auth.login_link": "Iniciar sesión",
|
||||
"auth.forgot_link": "¿Olvidaste tu contraseña?",
|
||||
"auth.verify_title": "Verifica tu correo",
|
||||
"auth.verify_code_label": "Código de 6 dígitos",
|
||||
"auth.verify_button": "Verificar",
|
||||
"auth.resend_link": "Reenviar código",
|
||||
"auth.forgot_title": "Restablece tu contraseña",
|
||||
"auth.forgot_button": "Enviar enlace de restablecimiento",
|
||||
"auth.back_to_login": "Volver a iniciar sesión",
|
||||
"auth.reset_title": "Elige una nueva contraseña",
|
||||
"auth.reset_button": "Restablecer contraseña"
|
||||
}
|
||||
104
pkg/i18n/locales/fr.json
Normal file
104
pkg/i18n/locales/fr.json
Normal file
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"nav.new_chat": "Nouvelle discussion",
|
||||
"nav.conversations": "Conversations",
|
||||
"nav.no_conversations": "Aucune conversation pour l'instant.",
|
||||
"nav.plan_suffix": "forfait",
|
||||
"nav.manage": "Gérer",
|
||||
"nav.log_out": "Déconnexion",
|
||||
"nav.theme_light": "Clair",
|
||||
"nav.theme_dark": "Sombre",
|
||||
"nav.theme_auto": "Auto",
|
||||
"nav.language": "Langue",
|
||||
"nav.search_placeholder": "Rechercher des discussions, fichiers, actions…",
|
||||
"nav.agent_ready": "Agent prêt",
|
||||
"nav.rename_save": "Enregistrer le nom",
|
||||
"nav.delete": "Supprimer",
|
||||
"nav.close_sidebar": "Fermer la barre latérale",
|
||||
"nav.open_sidebar": "Ouvrir la barre latérale",
|
||||
|
||||
"hero.title": "Que devons-nous construire aujourd'hui ?",
|
||||
"hero.subtitle": "enkly travaille directement dans votre espace de travail : il lit, recherche, modifie et exécute du code, puis affiche chaque étape.",
|
||||
"hero.start_with": "Commencer par",
|
||||
"hero.tools_heading": "Ce qu'enkly peut utiliser",
|
||||
"hero.explore_heading": "À explorer",
|
||||
|
||||
"starter.tour_title": "Visiter cet espace de travail",
|
||||
"starter.tour_desc": "Cartographier les dossiers, services et points d'entrée.",
|
||||
"starter.trace_title": "Suivre une fonctionnalité",
|
||||
"starter.trace_desc": "Suivre une requête à travers le code.",
|
||||
"starter.fix_title": "Corriger un bug",
|
||||
"starter.fix_desc": "Décrivez-le, enkly le trouve et le corrige.",
|
||||
"starter.tests_title": "Lancer les tests",
|
||||
"starter.tests_desc": "Exécuter la suite de tests et expliquer les échecs.",
|
||||
|
||||
"tool.read": "Lire des fichiers",
|
||||
"tool.write": "Écrire et modifier",
|
||||
"tool.search": "Rechercher dans le code",
|
||||
"tool.run": "Exécuter des commandes autorisées",
|
||||
"tool.subagents": "Sous-agents",
|
||||
|
||||
"explore.tests": "Écrire des tests pour…",
|
||||
"explore.review": "Relire du code",
|
||||
"explore.explain": "Expliquer un fichier",
|
||||
"explore.document": "Rédiger un document",
|
||||
"explore.refactor": "Refactoriser",
|
||||
|
||||
"composer.placeholder": "Demandez à enkly de construire, corriger ou expliquer n'importe quoi…",
|
||||
"composer.send": "Envoyer",
|
||||
"composer.attach": "Ajouter des fichiers ou des photos",
|
||||
"composer.hint_send": "envoyer",
|
||||
"composer.hint_newline": "nouvelle ligne",
|
||||
"composer.hint_drop": "Déposez des fichiers, collez des captures d'écran ou ajoutez un projet .zip",
|
||||
|
||||
"auth.login_title": "Se connecter",
|
||||
"auth.login_button": "Se connecter",
|
||||
"auth.email_label": "E-mail",
|
||||
"auth.password_label": "Mot de passe",
|
||||
"auth.confirm_password_label": "Confirmer le mot de passe",
|
||||
"auth.no_account": "Vous n'avez pas de compte ?",
|
||||
"auth.signup_link": "S'inscrire",
|
||||
"auth.signup_title": "Créer votre compte",
|
||||
"auth.signup_button": "S'inscrire",
|
||||
"auth.have_account": "Vous avez déjà un compte ?",
|
||||
"auth.login_link": "Se connecter",
|
||||
"auth.forgot_link": "Mot de passe oublié ?",
|
||||
"auth.verify_title": "Vérifiez votre e-mail",
|
||||
"auth.verify_code_label": "Code à 6 chiffres",
|
||||
"auth.verify_button": "Vérifier",
|
||||
"auth.resend_link": "Renvoyer le code",
|
||||
"auth.forgot_title": "Réinitialiser votre mot de passe",
|
||||
"auth.forgot_button": "Envoyer le lien de réinitialisation",
|
||||
"auth.back_to_login": "Retour à la connexion",
|
||||
"auth.reset_title": "Choisissez un nouveau mot de passe",
|
||||
"auth.reset_button": "Réinitialiser le mot de passe",
|
||||
|
||||
"errors.generic": "Une erreur est survenue. Veuillez réessayer.",
|
||||
"errors.invalid_email": "Saisissez une adresse e-mail valide.",
|
||||
"errors.password_min": "Le mot de passe doit comporter au moins %d caractères.",
|
||||
"errors.password_max": "Le mot de passe doit comporter au plus %d caractères.",
|
||||
"errors.password_mismatch": "Les mots de passe ne correspondent pas.",
|
||||
"errors.incorrect_credentials": "E-mail ou mot de passe incorrect.",
|
||||
"errors.invalid_code": "Saisissez le code à 6 chiffres.",
|
||||
"errors.code_incorrect": "Ce code est incorrect ou a expiré.",
|
||||
"errors.reset_link_invalid": "Ce lien de réinitialisation est invalide ou a expiré. Demandez-en un nouveau.",
|
||||
"errors.csrf": "Jeton de sécurité invalide ou manquant — rechargez la page et réessayez.",
|
||||
|
||||
"info.check_inbox": "Consultez votre boîte de réception pour le code de vérification à 6 chiffres.",
|
||||
"info.mail_not_configured": "La messagerie n'est pas configurée sur ce serveur — consultez le journal du serveur pour votre code de vérification.",
|
||||
"info.resend_sent": "Si ce compte doit être vérifié, un nouveau code est en route.",
|
||||
"info.email_verified": "Votre e-mail est vérifié — connectez-vous pour continuer.",
|
||||
"info.password_reset_done": "Votre mot de passe a été réinitialisé — connectez-vous avec votre nouveau mot de passe.",
|
||||
"info.forgot_sent": "Si cette adresse possède un compte, un lien de réinitialisation est en route.",
|
||||
|
||||
"chat.type_message_first": "Saisissez d'abord un message.",
|
||||
"chat.message_too_long": "Ce message est trop long (limite de %d caractères).",
|
||||
"chat.rate_limited": "Trop de requêtes — ralentissez un peu.",
|
||||
"chat.conversation_gone": "Cette conversation n'existe plus. Démarrez une nouvelle discussion.",
|
||||
"chat.conversation_busy": "enkly est encore en train de répondre dans cette conversation — attendez la fin, ou appuyez sur Arrêter.",
|
||||
"chat.too_many_streams": "Vous avez déjà plusieurs réponses en cours. Attendez qu'une se termine ou appuyez sur Arrêter.",
|
||||
"chat.save_failed": "Impossible d'enregistrer votre message. Veuillez réessayer.",
|
||||
"chat.load_failed": "Impossible de charger la conversation. Veuillez réessayer.",
|
||||
"chat.send_failed": "Une erreur est survenue. Veuillez réessayer.",
|
||||
|
||||
"billing.quota_exceeded": "Vous avez atteint la limite du forfait %s : %d messages par jour. Passez à un forfait supérieur depuis la page Abonnement pour en obtenir plus."
|
||||
}
|
||||
74
pkg/i18n/locales/pt.json
Normal file
74
pkg/i18n/locales/pt.json
Normal file
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"nav.new_chat": "Nova conversa",
|
||||
"nav.conversations": "Conversas",
|
||||
"nav.no_conversations": "Ainda não há conversas.",
|
||||
"nav.plan_suffix": "plano",
|
||||
"nav.manage": "Gerenciar",
|
||||
"nav.log_out": "Sair",
|
||||
"nav.theme_light": "Claro",
|
||||
"nav.theme_dark": "Escuro",
|
||||
"nav.theme_auto": "Automático",
|
||||
"nav.language": "Idioma",
|
||||
"nav.search_placeholder": "Pesquisar conversas, arquivos, ações…",
|
||||
"nav.agent_ready": "Agente pronto",
|
||||
"nav.rename_save": "Salvar nome",
|
||||
"nav.delete": "Excluir",
|
||||
"nav.close_sidebar": "Fechar barra lateral",
|
||||
"nav.open_sidebar": "Abrir barra lateral",
|
||||
|
||||
"hero.title": "O que vamos construir hoje?",
|
||||
"hero.subtitle": "enkly trabalha dentro do seu espaço de trabalho: lê, pesquisa, edita e executa código, mostrando cada etapa.",
|
||||
"hero.start_with": "Comece com",
|
||||
"hero.tools_heading": "O que o enkly pode usar",
|
||||
"hero.explore_heading": "Mais para explorar",
|
||||
|
||||
"starter.tour_title": "Conhecer este espaço de trabalho",
|
||||
"starter.tour_desc": "Mapeie as pastas, serviços e pontos de entrada.",
|
||||
"starter.trace_title": "Rastrear uma funcionalidade",
|
||||
"starter.trace_desc": "Acompanhe uma requisição pelo código.",
|
||||
"starter.fix_title": "Corrigir um bug",
|
||||
"starter.fix_desc": "Descreva-o, o enkly encontra e corrige.",
|
||||
"starter.tests_title": "Executar os testes",
|
||||
"starter.tests_desc": "Execute a suíte e explique o que falhou.",
|
||||
|
||||
"tool.read": "Ler arquivos",
|
||||
"tool.write": "Escrever e editar",
|
||||
"tool.search": "Pesquisar no código",
|
||||
"tool.run": "Executar comandos permitidos",
|
||||
"tool.subagents": "Subagentes",
|
||||
|
||||
"explore.tests": "Escrever testes para…",
|
||||
"explore.review": "Revisar código",
|
||||
"explore.explain": "Explicar um arquivo",
|
||||
"explore.document": "Redigir um documento",
|
||||
"explore.refactor": "Refatorar",
|
||||
|
||||
"composer.placeholder": "Peça ao enkly para construir, corrigir ou explicar qualquer coisa…",
|
||||
"composer.send": "Enviar",
|
||||
"composer.attach": "Adicionar arquivos ou fotos",
|
||||
"composer.hint_send": "enviar",
|
||||
"composer.hint_newline": "nova linha",
|
||||
"composer.hint_drop": "Solte arquivos, cole capturas de tela ou adicione um projeto .zip",
|
||||
|
||||
"auth.login_title": "Entrar",
|
||||
"auth.login_button": "Entrar",
|
||||
"auth.email_label": "E-mail",
|
||||
"auth.password_label": "Senha",
|
||||
"auth.confirm_password_label": "Confirmar senha",
|
||||
"auth.no_account": "Não tem uma conta?",
|
||||
"auth.signup_link": "Cadastre-se",
|
||||
"auth.signup_title": "Crie sua conta",
|
||||
"auth.signup_button": "Cadastrar",
|
||||
"auth.have_account": "Já tem uma conta?",
|
||||
"auth.login_link": "Entrar",
|
||||
"auth.forgot_link": "Esqueceu sua senha?",
|
||||
"auth.verify_title": "Verifique seu e-mail",
|
||||
"auth.verify_code_label": "Código de 6 dígitos",
|
||||
"auth.verify_button": "Verificar",
|
||||
"auth.resend_link": "Reenviar código",
|
||||
"auth.forgot_title": "Redefinir sua senha",
|
||||
"auth.forgot_button": "Enviar link de redefinição",
|
||||
"auth.back_to_login": "Voltar para o login",
|
||||
"auth.reset_title": "Escolha uma nova senha",
|
||||
"auth.reset_button": "Redefinir senha"
|
||||
}
|
||||
64
pkg/i18n/negotiate.go
Normal file
64
pkg/i18n/negotiate.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// weightedTag is one entry of a parsed Accept-Language header.
|
||||
type weightedTag struct {
|
||||
primary string // "fr" out of "fr-FR"
|
||||
q float64
|
||||
}
|
||||
|
||||
// parseAcceptLanguage turns a raw "Accept-Language" header value (RFC
|
||||
// 9110 §12.5.4 — e.g. "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7") into its
|
||||
// tags' primary subtags, ordered by descending quality weight. A
|
||||
// malformed segment is skipped rather than aborting the whole parse —
|
||||
// browsers have been known to send odd values, and the worst case of
|
||||
// ignoring one bad segment is falling back to Default, never a panic
|
||||
// or a rejected request.
|
||||
func parseAcceptLanguage(header string) []weightedTag {
|
||||
var tags []weightedTag
|
||||
for _, part := range strings.Split(header, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
tag, qStr, hasQ := strings.Cut(part, ";")
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag == "" || tag == "*" {
|
||||
continue
|
||||
}
|
||||
q := 1.0
|
||||
if hasQ {
|
||||
qStr = strings.TrimSpace(qStr)
|
||||
if v, ok := strings.CutPrefix(qStr, "q="); ok {
|
||||
if parsed, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil {
|
||||
q = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
primary, _, _ := strings.Cut(tag, "-")
|
||||
tags = append(tags, weightedTag{primary: strings.ToLower(primary), q: q})
|
||||
}
|
||||
sort.SliceStable(tags, func(i, j int) bool { return tags[i].q > tags[j].q })
|
||||
return tags
|
||||
}
|
||||
|
||||
// NegotiateAcceptLanguage picks the best Supported locale for a raw
|
||||
// "Accept-Language" header value, falling back to Default if nothing
|
||||
// in the header is one enkly ships a catalog for (or the header is
|
||||
// empty / unparseable). Callers only reach for this when there's no
|
||||
// stronger signal available — see the gateway's localeFrom, which
|
||||
// tries the enkly_lang cookie first and only falls through to this
|
||||
// for a visitor who has never touched the language switcher.
|
||||
func NegotiateAcceptLanguage(header string) Locale {
|
||||
for _, tag := range parseAcceptLanguage(header) {
|
||||
if IsSupported(tag.primary) {
|
||||
return Locale(tag.primary)
|
||||
}
|
||||
}
|
||||
return Default
|
||||
}
|
||||
20
pkg/i18n/negotiate_test.go
Normal file
20
pkg/i18n/negotiate_test.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package i18n
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNegotiateAcceptLanguage(t *testing.T) {
|
||||
cases := map[string]Locale{
|
||||
"": Default,
|
||||
"fr-FR,fr;q=0.9,en;q=0.8": "fr",
|
||||
"de;q=0.5,fr;q=0.9": "fr",
|
||||
"xx-XX,yy-YY": Default,
|
||||
"pt-BR": "pt",
|
||||
"en-US,en;q=0.9": "en",
|
||||
" , ;q=bogus , es-MX;q=0.4": "es",
|
||||
}
|
||||
for header, want := range cases {
|
||||
if got := NegotiateAcceptLanguage(header); got != want {
|
||||
t.Errorf("NegotiateAcceptLanguage(%q) = %q, want %q", header, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
173
pkg/mailkit/mail.go
Normal file
173
pkg/mailkit/mail.go
Normal file
@@ -0,0 +1,173 @@
|
||||
// Package mail sends outgoing email (verification codes and password
|
||||
// reset links) over SMTP with STARTTLS — the scheme smtp.gmail.com
|
||||
// requires on port 587. It's built on net/smtp + crypto/tls directly
|
||||
// rather than a third-party mailer library, matching the rest of
|
||||
// enkly's minimal-dependencies stance (see go.mod).
|
||||
package mailkit
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds SMTP connection details, normally sourced from the
|
||||
// ENKLY_SMTP_* environment variables (see cmd/server/main.go and
|
||||
// .env.example). Host/Port default to Gmail's submission endpoint;
|
||||
// Username/Password should be a Gmail *App Password*
|
||||
// (https://myaccount.google.com/apppasswords), not the account's normal
|
||||
// login password — Gmail rejects plain-password SMTP auth for accounts
|
||||
// with 2FA, and discourages it even without 2FA.
|
||||
type Config struct {
|
||||
Host string // e.g. "smtp.gmail.com"
|
||||
Port string // e.g. "587"
|
||||
Username string // full Gmail address
|
||||
Password string // Gmail App Password
|
||||
// From is used for the message's From: header, and may be either a
|
||||
// bare address ("noreply@example.com") or a full RFC 5322 mailbox
|
||||
// with a display name ("enkly <noreply@example.com>") — Send picks
|
||||
// the bare address back out of either form for the SMTP envelope
|
||||
// (see envelopeAddress), since MAIL FROM must never be given a
|
||||
// display name. Defaults to Username if empty.
|
||||
From string
|
||||
}
|
||||
|
||||
// Mailer sends email via a configured SMTP server.
|
||||
type Mailer struct {
|
||||
cfg Config
|
||||
}
|
||||
|
||||
// New builds a Mailer from cfg, filling in Gmail's defaults for any
|
||||
// blank Host/Port so a minimal .env (just username + app password)
|
||||
// works out of the box. Every field is also trimmed of surrounding
|
||||
// whitespace, including a stray trailing "\r" — a real failure mode
|
||||
// when an operator's .env or docker-compose.yml was saved with CRLF
|
||||
// line endings and the value reaches os.Getenv un-normalized: an
|
||||
// embedded "\r" in an SMTP command line reads to the server as a
|
||||
// premature line break, and Gmail answers with a "555 Syntax error"
|
||||
// that gives no hint the cause was whitespace.
|
||||
func New(cfg Config) *Mailer {
|
||||
cfg.Host = strings.TrimSpace(cfg.Host)
|
||||
cfg.Port = strings.TrimSpace(cfg.Port)
|
||||
cfg.Username = strings.TrimSpace(cfg.Username)
|
||||
cfg.Password = strings.TrimSpace(cfg.Password)
|
||||
cfg.From = strings.TrimSpace(cfg.From)
|
||||
if cfg.Host == "" {
|
||||
cfg.Host = "smtp.gmail.com"
|
||||
}
|
||||
if cfg.Port == "" {
|
||||
cfg.Port = "587"
|
||||
}
|
||||
if cfg.From == "" {
|
||||
cfg.From = cfg.Username
|
||||
}
|
||||
return &Mailer{cfg: cfg}
|
||||
}
|
||||
|
||||
// Configured reports whether enough SMTP settings are present to
|
||||
// actually send mail. main.go logs a warning instead of failing to
|
||||
// start when this is false, so the rest of the app (chat, RAG) keeps
|
||||
// working even if email hasn't been set up yet — signup will just fail
|
||||
// with a clear error until it is.
|
||||
func (m *Mailer) Configured() bool {
|
||||
return m.cfg.Username != "" && m.cfg.Password != ""
|
||||
}
|
||||
|
||||
// Send delivers a plain-text email to a single recipient, using
|
||||
// STARTTLS on the configured host/port (Gmail requires this on 587;
|
||||
// plaintext SMTP is refused). Uses net/smtp's low-level Client instead
|
||||
// of smtp.SendMail because SendMail only supports implicit TLS (port
|
||||
// 465) or no TLS at all, not STARTTLS-then-auth.
|
||||
func (m *Mailer) Send(to, subject, body string) error {
|
||||
if !m.Configured() {
|
||||
return fmt.Errorf("mail: not configured (set ENKLY_SMTP_USERNAME / ENKLY_SMTP_PASSWORD)")
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(m.cfg.Host, m.cfg.Port)
|
||||
|
||||
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mail: dial %s: %w", addr, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client, err := smtp.NewClient(conn, m.cfg.Host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mail: smtp handshake: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
tlsConfig := &tls.Config{ServerName: m.cfg.Host}
|
||||
if err := client.StartTLS(tlsConfig); err != nil {
|
||||
return fmt.Errorf("mail: starttls: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
auth := smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host)
|
||||
if ok, _ := client.Extension("AUTH"); ok {
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("mail: auth: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := client.Mail(envelopeAddress(m.cfg.From)); err != nil {
|
||||
return fmt.Errorf("mail: MAIL FROM: %w", err)
|
||||
}
|
||||
if err := client.Rcpt(envelopeAddress(to)); err != nil {
|
||||
return fmt.Errorf("mail: RCPT TO: %w", err)
|
||||
}
|
||||
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("mail: DATA: %w", err)
|
||||
}
|
||||
msg := buildMessage(m.cfg.From, to, subject, body)
|
||||
if _, err := w.Write([]byte(msg)); err != nil {
|
||||
return fmt.Errorf("mail: write body: %w", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return fmt.Errorf("mail: close body: %w", err)
|
||||
}
|
||||
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
// buildMessage assembles a minimal RFC 5322 message. Headers are kept
|
||||
// deliberately simple (plain text, no MIME multipart) since enkly only
|
||||
// ever sends short verification emails.
|
||||
func buildMessage(from, to, subject, body string) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "From: %s\r\n", from)
|
||||
fmt.Fprintf(&b, "To: %s\r\n", to)
|
||||
fmt.Fprintf(&b, "Subject: %s\r\n", subject)
|
||||
b.WriteString("MIME-Version: 1.0\r\n")
|
||||
b.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
|
||||
b.WriteString("\r\n")
|
||||
b.WriteString(body)
|
||||
b.WriteString("\r\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// envelopeAddress reduces addr to the bare mailbox SMTP's envelope
|
||||
// commands require (RFC 5321 §4.1.2 reverse-/forward-path: an address
|
||||
// only, never a display name or angle brackets). addr may already be
|
||||
// bare ("user@example.com") or a full RFC 5322 mailbox with a display
|
||||
// name ("enkly <user@example.com>") — either way client.Mail/client.Rcpt
|
||||
// wrap it in its own "<...>", so passing a mailbox that already
|
||||
// contains a display name and brackets there produces something like
|
||||
// "MAIL FROM:<enkly <user@example.com>>", which Gmail rejects with
|
||||
// exactly "555 5.5.2 Syntax error, cannot decode response" — the bug
|
||||
// this function exists to prevent. If addr doesn't parse as an address
|
||||
// at all, it's returned unchanged so the SMTP server's own error (not a
|
||||
// swallowed one here) explains what's wrong with it.
|
||||
func envelopeAddress(addr string) string {
|
||||
if parsed, err := mail.ParseAddress(addr); err == nil {
|
||||
return parsed.Address
|
||||
}
|
||||
return addr
|
||||
}
|
||||
99
pkg/mailkit/mail_test.go
Normal file
99
pkg/mailkit/mail_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package mailkit
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestEnvelopeAddress covers the exact bug this fixes: a configured
|
||||
// From address that includes a display name (a very natural thing to
|
||||
// set ENKLY_SMTP_FROM to, e.g. "enkly <noreply@example.com>") must not
|
||||
// be handed to MAIL FROM/RCPT TO as-is, or Gmail answers with "555
|
||||
// 5.5.2 Syntax error, cannot decode response" because the envelope
|
||||
// command only accepts a bare address (RFC 5321 §4.1.2).
|
||||
func TestEnvelopeAddress(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"user@example.com", "user@example.com"},
|
||||
{"enkly <user@example.com>", "user@example.com"},
|
||||
{" enkly <user@example.com> ", "user@example.com"},
|
||||
{"\"Enkly Notifications\" <user@example.com>", "user@example.com"},
|
||||
// Not parseable as an address at all: returned unchanged so the
|
||||
// SMTP server's own rejection explains the problem, rather than
|
||||
// this function silently swallowing it.
|
||||
{"not-an-address", "not-an-address"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := envelopeAddress(c.in); got != c.want {
|
||||
t.Errorf("envelopeAddress(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewTrimsConfig guards against the same class of bug from a
|
||||
// different angle: SMTP config values arriving from os.Getenv with
|
||||
// stray whitespace or an embedded "\r" (e.g. a CRLF-saved .env or
|
||||
// docker-compose.yml) must not reach the wire as part of an SMTP
|
||||
// command line, or the server sees a corrupted command.
|
||||
func TestNewTrimsConfig(t *testing.T) {
|
||||
m := New(Config{
|
||||
Host: " smtp.gmail.com\r\n",
|
||||
Port: " 587 ",
|
||||
Username: " user@example.com \r",
|
||||
Password: " app-password \r",
|
||||
From: " enkly <user@example.com> \r",
|
||||
})
|
||||
if m.cfg.Host != "smtp.gmail.com" {
|
||||
t.Errorf("Host = %q", m.cfg.Host)
|
||||
}
|
||||
if m.cfg.Port != "587" {
|
||||
t.Errorf("Port = %q", m.cfg.Port)
|
||||
}
|
||||
if m.cfg.Username != "user@example.com" {
|
||||
t.Errorf("Username = %q", m.cfg.Username)
|
||||
}
|
||||
if m.cfg.Password != "app-password" {
|
||||
t.Errorf("Password = %q", m.cfg.Password)
|
||||
}
|
||||
if m.cfg.From != "enkly <user@example.com>" {
|
||||
t.Errorf("From = %q", m.cfg.From)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewDefaults checks the Gmail host/port/from defaults still apply
|
||||
// after adding trimming, so a minimal config keeps working out of the
|
||||
// box.
|
||||
func TestNewDefaults(t *testing.T) {
|
||||
m := New(Config{Username: "user@example.com", Password: "app-password"})
|
||||
if m.cfg.Host != "smtp.gmail.com" {
|
||||
t.Errorf("default Host = %q", m.cfg.Host)
|
||||
}
|
||||
if m.cfg.Port != "587" {
|
||||
t.Errorf("default Port = %q", m.cfg.Port)
|
||||
}
|
||||
if m.cfg.From != "user@example.com" {
|
||||
t.Errorf("default From = %q", m.cfg.From)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigured(t *testing.T) {
|
||||
if New(Config{}).Configured() {
|
||||
t.Fatal("empty config must not report Configured")
|
||||
}
|
||||
if !New(Config{Username: "u", Password: "p"}).Configured() {
|
||||
t.Fatal("username+password must report Configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMessageShape(t *testing.T) {
|
||||
msg := buildMessage("enkly <from@example.com>", "to@example.com", "Subject line", "body text")
|
||||
for _, want := range []string{
|
||||
"From: enkly <from@example.com>\r\n",
|
||||
"To: to@example.com\r\n",
|
||||
"Subject: Subject line\r\n",
|
||||
"\r\nbody text\r\n",
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("buildMessage output missing %q; got:\n%s", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
115
pkg/mathkit/calculus.go
Normal file
115
pkg/mathkit/calculus.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package mathkit
|
||||
|
||||
import "fmt"
|
||||
|
||||
// defaultStep is the finite-difference step size used when the
|
||||
// caller doesn't specify one. 1e-5 is the standard sweet spot for
|
||||
// float64 central differences: small enough for truncation error to
|
||||
// be negligible, large enough that it doesn't get swallowed by
|
||||
// float64 rounding error (the classic bias-variance trade-off of
|
||||
// numerical differentiation).
|
||||
const defaultStep = 1e-5
|
||||
|
||||
// Derivative estimates d/d(variable) of expr at the point `at`
|
||||
// (variable -> value, including any other free variables the
|
||||
// expression needs) using the central-difference formula
|
||||
// (f(x+h)-f(x-h))/(2h), which is second-order accurate — far better
|
||||
// than the naive forward difference (f(x+h)-f(x))/h for the same h.
|
||||
// h<=0 uses defaultStep.
|
||||
func Derivative(expr *Expr, variable string, at map[string]float64, h float64) (float64, error) {
|
||||
if h <= 0 {
|
||||
h = defaultStep
|
||||
}
|
||||
x0, ok := at[variable]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("derivative: %q is not bound in \"at\"", variable)
|
||||
}
|
||||
plus := cloneVars(at)
|
||||
plus[variable] = x0 + h
|
||||
minus := cloneVars(at)
|
||||
minus[variable] = x0 - h
|
||||
fPlus, err := expr.Eval(plus)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("derivative: evaluating at %s+h: %w", variable, err)
|
||||
}
|
||||
fMinus, err := expr.Eval(minus)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("derivative: evaluating at %s-h: %w", variable, err)
|
||||
}
|
||||
return (fPlus - fMinus) / (2 * h), nil
|
||||
}
|
||||
|
||||
// Gradient estimates ∇f at the point `at` with respect to each name
|
||||
// in variables, in order — the central operation every ML training
|
||||
// loop runs millions of times (just normally via backprop's chain
|
||||
// rule rather than finite differences; this is the numerical
|
||||
// stand-in that's useful for checking a hand-derived gradient, or for
|
||||
// expressions where deriving the symbolic gradient isn't worth it).
|
||||
func Gradient(expr *Expr, variables []string, at map[string]float64, h float64) ([]float64, error) {
|
||||
if len(variables) == 0 {
|
||||
return nil, fmt.Errorf("gradient: \"variables\" must list at least one variable")
|
||||
}
|
||||
grad := make([]float64, len(variables))
|
||||
for i, v := range variables {
|
||||
d, err := Derivative(expr, v, at, h)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
grad[i] = d
|
||||
}
|
||||
return grad, nil
|
||||
}
|
||||
|
||||
func cloneVars(m map[string]float64) map[string]float64 {
|
||||
out := make(map[string]float64, len(m))
|
||||
for k, v := range m {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Integrate estimates the definite integral of expr with respect to
|
||||
// variable over [from, to] using Simpson's rule, which is exact for
|
||||
// up to cubic polynomials and converges much faster than the
|
||||
// trapezoid rule for smooth functions. steps (the number of
|
||||
// subintervals) is rounded up to the nearest even number, since
|
||||
// Simpson's rule needs an even number of subintervals; steps<=0
|
||||
// defaults to 1000.
|
||||
func Integrate(expr *Expr, variable string, from, to float64, steps int, fixed map[string]float64) (float64, error) {
|
||||
if steps <= 0 {
|
||||
steps = 1000
|
||||
}
|
||||
if steps%2 != 0 {
|
||||
steps++
|
||||
}
|
||||
h := (to - from) / float64(steps)
|
||||
vars := cloneVars(fixed)
|
||||
|
||||
eval := func(x float64) (float64, error) {
|
||||
vars[variable] = x
|
||||
return expr.Eval(vars)
|
||||
}
|
||||
|
||||
f0, err := eval(from)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("integral: %w", err)
|
||||
}
|
||||
fn, err := eval(to)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("integral: %w", err)
|
||||
}
|
||||
sum := f0 + fn
|
||||
for i := 1; i < steps; i++ {
|
||||
x := from + float64(i)*h
|
||||
fx, err := eval(x)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("integral: %w", err)
|
||||
}
|
||||
if i%2 == 0 {
|
||||
sum += 2 * fx
|
||||
} else {
|
||||
sum += 4 * fx
|
||||
}
|
||||
}
|
||||
return sum * h / 3, nil
|
||||
}
|
||||
421
pkg/mathkit/expr.go
Normal file
421
pkg/mathkit/expr.go
Normal file
@@ -0,0 +1,421 @@
|
||||
// Package mathkit is a dependency-free math engine for the problems
|
||||
// that actually come up when building and debugging ML code: linear
|
||||
// algebra (the shape of every tensor op), calculus (gradients are how
|
||||
// everything trains), probability/statistics (data, metrics, losses),
|
||||
// numerical optimization (gradient descent, root finding), and the
|
||||
// handful of ML-specific primitives (activations, loss functions,
|
||||
// least-squares linear regression) that sit on top of those four
|
||||
// pillars. It has no third-party dependency — see the comment on
|
||||
// pkg/envkit/dotenv.go for why this module keeps go.mod minimal — so
|
||||
// the whole engine is just the Go standard library's math/sort/fmt.
|
||||
//
|
||||
// Every sub-area (linalg.go, calculus.go, statistics.go, optimize.go,
|
||||
// ml.go) is usable directly from Go code. solve.go adds one more
|
||||
// layer on top: Solve(operation, params) dispatches a string
|
||||
// operation name and a loosely-typed params map (exactly the shape
|
||||
// a JSON tool-call argument object decodes to) to the right function
|
||||
// and renders a human-readable report — that's the layer the agent
|
||||
// service's solve_ml_math tool calls (see
|
||||
// services/agent/internal/infrastructure/toolexec/mlmath.go), because
|
||||
// an LLM tool call arrives as JSON, not as typed Go arguments.
|
||||
package mathkit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Expr is a parsed arithmetic expression that can be evaluated
|
||||
// repeatedly against different variable bindings without re-parsing —
|
||||
// exactly what Gradient and GradientDescent need, since they evaluate
|
||||
// the same expression at many points.
|
||||
type Expr struct {
|
||||
root node
|
||||
src string
|
||||
}
|
||||
|
||||
// String returns the original source text the expression was parsed
|
||||
// from, for error messages and report rendering.
|
||||
func (e *Expr) String() string { return e.src }
|
||||
|
||||
// node is the parsed AST. A tiny tagged union kept as an interface
|
||||
// rather than a struct-with-a-kind-field: each node type carries only
|
||||
// the fields it needs, and eval is a single recursive method.
|
||||
type node interface {
|
||||
eval(vars map[string]float64) (float64, error)
|
||||
}
|
||||
|
||||
type numNode float64
|
||||
|
||||
func (n numNode) eval(map[string]float64) (float64, error) { return float64(n), nil }
|
||||
|
||||
type varNode string
|
||||
|
||||
func (n varNode) eval(vars map[string]float64) (float64, error) {
|
||||
v, ok := vars[string(n)]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unbound variable %q (pass it in \"variables\" or \"at\")", string(n))
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
type binNode struct {
|
||||
op byte // '+', '-', '*', '/', '^'
|
||||
lhs, rhs node
|
||||
}
|
||||
|
||||
func (n binNode) eval(vars map[string]float64) (float64, error) {
|
||||
l, err := n.lhs.eval(vars)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
r, err := n.rhs.eval(vars)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
switch n.op {
|
||||
case '+':
|
||||
return l + r, nil
|
||||
case '-':
|
||||
return l - r, nil
|
||||
case '*':
|
||||
return l * r, nil
|
||||
case '/':
|
||||
if r == 0 {
|
||||
return 0, fmt.Errorf("division by zero")
|
||||
}
|
||||
return l / r, nil
|
||||
case '^':
|
||||
return math.Pow(l, r), nil
|
||||
}
|
||||
return 0, fmt.Errorf("internal: unknown operator %q", n.op)
|
||||
}
|
||||
|
||||
type negNode struct{ x node }
|
||||
|
||||
func (n negNode) eval(vars map[string]float64) (float64, error) {
|
||||
v, err := n.x.eval(vars)
|
||||
return -v, err
|
||||
}
|
||||
|
||||
type callNode struct {
|
||||
fn string
|
||||
args []node
|
||||
}
|
||||
|
||||
// unaryFuncs are functions of exactly one argument — the overwhelming
|
||||
// majority of what shows up in ML math (activations, logs, norms).
|
||||
var unaryFuncs = map[string]func(float64) float64{
|
||||
"sin": math.Sin, "cos": math.Cos, "tan": math.Tan,
|
||||
"asin": math.Asin, "acos": math.Acos, "atan": math.Atan,
|
||||
"sinh": math.Sinh, "cosh": math.Cosh, "tanh": math.Tanh,
|
||||
"exp": math.Exp, "sqrt": math.Sqrt, "abs": math.Abs,
|
||||
"ln": math.Log, "log": math.Log, "log2": math.Log2, "log10": math.Log10,
|
||||
"floor": math.Floor, "ceil": math.Ceil, "round": math.Round,
|
||||
"sigmoid": Sigmoid, "relu": ReLU, "sign": func(x float64) float64 { return math.Copysign(1, x) * boolF(x != 0) },
|
||||
}
|
||||
|
||||
func boolF(b bool) float64 {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// binaryFuncs are functions of exactly two arguments.
|
||||
var binaryFuncs = map[string]func(float64, float64) float64{
|
||||
"max": math.Max, "min": math.Min, "pow": math.Pow,
|
||||
"mod": math.Mod, "atan2": math.Atan2,
|
||||
}
|
||||
|
||||
func (n callNode) eval(vars map[string]float64) (float64, error) {
|
||||
switch len(n.args) {
|
||||
case 1:
|
||||
if f, ok := unaryFuncs[n.fn]; ok {
|
||||
v, err := n.args[0].eval(vars)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return f(v), nil
|
||||
}
|
||||
case 2:
|
||||
if f, ok := binaryFuncs[n.fn]; ok {
|
||||
a, err := n.args[0].eval(vars)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
b, err := n.args[1].eval(vars)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return f(a, b), nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("unknown function %q with %d argument(s)", n.fn, len(n.args))
|
||||
}
|
||||
|
||||
// Eval evaluates the expression against a variable binding. Missing
|
||||
// variables are a reported error (never silently treated as zero),
|
||||
// since a silent zero would turn a typo into a wrong-but-plausible
|
||||
// answer — exactly the failure mode numerical tools must not have.
|
||||
func (e *Expr) Eval(vars map[string]float64) (float64, error) {
|
||||
return e.root.eval(vars)
|
||||
}
|
||||
|
||||
// ParseExpr parses a math expression written in ordinary infix
|
||||
// notation: + - * / ^ (right-associative power), unary minus,
|
||||
// parentheses, named variables, the constants pi/e, and function
|
||||
// calls (sin(x), sigmoid(x), max(a,b), ...). See unaryFuncs and
|
||||
// binaryFuncs for the full function list.
|
||||
func ParseExpr(src string) (*Expr, error) {
|
||||
p := &parser{toks: tokenize(src)}
|
||||
n, err := p.parseExpr()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing %q: %w", src, err)
|
||||
}
|
||||
if p.pos != len(p.toks) {
|
||||
return nil, fmt.Errorf("parsing %q: unexpected trailing input near %q", src, p.toks[p.pos].text)
|
||||
}
|
||||
return &Expr{root: n, src: src}, nil
|
||||
}
|
||||
|
||||
// --- tokenizer ---
|
||||
|
||||
type tokKind int
|
||||
|
||||
const (
|
||||
tokNum tokKind = iota
|
||||
tokIdent
|
||||
tokOp
|
||||
tokLParen
|
||||
tokRParen
|
||||
tokComma
|
||||
)
|
||||
|
||||
type token struct {
|
||||
kind tokKind
|
||||
text string
|
||||
num float64
|
||||
}
|
||||
|
||||
func tokenize(src string) []token {
|
||||
var toks []token
|
||||
r := []rune(src)
|
||||
i := 0
|
||||
for i < len(r) {
|
||||
c := r[i]
|
||||
switch {
|
||||
case unicode.IsSpace(c):
|
||||
i++
|
||||
case c == '(':
|
||||
toks = append(toks, token{kind: tokLParen, text: "("})
|
||||
i++
|
||||
case c == ')':
|
||||
toks = append(toks, token{kind: tokRParen, text: ")"})
|
||||
i++
|
||||
case c == ',':
|
||||
toks = append(toks, token{kind: tokComma, text: ","})
|
||||
i++
|
||||
case strings.ContainsRune("+-*/^", c):
|
||||
toks = append(toks, token{kind: tokOp, text: string(c)})
|
||||
i++
|
||||
case unicode.IsDigit(c) || c == '.':
|
||||
j := i
|
||||
for j < len(r) && (unicode.IsDigit(r[j]) || r[j] == '.') {
|
||||
j++
|
||||
}
|
||||
// scientific notation, e.g. 1e-3
|
||||
if j < len(r) && (r[j] == 'e' || r[j] == 'E') {
|
||||
k := j + 1
|
||||
if k < len(r) && (r[k] == '+' || r[k] == '-') {
|
||||
k++
|
||||
}
|
||||
if k < len(r) && unicode.IsDigit(r[k]) {
|
||||
j = k
|
||||
for j < len(r) && unicode.IsDigit(r[j]) {
|
||||
j++
|
||||
}
|
||||
}
|
||||
}
|
||||
text := string(r[i:j])
|
||||
var f float64
|
||||
fmt.Sscanf(text, "%g", &f)
|
||||
toks = append(toks, token{kind: tokNum, text: text, num: f})
|
||||
i = j
|
||||
case unicode.IsLetter(c) || c == '_':
|
||||
j := i
|
||||
for j < len(r) && (unicode.IsLetter(r[j]) || unicode.IsDigit(r[j]) || r[j] == '_') {
|
||||
j++
|
||||
}
|
||||
toks = append(toks, token{kind: tokIdent, text: string(r[i:j])})
|
||||
i = j
|
||||
default:
|
||||
// Unknown character: emit as its own operator token so the
|
||||
// parser reports a clear "unexpected token" error instead
|
||||
// of the tokenizer silently dropping it.
|
||||
toks = append(toks, token{kind: tokOp, text: string(c)})
|
||||
i++
|
||||
}
|
||||
}
|
||||
return toks
|
||||
}
|
||||
|
||||
// --- recursive-descent parser ---
|
||||
//
|
||||
// expr := term (('+' | '-') term)*
|
||||
// term := unary (('*' | '/') unary)*
|
||||
// unary := ('-' | '+') unary | power
|
||||
// power := primary ('^' unary)? -- right-associative; the
|
||||
// exponent recurses through unary (not power) so the
|
||||
// precedence matches ordinary math/Python convention:
|
||||
// unary minus binds LOOSER than '^' (-2^2 == -(2^2) == -4)
|
||||
// but the exponent itself may carry its own sign
|
||||
// (2^-2 == 0.25).
|
||||
// primary:= number | const | ident '(' args ')' | ident | '(' expr ')'
|
||||
// args := expr (',' expr)*
|
||||
|
||||
type parser struct {
|
||||
toks []token
|
||||
pos int
|
||||
}
|
||||
|
||||
func (p *parser) peek() (token, bool) {
|
||||
if p.pos >= len(p.toks) {
|
||||
return token{}, false
|
||||
}
|
||||
return p.toks[p.pos], true
|
||||
}
|
||||
|
||||
func (p *parser) next() (token, bool) {
|
||||
t, ok := p.peek()
|
||||
if ok {
|
||||
p.pos++
|
||||
}
|
||||
return t, ok
|
||||
}
|
||||
|
||||
func (p *parser) parseExpr() (node, error) {
|
||||
lhs, err := p.parseTerm()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for {
|
||||
t, ok := p.peek()
|
||||
if !ok || t.kind != tokOp || (t.text != "+" && t.text != "-") {
|
||||
return lhs, nil
|
||||
}
|
||||
p.next()
|
||||
rhs, err := p.parseTerm()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lhs = binNode{op: t.text[0], lhs: lhs, rhs: rhs}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parser) parseTerm() (node, error) {
|
||||
lhs, err := p.parseUnary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for {
|
||||
t, ok := p.peek()
|
||||
if !ok || t.kind != tokOp || (t.text != "*" && t.text != "/") {
|
||||
return lhs, nil
|
||||
}
|
||||
p.next()
|
||||
rhs, err := p.parseUnary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lhs = binNode{op: t.text[0], lhs: lhs, rhs: rhs}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parser) parseUnary() (node, error) {
|
||||
if t, ok := p.peek(); ok && t.kind == tokOp && (t.text == "-" || t.text == "+") {
|
||||
p.next()
|
||||
x, err := p.parseUnary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if t.text == "-" {
|
||||
return negNode{x: x}, nil
|
||||
}
|
||||
return x, nil
|
||||
}
|
||||
return p.parsePower()
|
||||
}
|
||||
|
||||
func (p *parser) parsePower() (node, error) {
|
||||
lhs, err := p.parsePrimary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if t, ok := p.peek(); ok && t.kind == tokOp && t.text == "^" {
|
||||
p.next()
|
||||
rhs, err := p.parseUnary() // right-associative; lets the exponent carry its own sign
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return binNode{op: '^', lhs: lhs, rhs: rhs}, nil
|
||||
}
|
||||
return lhs, nil
|
||||
}
|
||||
|
||||
func (p *parser) parsePrimary() (node, error) {
|
||||
t, ok := p.next()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected end of expression")
|
||||
}
|
||||
switch t.kind {
|
||||
case tokNum:
|
||||
return numNode(t.num), nil
|
||||
case tokLParen:
|
||||
n, err := p.parseExpr()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c, ok := p.next(); !ok || c.kind != tokRParen {
|
||||
return nil, fmt.Errorf("missing closing ')'")
|
||||
}
|
||||
return n, nil
|
||||
case tokIdent:
|
||||
switch strings.ToLower(t.text) {
|
||||
case "pi":
|
||||
return numNode(math.Pi), nil
|
||||
case "e":
|
||||
return numNode(math.E), nil
|
||||
}
|
||||
if nx, ok := p.peek(); ok && nx.kind == tokLParen {
|
||||
p.next()
|
||||
var args []node
|
||||
if c, ok := p.peek(); !ok || c.kind != tokRParen {
|
||||
for {
|
||||
a, err := p.parseExpr()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args = append(args, a)
|
||||
c, ok := p.peek()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("missing closing ')' in call to %s", t.text)
|
||||
}
|
||||
if c.kind == tokComma {
|
||||
p.next()
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if c, ok := p.next(); !ok || c.kind != tokRParen {
|
||||
return nil, fmt.Errorf("missing closing ')' in call to %s", t.text)
|
||||
}
|
||||
return callNode{fn: strings.ToLower(t.text), args: args}, nil
|
||||
}
|
||||
return varNode(t.text), nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected token %q", t.text)
|
||||
}
|
||||
409
pkg/mathkit/linalg.go
Normal file
409
pkg/mathkit/linalg.go
Normal file
@@ -0,0 +1,409 @@
|
||||
package mathkit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// Matrix is a dense matrix stored row-major: Matrix[i][j] is row i,
|
||||
// column j. Every row must have the same length — functions that take
|
||||
// a Matrix validate that with Matrix.Dims before doing any math, so a
|
||||
// ragged input is reported as an error rather than panicking.
|
||||
type Matrix [][]float64
|
||||
|
||||
// Dims returns the matrix's row and column counts and an error if the
|
||||
// rows aren't all the same length (a ragged "matrix" isn't one).
|
||||
func (m Matrix) Dims() (rows, cols int, err error) {
|
||||
rows = len(m)
|
||||
if rows == 0 {
|
||||
return 0, 0, fmt.Errorf("empty matrix")
|
||||
}
|
||||
cols = len(m[0])
|
||||
for i, row := range m {
|
||||
if len(row) != cols {
|
||||
return 0, 0, fmt.Errorf("ragged matrix: row 0 has %d columns, row %d has %d", cols, i, len(row))
|
||||
}
|
||||
}
|
||||
return rows, cols, nil
|
||||
}
|
||||
|
||||
// Identity returns the n×n identity matrix.
|
||||
func Identity(n int) Matrix {
|
||||
m := make(Matrix, n)
|
||||
for i := range m {
|
||||
m[i] = make([]float64, n)
|
||||
m[i][i] = 1
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func sameDims(a, b Matrix) (rows, cols int, err error) {
|
||||
ra, ca, err := a.Dims()
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
rb, cb, err := b.Dims()
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if ra != rb || ca != cb {
|
||||
return 0, 0, fmt.Errorf("shape mismatch: %dx%d vs %dx%d", ra, ca, rb, cb)
|
||||
}
|
||||
return ra, ca, nil
|
||||
}
|
||||
|
||||
// MatAdd returns a+b, element-wise.
|
||||
func MatAdd(a, b Matrix) (Matrix, error) {
|
||||
rows, cols, err := sameDims(a, b)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("matrix_add: %w", err)
|
||||
}
|
||||
out := make(Matrix, rows)
|
||||
for i := 0; i < rows; i++ {
|
||||
out[i] = make([]float64, cols)
|
||||
for j := 0; j < cols; j++ {
|
||||
out[i][j] = a[i][j] + b[i][j]
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MatSub returns a-b, element-wise.
|
||||
func MatSub(a, b Matrix) (Matrix, error) {
|
||||
rows, cols, err := sameDims(a, b)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("matrix_sub: %w", err)
|
||||
}
|
||||
out := make(Matrix, rows)
|
||||
for i := 0; i < rows; i++ {
|
||||
out[i] = make([]float64, cols)
|
||||
for j := 0; j < cols; j++ {
|
||||
out[i][j] = a[i][j] - b[i][j]
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MatMul returns the matrix product a×b (standard inner-dimension
|
||||
// matching: a is m×n, b must be n×p).
|
||||
func MatMul(a, b Matrix) (Matrix, error) {
|
||||
ra, ca, err := a.Dims()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("matrix_mul: left operand: %w", err)
|
||||
}
|
||||
rb, cb, err := b.Dims()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("matrix_mul: right operand: %w", err)
|
||||
}
|
||||
if ca != rb {
|
||||
return nil, fmt.Errorf("matrix_mul: inner dimensions must match: %dx%d times %dx%d", ra, ca, rb, cb)
|
||||
}
|
||||
out := make(Matrix, ra)
|
||||
for i := 0; i < ra; i++ {
|
||||
out[i] = make([]float64, cb)
|
||||
for j := 0; j < cb; j++ {
|
||||
var sum float64
|
||||
for k := 0; k < ca; k++ {
|
||||
sum += a[i][k] * b[k][j]
|
||||
}
|
||||
out[i][j] = sum
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Scale multiplies every element of m by k.
|
||||
func Scale(m Matrix, k float64) Matrix {
|
||||
out := make(Matrix, len(m))
|
||||
for i, row := range m {
|
||||
out[i] = make([]float64, len(row))
|
||||
for j, v := range row {
|
||||
out[i][j] = v * k
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Transpose returns m^T.
|
||||
func Transpose(m Matrix) (Matrix, error) {
|
||||
rows, cols, err := m.Dims()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("matrix_transpose: %w", err)
|
||||
}
|
||||
out := make(Matrix, cols)
|
||||
for j := 0; j < cols; j++ {
|
||||
out[j] = make([]float64, rows)
|
||||
for i := 0; i < rows; i++ {
|
||||
out[j][i] = m[i][j]
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Trace returns the sum of the diagonal of a square matrix.
|
||||
func Trace(m Matrix) (float64, error) {
|
||||
rows, cols, err := m.Dims()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("matrix_trace: %w", err)
|
||||
}
|
||||
if rows != cols {
|
||||
return 0, fmt.Errorf("matrix_trace: matrix must be square, got %dx%d", rows, cols)
|
||||
}
|
||||
var sum float64
|
||||
for i := 0; i < rows; i++ {
|
||||
sum += m[i][i]
|
||||
}
|
||||
return sum, nil
|
||||
}
|
||||
|
||||
// clone makes a deep copy so Gaussian-elimination routines never
|
||||
// mutate the caller's matrix.
|
||||
func (m Matrix) clone() Matrix {
|
||||
out := make(Matrix, len(m))
|
||||
for i, row := range m {
|
||||
out[i] = append([]float64(nil), row...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// luDecompose runs Gaussian elimination with partial pivoting on a
|
||||
// clone of m, returning the resulting upper-triangular-ish matrix and
|
||||
// the sign of the permutation (needed to get the determinant's sign
|
||||
// right) and the pivots used. Both Determinant and Inverse build on
|
||||
// this one routine rather than duplicating elimination logic.
|
||||
func luDecompose(m Matrix) (a Matrix, sign float64, err error) {
|
||||
rows, cols, err := m.Dims()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if rows != cols {
|
||||
return nil, 0, fmt.Errorf("matrix must be square, got %dx%d", rows, cols)
|
||||
}
|
||||
n := rows
|
||||
a = m.clone()
|
||||
sign = 1
|
||||
for col := 0; col < n; col++ {
|
||||
// Partial pivot: swap in the row with the largest absolute
|
||||
// value in this column, for numerical stability.
|
||||
pivotRow := col
|
||||
best := math.Abs(a[col][col])
|
||||
for r := col + 1; r < n; r++ {
|
||||
if v := math.Abs(a[r][col]); v > best {
|
||||
best, pivotRow = v, r
|
||||
}
|
||||
}
|
||||
if best < 1e-12 {
|
||||
return a, 0, nil // singular; determinant is 0, caller decides
|
||||
}
|
||||
if pivotRow != col {
|
||||
a[col], a[pivotRow] = a[pivotRow], a[col]
|
||||
sign = -sign
|
||||
}
|
||||
for r := col + 1; r < n; r++ {
|
||||
factor := a[r][col] / a[col][col]
|
||||
for c := col; c < n; c++ {
|
||||
a[r][c] -= factor * a[col][c]
|
||||
}
|
||||
}
|
||||
}
|
||||
return a, sign, nil
|
||||
}
|
||||
|
||||
// Determinant computes det(m) via LU (Gaussian elimination with
|
||||
// partial pivoting), O(n^3) — the standard numerically-stable
|
||||
// approach, versus the O(n!) cofactor expansion a direct reading of
|
||||
// the definition would suggest.
|
||||
func Determinant(m Matrix) (float64, error) {
|
||||
a, sign, err := luDecompose(m)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("matrix_determinant: %w", err)
|
||||
}
|
||||
n := len(a)
|
||||
det := sign
|
||||
for i := 0; i < n; i++ {
|
||||
det *= a[i][i]
|
||||
}
|
||||
return det, nil
|
||||
}
|
||||
|
||||
// Inverse computes m^-1 via Gauss-Jordan elimination on [m | I].
|
||||
// Returns an error for a non-square or singular (determinant ≈ 0)
|
||||
// matrix — ML code hitting a singular matrix (e.g. a rank-deficient
|
||||
// design matrix in least squares) usually wants to know that, not a
|
||||
// silently garbage answer.
|
||||
func Inverse(m Matrix) (Matrix, error) {
|
||||
rows, cols, err := m.Dims()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("matrix_inverse: %w", err)
|
||||
}
|
||||
if rows != cols {
|
||||
return nil, fmt.Errorf("matrix_inverse: matrix must be square, got %dx%d", rows, cols)
|
||||
}
|
||||
n := rows
|
||||
aug := make(Matrix, n)
|
||||
for i := 0; i < n; i++ {
|
||||
aug[i] = make([]float64, 2*n)
|
||||
copy(aug[i], m[i])
|
||||
aug[i][n+i] = 1
|
||||
}
|
||||
for col := 0; col < n; col++ {
|
||||
pivotRow := col
|
||||
best := math.Abs(aug[col][col])
|
||||
for r := col + 1; r < n; r++ {
|
||||
if v := math.Abs(aug[r][col]); v > best {
|
||||
best, pivotRow = v, r
|
||||
}
|
||||
}
|
||||
if best < 1e-12 {
|
||||
return nil, fmt.Errorf("matrix_inverse: matrix is singular (not invertible)")
|
||||
}
|
||||
aug[col], aug[pivotRow] = aug[pivotRow], aug[col]
|
||||
pivot := aug[col][col]
|
||||
for c := 0; c < 2*n; c++ {
|
||||
aug[col][c] /= pivot
|
||||
}
|
||||
for r := 0; r < n; r++ {
|
||||
if r == col {
|
||||
continue
|
||||
}
|
||||
factor := aug[r][col]
|
||||
for c := 0; c < 2*n; c++ {
|
||||
aug[r][c] -= factor * aug[col][c]
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make(Matrix, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out[i] = append([]float64(nil), aug[i][n:]...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Rank computes the matrix rank as the number of non-negligible
|
||||
// pivots found by Gaussian elimination (works for non-square
|
||||
// matrices too, unlike Determinant/Inverse).
|
||||
func Rank(m Matrix) (int, error) {
|
||||
rows, cols, err := m.Dims()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("matrix_rank: %w", err)
|
||||
}
|
||||
a := m.clone()
|
||||
rank := 0
|
||||
for col := 0; col < cols && rank < rows; col++ {
|
||||
pivotRow := -1
|
||||
best := 1e-9
|
||||
for r := rank; r < rows; r++ {
|
||||
if v := math.Abs(a[r][col]); v > best {
|
||||
best, pivotRow = v, r
|
||||
}
|
||||
}
|
||||
if pivotRow == -1 {
|
||||
continue
|
||||
}
|
||||
a[rank], a[pivotRow] = a[pivotRow], a[rank]
|
||||
for r := rank + 1; r < rows; r++ {
|
||||
factor := a[r][col] / a[rank][col]
|
||||
for c := col; c < cols; c++ {
|
||||
a[r][c] -= factor * a[rank][c]
|
||||
}
|
||||
}
|
||||
rank++
|
||||
}
|
||||
return rank, nil
|
||||
}
|
||||
|
||||
// MatVecMul returns m×v for a column vector v.
|
||||
func MatVecMul(m Matrix, v []float64) ([]float64, error) {
|
||||
rows, cols, err := m.Dims()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("matrix-vector multiply: %w", err)
|
||||
}
|
||||
if cols != len(v) {
|
||||
return nil, fmt.Errorf("matrix-vector multiply: matrix has %d columns but vector has %d entries", cols, len(v))
|
||||
}
|
||||
out := make([]float64, rows)
|
||||
for i := 0; i < rows; i++ {
|
||||
var sum float64
|
||||
for j := 0; j < cols; j++ {
|
||||
sum += m[i][j] * v[j]
|
||||
}
|
||||
out[i] = sum
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Dot returns the dot (inner) product of two equal-length vectors.
|
||||
func Dot(a, b []float64) (float64, error) {
|
||||
if len(a) != len(b) {
|
||||
return 0, fmt.Errorf("dot_product: vectors have different lengths (%d vs %d)", len(a), len(b))
|
||||
}
|
||||
var sum float64
|
||||
for i := range a {
|
||||
sum += a[i] * b[i]
|
||||
}
|
||||
return sum, nil
|
||||
}
|
||||
|
||||
// Norm computes the Lp norm of a vector. p=2 is Euclidean (the
|
||||
// default any ML codebase means when it just says "norm"); p=1 is
|
||||
// Manhattan; p=math.Inf(1) is the max-absolute-value norm.
|
||||
func Norm(v []float64, p float64) float64 {
|
||||
if math.IsInf(p, 1) {
|
||||
var m float64
|
||||
for _, x := range v {
|
||||
if a := math.Abs(x); a > m {
|
||||
m = a
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
var sum float64
|
||||
for _, x := range v {
|
||||
sum += math.Pow(math.Abs(x), p)
|
||||
}
|
||||
return math.Pow(sum, 1/p)
|
||||
}
|
||||
|
||||
// DominantEigen estimates the eigenvalue of largest absolute value
|
||||
// and its eigenvector via power iteration — the workhorse behind
|
||||
// things like PageRank and quick PCA-component estimates, and much
|
||||
// simpler/more numerically robust to implement from scratch than a
|
||||
// full QR eigendecomposition, which is the right trade-off for a
|
||||
// hand-rolled numerical toolkit. iterations<=0 defaults to 200, which
|
||||
// converges for any matrix with a well-separated dominant eigenvalue
|
||||
// to within float64 precision.
|
||||
func DominantEigen(m Matrix, iterations int) (eigenvalue float64, eigenvector []float64, err error) {
|
||||
rows, cols, err := m.Dims()
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("dominant_eigen: %w", err)
|
||||
}
|
||||
if rows != cols {
|
||||
return 0, nil, fmt.Errorf("dominant_eigen: matrix must be square, got %dx%d", rows, cols)
|
||||
}
|
||||
if iterations <= 0 {
|
||||
iterations = 200
|
||||
}
|
||||
n := rows
|
||||
v := make([]float64, n)
|
||||
for i := range v {
|
||||
v[i] = 1 / math.Sqrt(float64(n)) // deterministic, reproducible start
|
||||
}
|
||||
var lambda float64
|
||||
for iter := 0; iter < iterations; iter++ {
|
||||
next, err := MatVecMul(m, v)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
norm := Norm(next, 2)
|
||||
if norm < 1e-15 {
|
||||
return 0, nil, fmt.Errorf("dominant_eigen: did not converge (matrix may be nilpotent or zero)")
|
||||
}
|
||||
for i := range next {
|
||||
next[i] /= norm
|
||||
}
|
||||
v = next
|
||||
mv, _ := MatVecMul(m, v)
|
||||
lambda, _ = Dot(v, mv)
|
||||
}
|
||||
return lambda, v, nil
|
||||
}
|
||||
364
pkg/mathkit/mathkit_test.go
Normal file
364
pkg/mathkit/mathkit_test.go
Normal file
@@ -0,0 +1,364 @@
|
||||
package mathkit
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func almostEqual(a, b, tol float64) bool { return math.Abs(a-b) <= tol }
|
||||
|
||||
func mustExpr(t *testing.T, src string) *Expr {
|
||||
t.Helper()
|
||||
e, err := ParseExpr(src)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseExpr(%q): %v", src, err)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func TestExprEval(t *testing.T) {
|
||||
cases := []struct {
|
||||
src string
|
||||
vars map[string]float64
|
||||
want float64
|
||||
}{
|
||||
{"2 + 3 * 4", nil, 14},
|
||||
{"(2 + 3) * 4", nil, 20},
|
||||
{"2 ^ 3 ^ 2", nil, 512}, // right-associative: 2^(3^2) = 2^9
|
||||
{"-2 ^ 2", nil, -4}, // unary minus binds looser than ^: -(2^2)
|
||||
{"x * x + 2*x + 1", map[string]float64{"x": 3}, 16},
|
||||
{"sigmoid(0)", nil, 0.5},
|
||||
{"relu(-5)", nil, 0},
|
||||
{"relu(5)", nil, 5},
|
||||
{"max(3, 7)", nil, 7},
|
||||
{"sqrt(16)", nil, 4},
|
||||
{"pi", nil, math.Pi},
|
||||
}
|
||||
for _, c := range cases {
|
||||
e := mustExpr(t, c.src)
|
||||
got, err := e.Eval(c.vars)
|
||||
if err != nil {
|
||||
t.Fatalf("Eval(%q): %v", c.src, err)
|
||||
}
|
||||
if !almostEqual(got, c.want, 1e-9) {
|
||||
t.Errorf("Eval(%q) = %v, want %v", c.src, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExprUnboundVariable(t *testing.T) {
|
||||
e := mustExpr(t, "x + 1")
|
||||
if _, err := e.Eval(nil); err == nil {
|
||||
t.Fatal("expected an error for an unbound variable, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatMul(t *testing.T) {
|
||||
a := Matrix{{1, 2}, {3, 4}}
|
||||
b := Matrix{{5, 6}, {7, 8}}
|
||||
got, err := MatMul(a, b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := Matrix{{19, 22}, {43, 50}}
|
||||
for i := range want {
|
||||
for j := range want[i] {
|
||||
if got[i][j] != want[i][j] {
|
||||
t.Errorf("MatMul[%d][%d] = %v, want %v", i, j, got[i][j], want[i][j])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatMulShapeMismatch(t *testing.T) {
|
||||
a := Matrix{{1, 2, 3}}
|
||||
b := Matrix{{1, 2}}
|
||||
if _, err := MatMul(a, b); err == nil {
|
||||
t.Fatal("expected a shape-mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeterminantAndInverse(t *testing.T) {
|
||||
a := Matrix{{4, 7}, {2, 6}}
|
||||
det, err := Determinant(a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !almostEqual(det, 10, 1e-9) {
|
||||
t.Errorf("det = %v, want 10", det)
|
||||
}
|
||||
inv, err := Inverse(a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A * A^-1 should be the identity.
|
||||
prod, err := MatMul(a, inv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
for j := 0; j < 2; j++ {
|
||||
want := 0.0
|
||||
if i == j {
|
||||
want = 1
|
||||
}
|
||||
if !almostEqual(prod[i][j], want, 1e-9) {
|
||||
t.Errorf("A*A^-1[%d][%d] = %v, want %v", i, j, prod[i][j], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInverseSingular(t *testing.T) {
|
||||
a := Matrix{{1, 2}, {2, 4}} // row 2 = 2 * row 1
|
||||
if _, err := Inverse(a); err == nil {
|
||||
t.Fatal("expected a singular-matrix error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDominantEigen(t *testing.T) {
|
||||
// [[2,0],[0,1]] has eigenvalues 2 and 1 — dominant is 2, eigenvector (1,0).
|
||||
a := Matrix{{2, 0}, {0, 1}}
|
||||
lambda, v, err := DominantEigen(a, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !almostEqual(lambda, 2, 1e-6) {
|
||||
t.Errorf("eigenvalue = %v, want 2", lambda)
|
||||
}
|
||||
if !almostEqual(math.Abs(v[0]), 1, 1e-6) || !almostEqual(v[1], 0, 1e-6) {
|
||||
t.Errorf("eigenvector = %v, want ~(1,0)", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivative(t *testing.T) {
|
||||
// d/dx[x^2] at x=3 is 6.
|
||||
e := mustExpr(t, "x^2")
|
||||
d, err := Derivative(e, "x", map[string]float64{"x": 3}, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !almostEqual(d, 6, 1e-4) {
|
||||
t.Errorf("derivative = %v, want 6", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGradient(t *testing.T) {
|
||||
// f(x,y) = x^2 + y^2 ; gradient at (3,4) is (6,8).
|
||||
e := mustExpr(t, "x^2 + y^2")
|
||||
g, err := Gradient(e, []string{"x", "y"}, map[string]float64{"x": 3, "y": 4}, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !almostEqual(g[0], 6, 1e-4) || !almostEqual(g[1], 8, 1e-4) {
|
||||
t.Errorf("gradient = %v, want [6 8]", g)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrate(t *testing.T) {
|
||||
// ∫0..1 x^2 dx = 1/3.
|
||||
e := mustExpr(t, "x^2")
|
||||
v, err := Integrate(e, "x", 0, 1, 100, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !almostEqual(v, 1.0/3.0, 1e-9) {
|
||||
t.Errorf("integral = %v, want 1/3", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGradientDescentConvergesToMinimum(t *testing.T) {
|
||||
// f(x) = (x-3)^2 has its minimum at x=3.
|
||||
e := mustExpr(t, "(x - 3)^2")
|
||||
res, err := GradientDescent(e, []string{"x"}, []float64{0}, 0.1, 200, 1e-10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
final := res.Path[len(res.Path)-1][0]
|
||||
if !almostEqual(final, 3, 1e-3) {
|
||||
t.Errorf("gradient descent converged to x=%v, want ~3", final)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewtonRootFindsSquareRoot(t *testing.T) {
|
||||
// Root of x^2 - 2 = 0 is sqrt(2).
|
||||
e := mustExpr(t, "x^2 - 2")
|
||||
res, err := NewtonRoot(e, "x", 1, 0, 0, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := res.Path[len(res.Path)-1]
|
||||
if !almostEqual(root, math.Sqrt2, 1e-6) {
|
||||
t.Errorf("newton root = %v, want %v", root, math.Sqrt2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatistics(t *testing.T) {
|
||||
data := []float64{2, 4, 4, 4, 5, 5, 7, 9}
|
||||
mean, _ := Mean(data)
|
||||
if !almostEqual(mean, 5, 1e-9) {
|
||||
t.Errorf("mean = %v, want 5", mean)
|
||||
}
|
||||
popVar, _ := Variance(data, true)
|
||||
if !almostEqual(popVar, 4, 1e-9) {
|
||||
t.Errorf("population variance = %v, want 4", popVar)
|
||||
}
|
||||
std, _ := StdDev(data, true)
|
||||
if !almostEqual(std, 2, 1e-9) {
|
||||
t.Errorf("population std_dev = %v, want 2", std)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCorrelationPerfect(t *testing.T) {
|
||||
x := []float64{1, 2, 3, 4, 5}
|
||||
y := []float64{2, 4, 6, 8, 10}
|
||||
c, err := Correlation(x, y)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !almostEqual(c, 1, 1e-9) {
|
||||
t.Errorf("correlation = %v, want 1", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalDistribution(t *testing.T) {
|
||||
// Standard normal at x=0: pdf = 1/sqrt(2*pi), cdf = 0.5.
|
||||
pdf, _ := NormalPDF(0, 0, 1)
|
||||
if !almostEqual(pdf, 1/math.Sqrt(2*math.Pi), 1e-9) {
|
||||
t.Errorf("pdf(0) = %v", pdf)
|
||||
}
|
||||
cdf, _ := NormalCDF(0, 0, 1)
|
||||
if !almostEqual(cdf, 0.5, 1e-9) {
|
||||
t.Errorf("cdf(0) = %v, want 0.5", cdf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSigmoidNumericallyStable(t *testing.T) {
|
||||
if v := Sigmoid(-1000); v != 0 {
|
||||
t.Errorf("sigmoid(-1000) = %v, want 0", v)
|
||||
}
|
||||
if v := Sigmoid(1000); !almostEqual(v, 1, 1e-12) {
|
||||
t.Errorf("sigmoid(1000) = %v, want ~1", v)
|
||||
}
|
||||
if v := Sigmoid(0); v != 0.5 {
|
||||
t.Errorf("sigmoid(0) = %v, want 0.5", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSoftmaxSumsToOne(t *testing.T) {
|
||||
out := Softmax([]float64{1, 2, 3})
|
||||
var sum float64
|
||||
for _, v := range out {
|
||||
sum += v
|
||||
}
|
||||
if !almostEqual(sum, 1, 1e-9) {
|
||||
t.Errorf("softmax sums to %v, want 1", sum)
|
||||
}
|
||||
// Largest logit should get the largest probability.
|
||||
if out[2] <= out[1] || out[1] <= out[0] {
|
||||
t.Errorf("softmax ordering wrong: %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMSELoss(t *testing.T) {
|
||||
v, err := MSELoss([]float64{1, 2, 3}, []float64{1, 2, 4})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// errors: 0, 0, -1 -> squared: 0,0,1 -> mean = 1/3
|
||||
if !almostEqual(v, 1.0/3.0, 1e-9) {
|
||||
t.Errorf("mse = %v, want 1/3", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinearRegressionPerfectFit(t *testing.T) {
|
||||
// y = 2 + 3x, exactly.
|
||||
X := Matrix{{1}, {2}, {3}, {4}, {5}}
|
||||
y := []float64{5, 8, 11, 14, 17}
|
||||
res, err := LinearRegression(X, y)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !almostEqual(res.Coefficients[0], 2, 1e-6) {
|
||||
t.Errorf("intercept = %v, want 2", res.Coefficients[0])
|
||||
}
|
||||
if !almostEqual(res.Coefficients[1], 3, 1e-6) {
|
||||
t.Errorf("slope = %v, want 3", res.Coefficients[1])
|
||||
}
|
||||
if !almostEqual(res.RSquared, 1, 1e-6) {
|
||||
t.Errorf("R^2 = %v, want 1", res.RSquared)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Solve dispatcher: the JSON-boundary layer the agent tool calls ---
|
||||
|
||||
func TestSolveEvaluate(t *testing.T) {
|
||||
out, err := Solve("evaluate", map[string]any{
|
||||
"expression": "x^2 + y^2",
|
||||
"variables": map[string]any{"x": 3.0, "y": 4.0},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "25") {
|
||||
t.Errorf("Solve(evaluate) = %q, want it to contain 25", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolveMatrixMul(t *testing.T) {
|
||||
out, err := Solve("matrix_mul", map[string]any{
|
||||
"a": []any{[]any{1.0, 2.0}, []any{3.0, 4.0}},
|
||||
"b": []any{[]any{5.0, 6.0}, []any{7.0, 8.0}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "19") || !strings.Contains(out, "50") {
|
||||
t.Errorf("Solve(matrix_mul) = %q, missing expected entries", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolveUnknownOperation(t *testing.T) {
|
||||
if _, err := Solve("not_a_real_operation", nil); err == nil {
|
||||
t.Fatal("expected an error for an unknown operation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolveGradientDescent(t *testing.T) {
|
||||
out, err := Solve("gradient_descent", map[string]any{
|
||||
"expression": "(x - 3)^2",
|
||||
"variables": []any{"x"},
|
||||
"start": []any{0.0},
|
||||
"learning_rate": 0.1,
|
||||
"iterations": 200.0,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "converged=true") {
|
||||
t.Errorf("Solve(gradient_descent) = %q, expected convergence", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolveMissingField(t *testing.T) {
|
||||
_, err := Solve("mean", map[string]any{})
|
||||
if err == nil {
|
||||
t.Fatal("expected a missing-field error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "data") {
|
||||
t.Errorf("error %q should mention the missing field", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperationsListIsSorted(t *testing.T) {
|
||||
ops := Operations()
|
||||
if len(ops) == 0 {
|
||||
t.Fatal("Operations() returned nothing")
|
||||
}
|
||||
for i := 1; i < len(ops); i++ {
|
||||
if ops[i-1] > ops[i] {
|
||||
t.Fatalf("Operations() not sorted: %q before %q", ops[i-1], ops[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
191
pkg/mathkit/ml.go
Normal file
191
pkg/mathkit/ml.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package mathkit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// Sigmoid is the logistic function 1 / (1 + e^-x), computed in the
|
||||
// numerically stable branch-on-sign form so it doesn't overflow
|
||||
// math.Exp for very negative x (a plain 1/(1+exp(-x)) blows up
|
||||
// exp(-x) to +Inf for x below about -745 and silently returns 0
|
||||
// instead of propagating the overflow visibly).
|
||||
func Sigmoid(x float64) float64 {
|
||||
if x >= 0 {
|
||||
z := math.Exp(-x)
|
||||
return 1 / (1 + z)
|
||||
}
|
||||
z := math.Exp(x)
|
||||
return z / (1 + z)
|
||||
}
|
||||
|
||||
// ReLU is max(0, x).
|
||||
func ReLU(x float64) float64 {
|
||||
if x > 0 {
|
||||
return x
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Softmax turns a vector of raw scores ("logits") into a probability
|
||||
// distribution: non-negative entries that sum to 1. Subtracts the max
|
||||
// before exponentiating (the standard numerically-stable formulation)
|
||||
// so large logits don't overflow math.Exp.
|
||||
func Softmax(x []float64) []float64 {
|
||||
if len(x) == 0 {
|
||||
return nil
|
||||
}
|
||||
max := x[0]
|
||||
for _, v := range x[1:] {
|
||||
if v > max {
|
||||
max = v
|
||||
}
|
||||
}
|
||||
out := make([]float64, len(x))
|
||||
var sum float64
|
||||
for i, v := range x {
|
||||
e := math.Exp(v - max)
|
||||
out[i] = e
|
||||
sum += e
|
||||
}
|
||||
for i := range out {
|
||||
out[i] /= sum
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MSELoss returns the mean squared error between predictions and
|
||||
// targets: mean((pred - target)^2), the default regression loss.
|
||||
func MSELoss(predictions, targets []float64) (float64, error) {
|
||||
if len(predictions) != len(targets) {
|
||||
return 0, fmt.Errorf("mse_loss: predictions and targets have different lengths (%d vs %d)", len(predictions), len(targets))
|
||||
}
|
||||
if len(predictions) == 0 {
|
||||
return 0, fmt.Errorf("mse_loss: empty input")
|
||||
}
|
||||
var sum float64
|
||||
for i := range predictions {
|
||||
d := predictions[i] - targets[i]
|
||||
sum += d * d
|
||||
}
|
||||
return sum / float64(len(predictions)), nil
|
||||
}
|
||||
|
||||
// crossEntropyEps clamps predicted probabilities away from exactly 0
|
||||
// or 1 before taking a log — ln(0) is -Inf, which would make a single
|
||||
// confidently-wrong prediction blow up the whole averaged loss to
|
||||
// +Inf instead of just contributing a large-but-finite penalty. Every
|
||||
// production cross-entropy implementation (e.g. scikit-learn's
|
||||
// log_loss) clips for exactly this reason.
|
||||
const crossEntropyEps = 1e-12
|
||||
|
||||
// CrossEntropyLoss returns the mean binary cross-entropy between
|
||||
// predicted probabilities and 0/1 (or soft, in [0,1]) targets:
|
||||
// mean(-(y*ln(p) + (1-y)*ln(1-p))), the default classification loss.
|
||||
func CrossEntropyLoss(predictions, targets []float64) (float64, error) {
|
||||
if len(predictions) != len(targets) {
|
||||
return 0, fmt.Errorf("cross_entropy_loss: predictions and targets have different lengths (%d vs %d)", len(predictions), len(targets))
|
||||
}
|
||||
if len(predictions) == 0 {
|
||||
return 0, fmt.Errorf("cross_entropy_loss: empty input")
|
||||
}
|
||||
var sum float64
|
||||
for i, p := range predictions {
|
||||
if p < 0 || p > 1 {
|
||||
return 0, fmt.Errorf("cross_entropy_loss: predictions must be probabilities in [0, 1], got %g at index %d", p, i)
|
||||
}
|
||||
p = math.Min(math.Max(p, crossEntropyEps), 1-crossEntropyEps)
|
||||
y := targets[i]
|
||||
sum += -(y*math.Log(p) + (1-y)*math.Log(1-p))
|
||||
}
|
||||
return sum / float64(len(predictions)), nil
|
||||
}
|
||||
|
||||
// LinearRegressionResult is a fitted ordinary-least-squares linear
|
||||
// model.
|
||||
type LinearRegressionResult struct {
|
||||
// Coefficients[0] is the intercept, Coefficients[1:] line up with
|
||||
// the feature columns of X in order.
|
||||
Coefficients []float64
|
||||
Predictions []float64
|
||||
Residuals []float64
|
||||
RSquared float64
|
||||
}
|
||||
|
||||
// LinearRegression fits y ≈ X·β + intercept by ordinary least squares
|
||||
// via the normal equations β = (XᵀX)^-1 Xᵀy, with an intercept column
|
||||
// of 1s prepended automatically (the caller supplies only the real
|
||||
// feature columns in X). The normal-equation approach is the direct,
|
||||
// closed-form solution to minimizing squared error — the textbook
|
||||
// ML-101 regression fit, as opposed to reaching it iteratively via
|
||||
// GradientDescent on the MSE loss.
|
||||
func LinearRegression(X Matrix, y []float64) (*LinearRegressionResult, error) {
|
||||
rows, cols, err := X.Dims()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("linear_regression: %w", err)
|
||||
}
|
||||
if rows != len(y) {
|
||||
return nil, fmt.Errorf("linear_regression: X has %d rows but y has %d entries", rows, len(y))
|
||||
}
|
||||
if rows <= cols {
|
||||
return nil, fmt.Errorf("linear_regression: need more observations (%d) than features+1 (%d) to fit uniquely", rows, cols+1)
|
||||
}
|
||||
|
||||
// Prepend the intercept column of 1s.
|
||||
design := make(Matrix, rows)
|
||||
for i := 0; i < rows; i++ {
|
||||
row := make([]float64, cols+1)
|
||||
row[0] = 1
|
||||
copy(row[1:], X[i])
|
||||
design[i] = row
|
||||
}
|
||||
|
||||
designT, err := Transpose(design)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("linear_regression: %w", err)
|
||||
}
|
||||
xtx, err := MatMul(designT, design)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("linear_regression: %w", err)
|
||||
}
|
||||
xtxInv, err := Inverse(xtx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("linear_regression: features are collinear (XᵀX is singular): %w", err)
|
||||
}
|
||||
xty, err := MatVecMul(designT, y)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("linear_regression: %w", err)
|
||||
}
|
||||
beta, err := MatVecMul(xtxInv, xty)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("linear_regression: %w", err)
|
||||
}
|
||||
|
||||
preds, err := MatVecMul(design, beta)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("linear_regression: %w", err)
|
||||
}
|
||||
residuals := make([]float64, rows)
|
||||
var ssRes float64
|
||||
for i := range preds {
|
||||
residuals[i] = y[i] - preds[i]
|
||||
ssRes += residuals[i] * residuals[i]
|
||||
}
|
||||
meanY, _ := Mean(y)
|
||||
var ssTot float64
|
||||
for _, v := range y {
|
||||
d := v - meanY
|
||||
ssTot += d * d
|
||||
}
|
||||
rSquared := 1.0
|
||||
if ssTot > 0 {
|
||||
rSquared = 1 - ssRes/ssTot
|
||||
}
|
||||
|
||||
return &LinearRegressionResult{
|
||||
Coefficients: beta,
|
||||
Predictions: preds,
|
||||
Residuals: residuals,
|
||||
RSquared: rSquared,
|
||||
}, nil
|
||||
}
|
||||
135
pkg/mathkit/optimize.go
Normal file
135
pkg/mathkit/optimize.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package mathkit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// GradientDescentResult is the trace of one gradient-descent run.
|
||||
type GradientDescentResult struct {
|
||||
// Path holds the point visited at the start of every iteration,
|
||||
// Path[0] being the caller's starting point and the last entry
|
||||
// being the final point — so len(Path) == Iterations+1. Kept in
|
||||
// full (rather than just the final point) because seeing the
|
||||
// trajectory is usually the point of running this: it's how you
|
||||
// tell "converging nicely" apart from "diverging" or
|
||||
// "oscillating because the learning rate is too high".
|
||||
Path [][]float64
|
||||
// Values holds f(Path[i]) for each point in Path, same length.
|
||||
Values []float64
|
||||
// Converged is true if the gradient norm dropped below the
|
||||
// requested tolerance before Iterations ran out.
|
||||
Converged bool
|
||||
}
|
||||
|
||||
// GradientDescent runs plain (batch) gradient descent on expr, over
|
||||
// the variables listed (in order — that order defines the vector
|
||||
// space the descent happens in), starting at `start`, for at most
|
||||
// `iterations` steps of size `learningRate`. tol<=0 disables the
|
||||
// early-stop check (it always runs the full iteration count).
|
||||
//
|
||||
// This is deliberately vanilla gradient descent — no momentum, no
|
||||
// Adam-style adaptive rates — because the point of this tool is to
|
||||
// let a user see and reason about the bare update rule
|
||||
// x := x - lr * ∇f(x) that every fancier optimizer builds on, not to
|
||||
// re-implement a training framework.
|
||||
func GradientDescent(expr *Expr, variables []string, start []float64, learningRate float64, iterations int, tol float64) (*GradientDescentResult, error) {
|
||||
if len(variables) != len(start) {
|
||||
return nil, fmt.Errorf("gradient_descent: %d variable name(s) but start point has %d coordinate(s)", len(variables), len(start))
|
||||
}
|
||||
if learningRate <= 0 {
|
||||
return nil, fmt.Errorf("gradient_descent: learning_rate must be positive")
|
||||
}
|
||||
if iterations <= 0 {
|
||||
iterations = 100
|
||||
}
|
||||
point := append([]float64(nil), start...)
|
||||
res := &GradientDescentResult{}
|
||||
addPoint := func(p []float64) error {
|
||||
vars := make(map[string]float64, len(variables))
|
||||
for i, name := range variables {
|
||||
vars[name] = p[i]
|
||||
}
|
||||
v, err := expr.Eval(vars)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res.Path = append(res.Path, append([]float64(nil), p...))
|
||||
res.Values = append(res.Values, v)
|
||||
return nil
|
||||
}
|
||||
if err := addPoint(point); err != nil {
|
||||
return nil, fmt.Errorf("gradient_descent: evaluating start point: %w", err)
|
||||
}
|
||||
vars := make(map[string]float64, len(variables))
|
||||
for iter := 0; iter < iterations; iter++ {
|
||||
for i, name := range variables {
|
||||
vars[name] = point[i]
|
||||
}
|
||||
grad, err := Gradient(expr, variables, vars, 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gradient_descent: iteration %d: %w", iter, err)
|
||||
}
|
||||
if tol > 0 && Norm(grad, 2) < tol {
|
||||
res.Converged = true
|
||||
break
|
||||
}
|
||||
for i := range point {
|
||||
point[i] -= learningRate * grad[i]
|
||||
}
|
||||
if err := addPoint(point); err != nil {
|
||||
return nil, fmt.Errorf("gradient_descent: iteration %d: %w", iter, err)
|
||||
}
|
||||
if math.IsNaN(point[0]) || math.IsInf(point[0], 0) {
|
||||
return nil, fmt.Errorf("gradient_descent: diverged (values went to NaN/Inf) — try a smaller learning_rate")
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// NewtonRootResult is the trace of one Newton's-method root-finding
|
||||
// run on a single-variable expression.
|
||||
type NewtonRootResult struct {
|
||||
Path []float64 // x_0, x_1, ..., x_final
|
||||
Converged bool
|
||||
}
|
||||
|
||||
// NewtonRoot finds a root of expr(variable) = 0 via Newton's method:
|
||||
// x_{n+1} = x_n - f(x_n)/f'(x_n), with f' estimated by central
|
||||
// difference (see Derivative). Any other free variables in expr must
|
||||
// be supplied in `fixed`. tol<=0 defaults to 1e-10.
|
||||
func NewtonRoot(expr *Expr, variable string, start float64, iterations int, tol float64, fixed map[string]float64) (*NewtonRootResult, error) {
|
||||
if iterations <= 0 {
|
||||
iterations = 100
|
||||
}
|
||||
if tol <= 0 {
|
||||
tol = 1e-10
|
||||
}
|
||||
x := start
|
||||
res := &NewtonRootResult{Path: []float64{x}}
|
||||
vars := cloneVars(fixed)
|
||||
for iter := 0; iter < iterations; iter++ {
|
||||
vars[variable] = x
|
||||
fx, err := expr.Eval(vars)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("newton_root: iteration %d: %w", iter, err)
|
||||
}
|
||||
if math.Abs(fx) < tol {
|
||||
res.Converged = true
|
||||
break
|
||||
}
|
||||
dfx, err := Derivative(expr, variable, vars, 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("newton_root: iteration %d: %w", iter, err)
|
||||
}
|
||||
if math.Abs(dfx) < 1e-14 {
|
||||
return nil, fmt.Errorf("newton_root: derivative vanished near x=%g (flat spot) — try a different start", x)
|
||||
}
|
||||
x = x - fx/dfx
|
||||
res.Path = append(res.Path, x)
|
||||
if math.IsNaN(x) || math.IsInf(x, 0) {
|
||||
return nil, fmt.Errorf("newton_root: diverged (x went to NaN/Inf) — try a different start")
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
835
pkg/mathkit/solve.go
Normal file
835
pkg/mathkit/solve.go
Normal file
@@ -0,0 +1,835 @@
|
||||
package mathkit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Solve dispatches a math problem by string operation name against a
|
||||
// loosely-typed params map — exactly the shape a JSON object (an LLM
|
||||
// tool-call argument blob, decoded with encoding/json into
|
||||
// map[string]any) takes once decoded. It exists as one layer above
|
||||
// the typed functions in linalg.go/calculus.go/statistics.go/
|
||||
// optimize.go/ml.go specifically so a caller at the JSON boundary
|
||||
// (services/agent/internal/infrastructure/toolexec/mlmath.go) has one
|
||||
// function to call and one error type to handle, instead of a type
|
||||
// switch per operation.
|
||||
//
|
||||
// Returns a human-readable report (not raw JSON or a bare float) on
|
||||
// success, because the caller here is ultimately an LLM deciding what
|
||||
// to do next — a labeled, explained answer is directly usable in its
|
||||
// response, where a bare number would have to be re-explained.
|
||||
func Solve(operation string, params map[string]any) (string, error) {
|
||||
op := strings.ToLower(strings.TrimSpace(operation))
|
||||
switch op {
|
||||
// --- general ---
|
||||
case "evaluate":
|
||||
return solveEvaluate(params)
|
||||
|
||||
// --- linear algebra ---
|
||||
case "matrix_add", "matrix_sub", "matrix_mul":
|
||||
return solveMatrixBinOp(op, params)
|
||||
case "matrix_transpose":
|
||||
return solveMatrixTranspose(params)
|
||||
case "matrix_determinant":
|
||||
return solveMatrixDeterminant(params)
|
||||
case "matrix_inverse":
|
||||
return solveMatrixInverse(params)
|
||||
case "matrix_rank":
|
||||
return solveMatrixRank(params)
|
||||
case "matrix_trace":
|
||||
return solveMatrixTrace(params)
|
||||
case "dot_product":
|
||||
return solveDotProduct(params)
|
||||
case "vector_norm":
|
||||
return solveVectorNorm(params)
|
||||
case "dominant_eigen":
|
||||
return solveDominantEigen(params)
|
||||
|
||||
// --- calculus ---
|
||||
case "derivative":
|
||||
return solveDerivative(params)
|
||||
case "gradient":
|
||||
return solveGradient(params)
|
||||
case "integral":
|
||||
return solveIntegral(params)
|
||||
|
||||
// --- optimization ---
|
||||
case "gradient_descent":
|
||||
return solveGradientDescent(params)
|
||||
case "newton_root":
|
||||
return solveNewtonRoot(params)
|
||||
|
||||
// --- statistics & probability ---
|
||||
case "mean":
|
||||
return solveReduce1(params, "mean", func(d []float64) (float64, error) { return Mean(d) })
|
||||
case "variance":
|
||||
return solveReduce1Pop(params, "variance", Variance)
|
||||
case "std_dev":
|
||||
return solveReduce1Pop(params, "std_dev", StdDev)
|
||||
case "median":
|
||||
return solveReduce1(params, "median", Median)
|
||||
case "covariance":
|
||||
return solveReduce2Pop(params, "covariance", Covariance)
|
||||
case "correlation":
|
||||
return solveReduce2(params, "correlation", Correlation)
|
||||
case "z_score_normalize":
|
||||
return solveNormalize(params, "z_score_normalize", ZScoreNormalize)
|
||||
case "min_max_normalize":
|
||||
return solveNormalize(params, "min_max_normalize", MinMaxNormalize)
|
||||
case "normal_pdf":
|
||||
return solveNormalDist(params, "normal_pdf", NormalPDF)
|
||||
case "normal_cdf":
|
||||
return solveNormalDist(params, "normal_cdf", NormalCDF)
|
||||
case "binomial_pmf":
|
||||
return solveBinomialPMF(params)
|
||||
|
||||
// --- ML primitives ---
|
||||
case "sigmoid":
|
||||
return solveElementwise(params, "sigmoid", Sigmoid)
|
||||
case "relu":
|
||||
return solveElementwise(params, "relu", ReLU)
|
||||
case "tanh":
|
||||
return solveElementwise(params, "tanh", math.Tanh)
|
||||
case "softmax":
|
||||
return solveSoftmax(params)
|
||||
case "mse_loss":
|
||||
return solveLoss(params, "mse_loss", MSELoss)
|
||||
case "cross_entropy_loss":
|
||||
return solveLoss(params, "cross_entropy_loss", CrossEntropyLoss)
|
||||
case "linear_regression":
|
||||
return solveLinearRegression(params)
|
||||
|
||||
default:
|
||||
return "", fmt.Errorf("unknown operation %q — see the tool description for the full list of supported operations", operation)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- param extraction helpers ----------
|
||||
//
|
||||
// Every helper below reports a specific, actionable error (which
|
||||
// field was wrong and why) rather than a generic type-assertion
|
||||
// panic recovery message, because these errors are fed straight back
|
||||
// to the calling model as the tool result — see toolexec's rpcFail /
|
||||
// IsError convention — and "field X must be a number, got a string"
|
||||
// is something the model can act on; "interface conversion failed"
|
||||
// is not.
|
||||
|
||||
func reqFloat(params map[string]any, key string) (float64, error) {
|
||||
v, ok := params[key]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("missing required field %q", key)
|
||||
}
|
||||
f, ok := toFloat(v)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("field %q must be a number", key)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func optFloat(params map[string]any, key string, fallback float64) float64 {
|
||||
v, ok := params[key]
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
if f, ok := toFloat(v); ok {
|
||||
return f
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func optInt(params map[string]any, key string, fallback int) int {
|
||||
v, ok := params[key]
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
if f, ok := toFloat(v); ok {
|
||||
return int(f)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func optBool(params map[string]any, key string, fallback bool) bool {
|
||||
v, ok := params[key]
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
if b, ok := v.(bool); ok {
|
||||
return b
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func toFloat(v any) (float64, bool) {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return n, true
|
||||
case int:
|
||||
return float64(n), true
|
||||
case int64:
|
||||
return float64(n), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func reqString(params map[string]any, key string) (string, error) {
|
||||
v, ok := params[key]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("missing required field %q", key)
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok || strings.TrimSpace(s) == "" {
|
||||
return "", fmt.Errorf("field %q must be a non-empty string", key)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func optString(params map[string]any, key, fallback string) string {
|
||||
v, ok := params[key]
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func reqFloatSlice(params map[string]any, key string) ([]float64, error) {
|
||||
v, ok := params[key]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("missing required field %q (expected an array of numbers)", key)
|
||||
}
|
||||
raw, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("field %q must be an array of numbers", key)
|
||||
}
|
||||
out := make([]float64, len(raw))
|
||||
for i, x := range raw {
|
||||
f, ok := toFloat(x)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("field %q: element %d is not a number", key, i)
|
||||
}
|
||||
out[i] = f
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func reqStringSlice(params map[string]any, key string) ([]string, error) {
|
||||
v, ok := params[key]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("missing required field %q (expected an array of strings)", key)
|
||||
}
|
||||
raw, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("field %q must be an array of strings", key)
|
||||
}
|
||||
out := make([]string, len(raw))
|
||||
for i, x := range raw {
|
||||
s, ok := x.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("field %q: element %d is not a string", key, i)
|
||||
}
|
||||
out[i] = s
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func reqMatrix(params map[string]any, key string) (Matrix, error) {
|
||||
v, ok := params[key]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("missing required field %q (expected a 2D array of numbers)", key)
|
||||
}
|
||||
rows, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("field %q must be a 2D array of numbers, e.g. [[1,2],[3,4]]", key)
|
||||
}
|
||||
m := make(Matrix, len(rows))
|
||||
for i, r := range rows {
|
||||
rowRaw, ok := r.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("field %q: row %d is not an array", key, i)
|
||||
}
|
||||
row := make([]float64, len(rowRaw))
|
||||
for j, x := range rowRaw {
|
||||
f, ok := toFloat(x)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("field %q: row %d, column %d is not a number", key, i, j)
|
||||
}
|
||||
row[j] = f
|
||||
}
|
||||
m[i] = row
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// varMap builds the variable bindings an expression needs from an
|
||||
// "at" (or "variables") object field — {"x": 1, "y": 2} — falling
|
||||
// back to an empty map if the field is absent (a single-variable
|
||||
// expression evaluated via the "variable"/"at" pair still works).
|
||||
func varMap(params map[string]any, key string) (map[string]float64, error) {
|
||||
v, ok := params[key]
|
||||
if !ok {
|
||||
return map[string]float64{}, nil
|
||||
}
|
||||
raw, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("field %q must be an object mapping variable names to numbers, e.g. {\"x\": 1.5}", key)
|
||||
}
|
||||
out := make(map[string]float64, len(raw))
|
||||
for k, x := range raw {
|
||||
f, ok := toFloat(x)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("field %q: value for %q is not a number", key, k)
|
||||
}
|
||||
out[k] = f
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseExprField(params map[string]any) (*Expr, error) {
|
||||
src, err := reqString(params, "expression")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expr, err := ParseExpr(src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return expr, nil
|
||||
}
|
||||
|
||||
func fmtFloat(f float64) string {
|
||||
if math.IsNaN(f) {
|
||||
return "NaN"
|
||||
}
|
||||
if math.IsInf(f, 1) {
|
||||
return "+Inf"
|
||||
}
|
||||
if math.IsInf(f, -1) {
|
||||
return "-Inf"
|
||||
}
|
||||
return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.10f", f), "0"), ".")
|
||||
}
|
||||
|
||||
func fmtFloats(fs []float64) string {
|
||||
parts := make([]string, len(fs))
|
||||
for i, f := range fs {
|
||||
parts[i] = fmtFloat(f)
|
||||
}
|
||||
return "[" + strings.Join(parts, ", ") + "]"
|
||||
}
|
||||
|
||||
func fmtMatrix(m Matrix) string {
|
||||
var sb strings.Builder
|
||||
for _, row := range m {
|
||||
sb.WriteString(fmtFloats(row))
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
return strings.TrimRight(sb.String(), "\n")
|
||||
}
|
||||
|
||||
// ---------- operation implementations ----------
|
||||
|
||||
func solveEvaluate(params map[string]any) (string, error) {
|
||||
expr, err := parseExprField(params)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
vars, err := varMap(params, "variables")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
result, err := expr.Eval(vars)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%s = %s", expr.String(), fmtFloat(result)), nil
|
||||
}
|
||||
|
||||
func solveMatrixBinOp(op string, params map[string]any) (string, error) {
|
||||
a, err := reqMatrix(params, "a")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b, err := reqMatrix(params, "b")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var out Matrix
|
||||
switch op {
|
||||
case "matrix_add":
|
||||
out, err = MatAdd(a, b)
|
||||
case "matrix_sub":
|
||||
out, err = MatSub(a, b)
|
||||
case "matrix_mul":
|
||||
out, err = MatMul(a, b)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%s result:\n%s", op, fmtMatrix(out)), nil
|
||||
}
|
||||
|
||||
func solveMatrixTranspose(params map[string]any) (string, error) {
|
||||
a, err := reqMatrix(params, "a")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
out, err := Transpose(a)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("transpose result:\n%s", fmtMatrix(out)), nil
|
||||
}
|
||||
|
||||
func solveMatrixDeterminant(params map[string]any) (string, error) {
|
||||
a, err := reqMatrix(params, "a")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
det, err := Determinant(a)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("det(A) = %s", fmtFloat(det)), nil
|
||||
}
|
||||
|
||||
func solveMatrixInverse(params map[string]any) (string, error) {
|
||||
a, err := reqMatrix(params, "a")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
inv, err := Inverse(a)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("A^-1:\n%s", fmtMatrix(inv)), nil
|
||||
}
|
||||
|
||||
func solveMatrixRank(params map[string]any) (string, error) {
|
||||
a, err := reqMatrix(params, "a")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
r, err := Rank(a)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("rank(A) = %d", r), nil
|
||||
}
|
||||
|
||||
func solveMatrixTrace(params map[string]any) (string, error) {
|
||||
a, err := reqMatrix(params, "a")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tr, err := Trace(a)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("trace(A) = %s", fmtFloat(tr)), nil
|
||||
}
|
||||
|
||||
func solveDotProduct(params map[string]any) (string, error) {
|
||||
a, err := reqFloatSlice(params, "a")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b, err := reqFloatSlice(params, "b")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
d, err := Dot(a, b)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("a · b = %s", fmtFloat(d)), nil
|
||||
}
|
||||
|
||||
func solveVectorNorm(params map[string]any) (string, error) {
|
||||
a, err := reqFloatSlice(params, "a")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
p := optFloat(params, "p", 2)
|
||||
n := Norm(a, p)
|
||||
label := fmt.Sprintf("L%g", p)
|
||||
if math.IsInf(p, 1) {
|
||||
label = "L∞"
|
||||
}
|
||||
return fmt.Sprintf("%s norm = %s", label, fmtFloat(n)), nil
|
||||
}
|
||||
|
||||
func solveDominantEigen(params map[string]any) (string, error) {
|
||||
a, err := reqMatrix(params, "a")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
iters := optInt(params, "iterations", 0)
|
||||
lambda, v, err := DominantEigen(a, iters)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("dominant eigenvalue ≈ %s\neigenvector ≈ %s", fmtFloat(lambda), fmtFloats(v)), nil
|
||||
}
|
||||
|
||||
func solveDerivative(params map[string]any) (string, error) {
|
||||
expr, err := parseExprField(params)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
variable, err := reqString(params, "variable")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
at, err := varMap(params, "at")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, ok := at[variable]; !ok {
|
||||
at[variable] = optFloat(params, "value", 0)
|
||||
}
|
||||
h := optFloat(params, "h", 0)
|
||||
d, err := Derivative(expr, variable, at, h)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("d/d%s[%s] at %s=%s ≈ %s", variable, expr.String(), variable, fmtFloat(at[variable]), fmtFloat(d)), nil
|
||||
}
|
||||
|
||||
func solveGradient(params map[string]any) (string, error) {
|
||||
expr, err := parseExprField(params)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
variables, err := reqStringSlice(params, "variables")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
at, err := varMap(params, "at")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
h := optFloat(params, "h", 0)
|
||||
grad, err := Gradient(expr, variables, at, h)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("∇[%s] with respect to %s ≈ %s", expr.String(), strings.Join(variables, ","), fmtFloats(grad)), nil
|
||||
}
|
||||
|
||||
func solveIntegral(params map[string]any) (string, error) {
|
||||
expr, err := parseExprField(params)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
variable, err := reqString(params, "variable")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
from, err := reqFloat(params, "from")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
to, err := reqFloat(params, "to")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
steps := optInt(params, "steps", 0)
|
||||
fixed, err := varMap(params, "variables")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
result, err := Integrate(expr, variable, from, to, steps, fixed)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("∫[%s to %s] %s d%s ≈ %s", fmtFloat(from), fmtFloat(to), expr.String(), variable, fmtFloat(result)), nil
|
||||
}
|
||||
|
||||
func solveGradientDescent(params map[string]any) (string, error) {
|
||||
expr, err := parseExprField(params)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
variables, err := reqStringSlice(params, "variables")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
start, err := reqFloatSlice(params, "start")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
lr, err := reqFloat(params, "learning_rate")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
iterations := optInt(params, "iterations", 100)
|
||||
tol := optFloat(params, "tolerance", 1e-8)
|
||||
res, err := GradientDescent(expr, variables, start, lr, iterations, tol)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
last := len(res.Path) - 1
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, "gradient descent on f(%s) = %s\n", strings.Join(variables, ","), expr.String())
|
||||
fmt.Fprintf(&sb, "start: %s -> f = %s\n", fmtFloats(res.Path[0]), fmtFloat(res.Values[0]))
|
||||
fmt.Fprintf(&sb, "ran %d step(s), converged=%v\n", last, res.Converged)
|
||||
fmt.Fprintf(&sb, "final: %s -> f = %s\n", fmtFloats(res.Path[last]), fmtFloat(res.Values[last]))
|
||||
// Show a short trajectory sample so the model can see the descent
|
||||
// shape without a huge wall of numbers for large iteration counts.
|
||||
sampleN := 5
|
||||
if last+1 <= sampleN*2 {
|
||||
for i := 0; i <= last; i++ {
|
||||
fmt.Fprintf(&sb, " step %d: x=%s f=%s\n", i, fmtFloats(res.Path[i]), fmtFloat(res.Values[i]))
|
||||
}
|
||||
} else {
|
||||
for i := 0; i < sampleN; i++ {
|
||||
fmt.Fprintf(&sb, " step %d: x=%s f=%s\n", i, fmtFloats(res.Path[i]), fmtFloat(res.Values[i]))
|
||||
}
|
||||
sb.WriteString(" ...\n")
|
||||
for i := last - sampleN + 1; i <= last; i++ {
|
||||
fmt.Fprintf(&sb, " step %d: x=%s f=%s\n", i, fmtFloats(res.Path[i]), fmtFloat(res.Values[i]))
|
||||
}
|
||||
}
|
||||
return strings.TrimRight(sb.String(), "\n"), nil
|
||||
}
|
||||
|
||||
func solveNewtonRoot(params map[string]any) (string, error) {
|
||||
expr, err := parseExprField(params)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
variable, err := reqString(params, "variable")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
start, err := reqFloat(params, "start")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
iterations := optInt(params, "iterations", 0)
|
||||
tol := optFloat(params, "tolerance", 0)
|
||||
fixed, err := varMap(params, "variables")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
res, err := NewtonRoot(expr, variable, start, iterations, tol, fixed)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
last := res.Path[len(res.Path)-1]
|
||||
return fmt.Sprintf("newton's method on %s(%s)=0: converged=%v after %d step(s), root ≈ %s=%s",
|
||||
expr.String(), variable, res.Converged, len(res.Path)-1, variable, fmtFloat(last)), nil
|
||||
}
|
||||
|
||||
func solveReduce1(params map[string]any, name string, f func([]float64) (float64, error)) (string, error) {
|
||||
data, err := reqFloatSlice(params, "data")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
v, err := f(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%s(%s) = %s", name, fmtFloats(data), fmtFloat(v)), nil
|
||||
}
|
||||
|
||||
func solveReduce1Pop(params map[string]any, name string, f func([]float64, bool) (float64, error)) (string, error) {
|
||||
data, err := reqFloatSlice(params, "data")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
population := optBool(params, "population", false)
|
||||
v, err := f(data, population)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
kind := "sample"
|
||||
if population {
|
||||
kind = "population"
|
||||
}
|
||||
return fmt.Sprintf("%s %s(%s) = %s", kind, name, fmtFloats(data), fmtFloat(v)), nil
|
||||
}
|
||||
|
||||
func solveReduce2(params map[string]any, name string, f func([]float64, []float64) (float64, error)) (string, error) {
|
||||
x, err := reqFloatSlice(params, "x")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
y, err := reqFloatSlice(params, "y")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
v, err := f(x, y)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%s(x, y) = %s", name, fmtFloat(v)), nil
|
||||
}
|
||||
|
||||
func solveReduce2Pop(params map[string]any, name string, f func([]float64, []float64, bool) (float64, error)) (string, error) {
|
||||
x, err := reqFloatSlice(params, "x")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
y, err := reqFloatSlice(params, "y")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
population := optBool(params, "population", false)
|
||||
v, err := f(x, y, population)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
kind := "sample"
|
||||
if population {
|
||||
kind = "population"
|
||||
}
|
||||
return fmt.Sprintf("%s %s(x, y) = %s", kind, name, fmtFloat(v)), nil
|
||||
}
|
||||
|
||||
func solveNormalize(params map[string]any, name string, f func([]float64) ([]float64, error)) (string, error) {
|
||||
data, err := reqFloatSlice(params, "data")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
out, err := f(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%s(%s) = %s", name, fmtFloats(data), fmtFloats(out)), nil
|
||||
}
|
||||
|
||||
func solveNormalDist(params map[string]any, name string, f func(float64, float64, float64) (float64, error)) (string, error) {
|
||||
x, err := reqFloat(params, "x")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
mean := optFloat(params, "mean", 0)
|
||||
std := optFloat(params, "std_dev", 1)
|
||||
v, err := f(x, mean, std)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%s(x=%s, mean=%s, std_dev=%s) = %s", name, fmtFloat(x), fmtFloat(mean), fmtFloat(std), fmtFloat(v)), nil
|
||||
}
|
||||
|
||||
func solveBinomialPMF(params map[string]any) (string, error) {
|
||||
k := optInt(params, "k", -1)
|
||||
if k < 0 {
|
||||
return "", fmt.Errorf("missing required field %q", "k")
|
||||
}
|
||||
n := optInt(params, "n", -1)
|
||||
if n < 0 {
|
||||
return "", fmt.Errorf("missing required field %q", "n")
|
||||
}
|
||||
p, err := reqFloat(params, "p")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
v, err := BinomialPMF(k, n, p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("P(X=%d) for X~Binomial(n=%d, p=%s) = %s", k, n, fmtFloat(p), fmtFloat(v)), nil
|
||||
}
|
||||
|
||||
func solveElementwise(params map[string]any, name string, f func(float64) float64) (string, error) {
|
||||
if raw, ok := params["x"].([]any); ok {
|
||||
data := make([]float64, len(raw))
|
||||
for i, v := range raw {
|
||||
fv, ok := toFloat(v)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("field \"x\": element %d is not a number", i)
|
||||
}
|
||||
data[i] = fv
|
||||
}
|
||||
out := make([]float64, len(data))
|
||||
for i, v := range data {
|
||||
out[i] = f(v)
|
||||
}
|
||||
return fmt.Sprintf("%s(%s) = %s", name, fmtFloats(data), fmtFloats(out)), nil
|
||||
}
|
||||
x, err := reqFloat(params, "x")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf(`field "x" must be a number or an array of numbers: %w`, err)
|
||||
}
|
||||
return fmt.Sprintf("%s(%s) = %s", name, fmtFloat(x), fmtFloat(f(x))), nil
|
||||
}
|
||||
|
||||
func solveSoftmax(params map[string]any) (string, error) {
|
||||
x, err := reqFloatSlice(params, "x")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
out := Softmax(x)
|
||||
sum := 0.0
|
||||
for _, v := range out {
|
||||
sum += v
|
||||
}
|
||||
return fmt.Sprintf("softmax(%s) = %s (sums to %s)", fmtFloats(x), fmtFloats(out), fmtFloat(sum)), nil
|
||||
}
|
||||
|
||||
func solveLoss(params map[string]any, name string, f func([]float64, []float64) (float64, error)) (string, error) {
|
||||
pred, err := reqFloatSlice(params, "predictions")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
target, err := reqFloatSlice(params, "targets")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
v, err := f(pred, target)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%s(predictions, targets) = %s", name, fmtFloat(v)), nil
|
||||
}
|
||||
|
||||
func solveLinearRegression(params map[string]any) (string, error) {
|
||||
X, err := reqMatrix(params, "x")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
y, err := reqFloatSlice(params, "y")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
res, err := LinearRegression(X, y)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, "ordinary least squares fit: y ≈ %s", fmtFloat(res.Coefficients[0]))
|
||||
for i, c := range res.Coefficients[1:] {
|
||||
fmt.Fprintf(&sb, " + %s*x%d", fmtFloat(c), i+1)
|
||||
}
|
||||
fmt.Fprintf(&sb, "\nR² = %s\n", fmtFloat(res.RSquared))
|
||||
fmt.Fprintf(&sb, "predictions: %s\n", fmtFloats(res.Predictions))
|
||||
fmt.Fprintf(&sb, "residuals: %s", fmtFloats(res.Residuals))
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
// Operations returns the sorted list of every operation name Solve
|
||||
// accepts — used by the agent's tool-catalog description so the
|
||||
// supported operation list is generated from the real dispatch table
|
||||
// instead of hand-copied prose that can drift out of sync with it.
|
||||
func Operations() []string {
|
||||
ops := []string{
|
||||
"evaluate",
|
||||
"matrix_add", "matrix_sub", "matrix_mul", "matrix_transpose", "matrix_determinant",
|
||||
"matrix_inverse", "matrix_rank", "matrix_trace", "dot_product", "vector_norm", "dominant_eigen",
|
||||
"derivative", "gradient", "integral",
|
||||
"gradient_descent", "newton_root",
|
||||
"mean", "variance", "std_dev", "median", "covariance", "correlation",
|
||||
"z_score_normalize", "min_max_normalize", "normal_pdf", "normal_cdf", "binomial_pmf",
|
||||
"sigmoid", "relu", "tanh", "softmax", "mse_loss", "cross_entropy_loss", "linear_regression",
|
||||
}
|
||||
sort.Strings(ops)
|
||||
return ops
|
||||
}
|
||||
224
pkg/mathkit/statistics.go
Normal file
224
pkg/mathkit/statistics.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package mathkit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// Mean returns the arithmetic mean of data.
|
||||
func Mean(data []float64) (float64, error) {
|
||||
if len(data) == 0 {
|
||||
return 0, fmt.Errorf("mean: empty data set")
|
||||
}
|
||||
var sum float64
|
||||
for _, v := range data {
|
||||
sum += v
|
||||
}
|
||||
return sum / float64(len(data)), nil
|
||||
}
|
||||
|
||||
// Variance returns the variance of data. population=true divides by
|
||||
// n (the population variance); population=false (the default for ML
|
||||
// work, where `data` is almost always a sample) divides by n-1
|
||||
// (Bessel's correction, the unbiased sample-variance estimator).
|
||||
func Variance(data []float64, population bool) (float64, error) {
|
||||
n := len(data)
|
||||
if n == 0 {
|
||||
return 0, fmt.Errorf("variance: empty data set")
|
||||
}
|
||||
if !population && n < 2 {
|
||||
return 0, fmt.Errorf("variance: sample variance needs at least 2 points (use population variance for a single point)")
|
||||
}
|
||||
mean, _ := Mean(data)
|
||||
var ss float64
|
||||
for _, v := range data {
|
||||
d := v - mean
|
||||
ss += d * d
|
||||
}
|
||||
denom := float64(n)
|
||||
if !population {
|
||||
denom = float64(n - 1)
|
||||
}
|
||||
return ss / denom, nil
|
||||
}
|
||||
|
||||
// StdDev returns the standard deviation (sqrt of Variance).
|
||||
func StdDev(data []float64, population bool) (float64, error) {
|
||||
v, err := Variance(data, population)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return math.Sqrt(v), nil
|
||||
}
|
||||
|
||||
// Median returns the median of data (average of the two middle
|
||||
// values for an even-length slice). Does not mutate the input.
|
||||
func Median(data []float64) (float64, error) {
|
||||
if len(data) == 0 {
|
||||
return 0, fmt.Errorf("median: empty data set")
|
||||
}
|
||||
sorted := append([]float64(nil), data...)
|
||||
insertionSort(sorted)
|
||||
n := len(sorted)
|
||||
if n%2 == 1 {
|
||||
return sorted[n/2], nil
|
||||
}
|
||||
return (sorted[n/2-1] + sorted[n/2]) / 2, nil
|
||||
}
|
||||
|
||||
// insertionSort is O(n^2) but this package only ever sorts small
|
||||
// stats samples, where it beats sort.Float64s' overhead and keeps
|
||||
// the package allocation-free apart from the clone itself.
|
||||
func insertionSort(a []float64) {
|
||||
for i := 1; i < len(a); i++ {
|
||||
key := a[i]
|
||||
j := i - 1
|
||||
for j >= 0 && a[j] > key {
|
||||
a[j+1] = a[j]
|
||||
j--
|
||||
}
|
||||
a[j+1] = key
|
||||
}
|
||||
}
|
||||
|
||||
// Covariance returns the sample (or population) covariance of two
|
||||
// equal-length data sets — the building block of both Correlation and
|
||||
// a PCA covariance matrix.
|
||||
func Covariance(x, y []float64, population bool) (float64, error) {
|
||||
if len(x) != len(y) {
|
||||
return 0, fmt.Errorf("covariance: data sets have different lengths (%d vs %d)", len(x), len(y))
|
||||
}
|
||||
n := len(x)
|
||||
if n == 0 {
|
||||
return 0, fmt.Errorf("covariance: empty data set")
|
||||
}
|
||||
if !population && n < 2 {
|
||||
return 0, fmt.Errorf("covariance: sample covariance needs at least 2 points")
|
||||
}
|
||||
mx, _ := Mean(x)
|
||||
my, _ := Mean(y)
|
||||
var sum float64
|
||||
for i := 0; i < n; i++ {
|
||||
sum += (x[i] - mx) * (y[i] - my)
|
||||
}
|
||||
denom := float64(n)
|
||||
if !population {
|
||||
denom = float64(n - 1)
|
||||
}
|
||||
return sum / denom, nil
|
||||
}
|
||||
|
||||
// Correlation returns the Pearson correlation coefficient of x and y,
|
||||
// in [-1, 1].
|
||||
func Correlation(x, y []float64) (float64, error) {
|
||||
cov, err := Covariance(x, y, true)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("correlation: %w", err)
|
||||
}
|
||||
sx, err := StdDev(x, true)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("correlation: %w", err)
|
||||
}
|
||||
sy, err := StdDev(y, true)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("correlation: %w", err)
|
||||
}
|
||||
if sx == 0 || sy == 0 {
|
||||
return 0, fmt.Errorf("correlation: a constant data set has undefined correlation")
|
||||
}
|
||||
return cov / (sx * sy), nil
|
||||
}
|
||||
|
||||
// ZScoreNormalize returns (x - mean) / stddev for each point — the
|
||||
// standardization every ML feature-scaling pipeline applies before
|
||||
// training a distance- or gradient-sensitive model.
|
||||
func ZScoreNormalize(data []float64) ([]float64, error) {
|
||||
mean, err := Mean(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("z_score_normalize: %w", err)
|
||||
}
|
||||
std, err := StdDev(data, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("z_score_normalize: %w", err)
|
||||
}
|
||||
if std == 0 {
|
||||
return nil, fmt.Errorf("z_score_normalize: a constant data set has zero standard deviation")
|
||||
}
|
||||
out := make([]float64, len(data))
|
||||
for i, v := range data {
|
||||
out[i] = (v - mean) / std
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MinMaxNormalize rescales data to [0, 1].
|
||||
func MinMaxNormalize(data []float64) ([]float64, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, fmt.Errorf("min_max_normalize: empty data set")
|
||||
}
|
||||
lo, hi := data[0], data[0]
|
||||
for _, v := range data {
|
||||
if v < lo {
|
||||
lo = v
|
||||
}
|
||||
if v > hi {
|
||||
hi = v
|
||||
}
|
||||
}
|
||||
if hi == lo {
|
||||
return nil, fmt.Errorf("min_max_normalize: a constant data set has zero range")
|
||||
}
|
||||
out := make([]float64, len(data))
|
||||
for i, v := range data {
|
||||
out[i] = (v - lo) / (hi - lo)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// NormalPDF is the probability density function of N(mean, stdDev^2)
|
||||
// at x.
|
||||
func NormalPDF(x, mean, stdDev float64) (float64, error) {
|
||||
if stdDev <= 0 {
|
||||
return 0, fmt.Errorf("normal_pdf: std_dev must be positive")
|
||||
}
|
||||
z := (x - mean) / stdDev
|
||||
return math.Exp(-0.5*z*z) / (stdDev * math.Sqrt(2*math.Pi)), nil
|
||||
}
|
||||
|
||||
// NormalCDF is the cumulative distribution function of N(mean,
|
||||
// stdDev^2) at x, computed via math.Erf (the standard closed form:
|
||||
// Φ(x) = (1 + erf((x-μ)/(σ√2))) / 2).
|
||||
func NormalCDF(x, mean, stdDev float64) (float64, error) {
|
||||
if stdDev <= 0 {
|
||||
return 0, fmt.Errorf("normal_cdf: std_dev must be positive")
|
||||
}
|
||||
return 0.5 * (1 + math.Erf((x-mean)/(stdDev*math.Sqrt2))), nil
|
||||
}
|
||||
|
||||
// BinomialPMF returns P(X = k) for X ~ Binomial(n, p).
|
||||
func BinomialPMF(k, n int, p float64) (float64, error) {
|
||||
if n < 0 || k < 0 || k > n {
|
||||
return 0, fmt.Errorf("binomial_pmf: require 0 <= k <= n, got k=%d n=%d", k, n)
|
||||
}
|
||||
if p < 0 || p > 1 {
|
||||
return 0, fmt.Errorf("binomial_pmf: p must be in [0, 1], got %g", p)
|
||||
}
|
||||
return binomialCoeff(n, k) * math.Pow(p, float64(k)) * math.Pow(1-p, float64(n-k)), nil
|
||||
}
|
||||
|
||||
// binomialCoeff computes C(n, k) via the multiplicative formula,
|
||||
// which stays in float64 range far longer than computing n!/(k!(n-k)!)
|
||||
// from raw factorials would.
|
||||
func binomialCoeff(n, k int) float64 {
|
||||
if k < 0 || k > n {
|
||||
return 0
|
||||
}
|
||||
if k > n-k {
|
||||
k = n - k
|
||||
}
|
||||
result := 1.0
|
||||
for i := 0; i < k; i++ {
|
||||
result *= float64(n-i) / float64(i+1)
|
||||
}
|
||||
return result
|
||||
}
|
||||
557
pkg/officekit/docx.go
Normal file
557
pkg/officekit/docx.go
Normal file
@@ -0,0 +1,557 @@
|
||||
package officekit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParaStyle is the small, Claude-Code-skill-inspired vocabulary of
|
||||
// paragraph styles the model is offered — deliberately not "any OOXML
|
||||
// style name", so the model can never need to type XML to format
|
||||
// something.
|
||||
type ParaStyle string
|
||||
|
||||
const (
|
||||
StyleNormal ParaStyle = "normal"
|
||||
StyleHeading1 ParaStyle = "heading1"
|
||||
StyleHeading2 ParaStyle = "heading2"
|
||||
StyleHeading3 ParaStyle = "heading3"
|
||||
StyleBullet ParaStyle = "bullet"
|
||||
)
|
||||
|
||||
// Paragraph is one structured paragraph of a Word document, as the
|
||||
// model supplies it via the create_document/edit_document tool
|
||||
// arguments — plain fields, never markup.
|
||||
type Paragraph struct {
|
||||
Text string
|
||||
Style ParaStyle
|
||||
Bold bool
|
||||
Italic bool
|
||||
Underline bool
|
||||
}
|
||||
|
||||
const docxDocumentPart = "word/document.xml"
|
||||
|
||||
// --- word/document.xml, built with encoding/xml so every run of text
|
||||
// is correctly escaped no matter what characters it contains (a stray
|
||||
// "<" or "&" in a paragraph's text becomes "<"/"&" the same way
|
||||
// Go's XML encoder would escape it anywhere else — it can never end up
|
||||
// as literal, parseable-looking markup). ---
|
||||
|
||||
// wDocument's XMLName tag uses Go's "namespace-URI local-name" form
|
||||
// (space-separated, not a colon prefix — see the package doc comment
|
||||
// in zipkit.go's header and this file's note above BuildDocx for why
|
||||
// colon-prefixed tags like "w:document" don't round-trip). Marshal
|
||||
// emits this as the default namespace (xmlns="...") on the root
|
||||
// element; every child struct below uses a plain, unprefixed local
|
||||
// name with no namespace component, which both (a) is valid XML —
|
||||
// unprefixed descendants simply inherit the nearest enclosing default
|
||||
// namespace — and (b) unmarshals back correctly, because an
|
||||
// unnamespaced field tag matches an element by local name only,
|
||||
// regardless of which namespace it actually resolved to.
|
||||
type wDocument struct {
|
||||
XMLName xml.Name `xml:"http://schemas.openxmlformats.org/wordprocessingml/2006/main document"`
|
||||
Body wBody `xml:"body"`
|
||||
}
|
||||
|
||||
type wBody struct {
|
||||
Paragraphs []wParagraph `xml:"p"`
|
||||
SectPr wSectPr `xml:"sectPr"`
|
||||
}
|
||||
|
||||
type wSectPr struct {
|
||||
PgSz wPgSz `xml:"pgSz"`
|
||||
}
|
||||
|
||||
type wPgSz struct {
|
||||
W int `xml:"w,attr"`
|
||||
H int `xml:"h,attr"`
|
||||
}
|
||||
|
||||
type wParagraph struct {
|
||||
PPr *wPPr `xml:"pPr,omitempty"`
|
||||
Run wRun `xml:"r"`
|
||||
}
|
||||
|
||||
type wPPr struct {
|
||||
NumPr *wNumPr `xml:"numPr,omitempty"`
|
||||
}
|
||||
|
||||
type wNumPr struct {
|
||||
Ilvl wVal `xml:"ilvl"`
|
||||
NumId wVal `xml:"numId"`
|
||||
}
|
||||
|
||||
type wVal struct {
|
||||
Val int `xml:"val,attr"`
|
||||
}
|
||||
|
||||
type wRun struct {
|
||||
RPr *wRPr `xml:"rPr,omitempty"`
|
||||
Text wText `xml:"t"`
|
||||
}
|
||||
|
||||
// wRPr's field order follows OOXML's CT_RPr schema sequence (b, i,
|
||||
// sz, u, ...) — Go's xml.Marshal emits struct fields in declaration
|
||||
// order, and while real-world readers (Word included) tend to be
|
||||
// lenient about run-property order, writing it correctly costs
|
||||
// nothing and keeps generated files schema-valid, not just
|
||||
// Word-openable.
|
||||
type wRPr struct {
|
||||
Bold *struct{} `xml:"b,omitempty"`
|
||||
Italic *struct{} `xml:"i,omitempty"`
|
||||
Sz *wSz `xml:"sz,omitempty"`
|
||||
Underline *wU `xml:"u,omitempty"`
|
||||
}
|
||||
|
||||
type wSz struct {
|
||||
Val int `xml:"val,attr"`
|
||||
}
|
||||
|
||||
type wU struct {
|
||||
Val string `xml:"val,attr"`
|
||||
}
|
||||
|
||||
type wText struct {
|
||||
Space string `xml:"xml:space,attr"`
|
||||
Value string `xml:",chardata"`
|
||||
}
|
||||
|
||||
// wParagraphFragment is the same content as wParagraph, but with its
|
||||
// own explicit WordprocessingML namespace declaration on XMLName.
|
||||
// It exists only for AppendDocxParagraph, which marshals ONE paragraph
|
||||
// standalone and splices its bytes directly into an existing
|
||||
// document.xml rather than re-marshaling the whole document (see that
|
||||
// function's doc comment for why). A spliced fragment has no parent
|
||||
// element to inherit a namespace from if the target document uses
|
||||
// Word's own "w:"-prefixed convention instead of this package's
|
||||
// default-namespace one — so this type redeclares the namespace
|
||||
// locally on the fragment's own root element. XML lets any element do
|
||||
// that regardless of its surrounding document's prefix convention, and
|
||||
// every OOXML-consuming application (Word included) resolves element
|
||||
// identity by namespace URI + local name, not by literal prefix text,
|
||||
// so `<p xmlns="...wordprocessingml.../main">` and `<w:p>` are the same
|
||||
// element as far as Word is concerned. wParagraph itself deliberately
|
||||
// keeps no such tag, so BuildDocx's normal output — where every
|
||||
// paragraph already inherits the root's default namespace — doesn't
|
||||
// pay for a redundant xmlns on every single paragraph.
|
||||
type wParagraphFragment struct {
|
||||
XMLName xml.Name `xml:"http://schemas.openxmlformats.org/wordprocessingml/2006/main p"`
|
||||
PPr *wPPr `xml:"pPr,omitempty"`
|
||||
Run wRun `xml:"r"`
|
||||
}
|
||||
|
||||
func newParagraphFragment(p Paragraph) wParagraphFragment {
|
||||
wp := toWParagraph(p)
|
||||
return wParagraphFragment{PPr: wp.PPr, Run: wp.Run}
|
||||
}
|
||||
|
||||
// runProps maps the model's small formatting vocabulary (bold, italic,
|
||||
// underline, plus the size/boldness implied by a heading style) to a
|
||||
// run's rPr, or nil if the run needs no rPr at all (the common case:
|
||||
// a plain, unstyled normal/bullet paragraph).
|
||||
func runProps(p Paragraph) *wRPr {
|
||||
bold, italic, underline := p.Bold, p.Italic, p.Underline
|
||||
var sz *wSz
|
||||
switch p.Style {
|
||||
case StyleHeading1:
|
||||
bold = true
|
||||
sz = &wSz{Val: 32} // 16pt
|
||||
case StyleHeading2:
|
||||
bold = true
|
||||
sz = &wSz{Val: 28} // 14pt
|
||||
case StyleHeading3:
|
||||
bold = true
|
||||
sz = &wSz{Val: 24} // 12pt
|
||||
}
|
||||
if !bold && !italic && !underline && sz == nil {
|
||||
return nil
|
||||
}
|
||||
rpr := &wRPr{Sz: sz}
|
||||
if bold {
|
||||
rpr.Bold = &struct{}{}
|
||||
}
|
||||
if italic {
|
||||
rpr.Italic = &struct{}{}
|
||||
}
|
||||
if underline {
|
||||
rpr.Underline = &wU{Val: "single"}
|
||||
}
|
||||
return rpr
|
||||
}
|
||||
|
||||
func toWParagraph(p Paragraph) wParagraph {
|
||||
wp := wParagraph{
|
||||
Run: wRun{
|
||||
RPr: runProps(p),
|
||||
Text: wText{Space: "preserve", Value: p.Text},
|
||||
},
|
||||
}
|
||||
if p.Style == StyleBullet {
|
||||
wp.PPr = &wPPr{NumPr: &wNumPr{Ilvl: wVal{Val: 0}, NumId: wVal{Val: 1}}}
|
||||
}
|
||||
return wp
|
||||
}
|
||||
|
||||
// BuildDocx renders paragraphs into a complete, valid .docx file.
|
||||
func BuildDocx(paragraphs []Paragraph) ([]byte, error) {
|
||||
body := wBody{SectPr: wSectPr{PgSz: wPgSz{W: 12240, H: 15840}}}
|
||||
for _, p := range paragraphs {
|
||||
body.Paragraphs = append(body.Paragraphs, toWParagraph(p))
|
||||
}
|
||||
doc := wDocument{Body: body}
|
||||
docXML, err := xml.Marshal(doc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("officekit: marshal document.xml: %w", err)
|
||||
}
|
||||
|
||||
// A minimal numbering part is included (word/numbering.xml) only so
|
||||
// StyleBullet's w:numId="1" resolves to something rather than being
|
||||
// a dangling reference; every bullet paragraph shares the same
|
||||
// single bullet-list definition.
|
||||
parts := []zipPart{
|
||||
{Name: "[Content_Types].xml", Body: docxContentTypes},
|
||||
{Name: "_rels/.rels", Body: docxRootRels},
|
||||
{Name: "word/_rels/document.xml.rels", Body: docxDocumentRels},
|
||||
{Name: docxDocumentPart, Body: xml.Header + string(docXML)},
|
||||
{Name: "word/numbering.xml", Body: docxNumberingXML},
|
||||
{Name: "docProps/core.xml", Body: officeCoreProps},
|
||||
{Name: "docProps/app.xml", Body: docxAppProps},
|
||||
}
|
||||
return buildZip(parts)
|
||||
}
|
||||
|
||||
// ReadDocxText extracts the plain text of a .docx file, one line per
|
||||
// paragraph (bullets prefixed with "- "), for the read_document tool.
|
||||
// This is what the model actually sees — never the underlying XML.
|
||||
//
|
||||
// This walks document.xml's raw token stream rather than unmarshaling
|
||||
// into a struct that only models direct <w:body> children: a real
|
||||
// document's paragraphs can be nested inside a table cell
|
||||
// (<w:tbl>/<w:tr>/<w:tc>/<w:p>), and a struct-based Unmarshal into
|
||||
// wBody.Paragraphs would silently skip every one of those, making a
|
||||
// user-uploaded document with tables look emptier than it is. The
|
||||
// token walk picks up every <w:p> wherever it occurs, in document
|
||||
// order; it still doesn't reconstruct a table's row/column grid (see
|
||||
// the docx skill's documented limitation) — each cell's paragraphs
|
||||
// simply appear as their own lines.
|
||||
func ReadDocxText(data []byte) (string, error) {
|
||||
raw, ok, err := readZipPart(data, docxDocumentPart)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !ok {
|
||||
return "", fmt.Errorf("officekit: %s not found (not a valid .docx)", docxDocumentPart)
|
||||
}
|
||||
lines, err := walkParagraphText(raw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("officekit: parse document.xml: %w", err)
|
||||
}
|
||||
return strings.Join(lines, "\n"), nil
|
||||
}
|
||||
|
||||
// walkParagraphText returns one text line per <w:p> paragraph found
|
||||
// anywhere in document.xml, in document order: the concatenation of
|
||||
// every <w:t> run's text within it, with <w:tab/> rendered as a
|
||||
// literal tab, prefixed "- " when the paragraph carries a <w:numPr>
|
||||
// (list) property. Matches by local element name only (see the
|
||||
// package-level namespace note on wDocument), so it works the same
|
||||
// whether the source uses Word's "w:" prefix or this package's own
|
||||
// default-namespace convention.
|
||||
func walkParagraphText(raw []byte) ([]string, error) {
|
||||
dec := xml.NewDecoder(bytes.NewReader(raw))
|
||||
var (
|
||||
lines []string
|
||||
cur strings.Builder
|
||||
inPara bool
|
||||
inText bool
|
||||
isBullet bool
|
||||
)
|
||||
flushPara := func() {
|
||||
text := cur.String()
|
||||
if isBullet {
|
||||
text = "- " + text
|
||||
}
|
||||
lines = append(lines, text)
|
||||
cur.Reset()
|
||||
inPara, isBullet = false, false
|
||||
}
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
switch t.Name.Local {
|
||||
case "p":
|
||||
inPara = true
|
||||
case "t":
|
||||
inText = true
|
||||
case "tab":
|
||||
if inPara {
|
||||
cur.WriteByte('\t')
|
||||
}
|
||||
case "numPr":
|
||||
if inPara {
|
||||
isBullet = true
|
||||
}
|
||||
}
|
||||
case xml.EndElement:
|
||||
switch t.Name.Local {
|
||||
case "p":
|
||||
if inPara {
|
||||
flushPara()
|
||||
}
|
||||
case "t":
|
||||
inText = false
|
||||
}
|
||||
case xml.CharData:
|
||||
if inPara && inText {
|
||||
cur.Write(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
// AppendDocxParagraph adds one paragraph as the new last content
|
||||
// element of the document body and returns the new file bytes.
|
||||
//
|
||||
// This splices the new paragraph's marshaled XML directly into
|
||||
// document.xml's existing raw bytes, immediately before the body's
|
||||
// trailing <w:sectPr> (OOXML requires section properties to be the
|
||||
// body's last child) — or immediately before </w:body> if there's no
|
||||
// sectPr at all — instead of the unmarshal-modify-marshal round trip
|
||||
// this used to share with ReplaceDocxText. That round trip only ever
|
||||
// modeled direct <w:body> children (paragraphs and the trailing
|
||||
// sectPr), so re-marshaling it back would silently drop everything
|
||||
// else a real document has: tables, images, headers, multiple runs
|
||||
// per paragraph, tracked changes. Splicing means every byte of the
|
||||
// original document this package doesn't model survives untouched.
|
||||
func AppendDocxParagraph(data []byte, p Paragraph) ([]byte, error) {
|
||||
raw, ok, err := readZipPart(data, docxDocumentPart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("officekit: %s not found (not a valid .docx)", docxDocumentPart)
|
||||
}
|
||||
paraXML, err := xml.Marshal(newParagraphFragment(p))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("officekit: marshal paragraph: %w", err)
|
||||
}
|
||||
at := bodyInsertionPoint(raw)
|
||||
if at < 0 {
|
||||
return nil, fmt.Errorf("officekit: %s has no <w:body> to append to (not a valid .docx)", docxDocumentPart)
|
||||
}
|
||||
newDoc := make([]byte, 0, len(raw)+len(paraXML))
|
||||
newDoc = append(newDoc, raw[:at]...)
|
||||
newDoc = append(newDoc, paraXML...)
|
||||
newDoc = append(newDoc, raw[at:]...)
|
||||
return replaceZipPart(data, docxDocumentPart, newDoc)
|
||||
}
|
||||
|
||||
// bodyInsertionPoint returns the byte offset in document.xml where new
|
||||
// body content should be spliced in: right before the body's trailing
|
||||
// <w:sectPr> if present, else right before </w:body>. Returns -1 if
|
||||
// neither tag is found at all (not a recognizable Word document body).
|
||||
func bodyInsertionPoint(doc []byte) int {
|
||||
for _, tag := range []string{"<w:sectPr", "<sectPr"} {
|
||||
if i := lastTagIndex(doc, tag); i >= 0 {
|
||||
return i
|
||||
}
|
||||
}
|
||||
for _, tag := range []string{"</w:body>", "</body>"} {
|
||||
if i := bytes.Index(doc, []byte(tag)); i >= 0 {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// lastTagIndex finds the last byte offset of an opening tag whose name
|
||||
// is exactly tag (e.g. "<w:sectPr"), rejecting a longer tag name that
|
||||
// merely starts with the same bytes — such as the tracked-changes
|
||||
// element "<w:sectPrChange", which would otherwise look like a match.
|
||||
func lastTagIndex(doc []byte, tag string) int {
|
||||
needle := []byte(tag)
|
||||
end := len(doc)
|
||||
for {
|
||||
i := bytes.LastIndex(doc[:end], needle)
|
||||
if i < 0 {
|
||||
return -1
|
||||
}
|
||||
after := i + len(needle)
|
||||
if after >= len(doc) {
|
||||
return i
|
||||
}
|
||||
switch doc[after] {
|
||||
case ' ', '\t', '\n', '\r', '>', '/':
|
||||
return i
|
||||
}
|
||||
end = i // false positive (e.g. sectPrChange); keep searching earlier
|
||||
}
|
||||
}
|
||||
|
||||
// wTextElementRe matches one WordprocessingML text run's element —
|
||||
// <w:t>...</w:t>, the unprefixed <t>...</t> this package's own output
|
||||
// uses, or any other single-letter-prefixed variant — capturing the
|
||||
// opening tag, the raw (still XML-escaped) inner text, and the closing
|
||||
// tag separately, so ReplaceDocxText only ever rewrites the inner text
|
||||
// and leaves the tag itself, and everything outside it, byte-for-byte
|
||||
// untouched. The boundary check after the local name (next character
|
||||
// must be whitespace, "/", or ">") excludes longer names that merely
|
||||
// start with "t", such as "tab", "tbl", or "tc".
|
||||
var wTextElementRe = regexp.MustCompile(`(?s)(<(?:[A-Za-z][\w.-]*:)?t(?:\s[^>]*)?>)(.*?)(</(?:[A-Za-z][\w.-]*:)?t>)`)
|
||||
|
||||
// ReplaceDocxText replaces every exact, literal occurrence of find
|
||||
// with replace inside the document's text runs — including runs
|
||||
// nested inside a table cell, which the old paragraph-struct-based
|
||||
// approach never saw at all — and returns the new file bytes plus how
|
||||
// many text runs were changed. It rewrites only the matched <w:t>
|
||||
// elements' inner text in document.xml's raw bytes; every other part
|
||||
// of the document (formatting, tables, images, headers) passes through
|
||||
// completely untouched.
|
||||
//
|
||||
// Because Word often splits a single sentence across more than one run
|
||||
// (spell-check and revision tracking both do this), a phrase that
|
||||
// spans a run boundary may not be found even though it reads as one
|
||||
// phrase in Word — read_document first, and prefer a shorter, more
|
||||
// literal match string when a longer one doesn't seem to apply.
|
||||
func ReplaceDocxText(data []byte, find, replace string) ([]byte, int, error) {
|
||||
raw, ok, err := readZipPart(data, docxDocumentPart)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, 0, fmt.Errorf("officekit: %s not found (not a valid .docx)", docxDocumentPart)
|
||||
}
|
||||
|
||||
changed := 0
|
||||
newDoc := wTextElementRe.ReplaceAllFunc(raw, func(match []byte) []byte {
|
||||
groups := wTextElementRe.FindSubmatch(match)
|
||||
open, inner, closeTag := groups[1], groups[2], groups[3]
|
||||
decoded := xmlUnescapeText(inner)
|
||||
if !strings.Contains(decoded, find) {
|
||||
return match
|
||||
}
|
||||
changed++
|
||||
replaced := strings.ReplaceAll(decoded, find, replace)
|
||||
out := make([]byte, 0, len(open)+len(replaced)+len(closeTag))
|
||||
out = append(out, open...)
|
||||
out = append(out, []byte(xmlEscapeText(replaced))...)
|
||||
out = append(out, closeTag...)
|
||||
return out
|
||||
})
|
||||
if changed == 0 {
|
||||
return nil, 0, fmt.Errorf("officekit: %q not found in any text run", find)
|
||||
}
|
||||
out, err := replaceZipPart(data, docxDocumentPart, newDoc)
|
||||
return out, changed, err
|
||||
}
|
||||
|
||||
// xmlUnescapeText reverses XML's five predefined entities plus numeric
|
||||
// character references (&#NNN; / &#xHH;) — the inverse of
|
||||
// xmlEscapeText (pptx.go) — so ReplaceDocxText can match and replace
|
||||
// against the plain text the model actually passed, not its
|
||||
// XML-escaped form. An unknown or malformed entity is left verbatim
|
||||
// rather than erroring: this only ever feeds a literal-substring
|
||||
// comparison, so there's nothing to fail loudly about.
|
||||
func xmlUnescapeText(b []byte) string {
|
||||
s := string(b)
|
||||
if !strings.ContainsRune(s, '&') {
|
||||
return s
|
||||
}
|
||||
var out strings.Builder
|
||||
for {
|
||||
i := strings.IndexByte(s, '&')
|
||||
if i < 0 {
|
||||
out.WriteString(s)
|
||||
break
|
||||
}
|
||||
out.WriteString(s[:i])
|
||||
rest := s[i+1:]
|
||||
semi := strings.IndexByte(rest, ';')
|
||||
if semi < 0 {
|
||||
out.WriteByte('&')
|
||||
s = rest
|
||||
continue
|
||||
}
|
||||
entity, tail := rest[:semi], rest[semi+1:]
|
||||
switch {
|
||||
case entity == "amp":
|
||||
out.WriteByte('&')
|
||||
case entity == "lt":
|
||||
out.WriteByte('<')
|
||||
case entity == "gt":
|
||||
out.WriteByte('>')
|
||||
case entity == "apos":
|
||||
out.WriteByte('\'')
|
||||
case entity == "quot":
|
||||
out.WriteByte('"')
|
||||
case strings.HasPrefix(entity, "#x"), strings.HasPrefix(entity, "#X"):
|
||||
if n, err := strconv.ParseInt(entity[2:], 16, 32); err == nil {
|
||||
out.WriteRune(rune(n))
|
||||
} else {
|
||||
out.WriteByte('&')
|
||||
out.WriteString(entity)
|
||||
out.WriteByte(';')
|
||||
}
|
||||
case strings.HasPrefix(entity, "#"):
|
||||
if n, err := strconv.ParseInt(entity[1:], 10, 32); err == nil {
|
||||
out.WriteRune(rune(n))
|
||||
} else {
|
||||
out.WriteByte('&')
|
||||
out.WriteString(entity)
|
||||
out.WriteByte(';')
|
||||
}
|
||||
default:
|
||||
out.WriteByte('&')
|
||||
out.WriteString(entity)
|
||||
out.WriteByte(';')
|
||||
}
|
||||
s = tail
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
const docxContentTypes = xml.Header + `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
||||
<Override PartName="/word/numbering.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"/>
|
||||
<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>
|
||||
<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>
|
||||
</Types>`
|
||||
|
||||
const docxRootRels = xml.Header + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
||||
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
|
||||
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>
|
||||
</Relationships>`
|
||||
|
||||
const docxDocumentRels = xml.Header + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering" Target="numbering.xml"/>
|
||||
</Relationships>`
|
||||
|
||||
const docxNumberingXML = xml.Header + `<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:abstractNum w:abstractNumId="0">
|
||||
<w:lvl w:ilvl="0"><w:start w:val="1"/><w:numFmt w:val="bullet"/><w:lvlText w:val=""/><w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr></w:lvl>
|
||||
</w:abstractNum>
|
||||
<w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num>
|
||||
</w:numbering>`
|
||||
|
||||
const docxAppProps = xml.Header + `<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"><Application>enkly</Application></Properties>`
|
||||
|
||||
// officeCoreProps is shared verbatim by docx/xlsx/pptx — the schema is
|
||||
// package-level (docProps/core.xml), not format-specific.
|
||||
const officeCoreProps = xml.Header + `<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:creator>enkly agent</dc:creator></cp:coreProperties>`
|
||||
128
pkg/officekit/markdown.go
Normal file
128
pkg/officekit/markdown.go
Normal file
@@ -0,0 +1,128 @@
|
||||
// markdown.go adds Markdown → the same structured []Paragraph
|
||||
// vocabulary create_document and BuildDocx already use, so an
|
||||
// existing Markdown document (a README, a chat reply, something an
|
||||
// upload already contains) can become a real .docx without the model
|
||||
// ever hand-translating headings and bullets into paragraph arguments
|
||||
// itself. It reuses the gomarkdown parser already vendored for
|
||||
// chat-message rendering (pkg/renderkit) rather than hand-rolling a
|
||||
// second Markdown parser — one less thing in this codebase that can
|
||||
// disagree about what counts as a heading or a list item.
|
||||
//
|
||||
// Scope: block-level structure only — headings, paragraphs, and
|
||||
// bullet/numbered lists (numbered lists render as bullets, since
|
||||
// Paragraph has no numbered-list style of its own yet). Fenced or
|
||||
// indented code blocks come through as plain paragraphs, since
|
||||
// Paragraph has no monospace style either. Inline formatting
|
||||
// (bold/italic spans within a paragraph, links, inline code) is
|
||||
// flattened to plain text — Paragraph only supports whole-paragraph
|
||||
// bold/italic/underline (see docx.go), the same limit
|
||||
// create_document's tool description already states. Tables, raw HTML
|
||||
// blocks, footnotes, and anything else without a Paragraph-vocabulary
|
||||
// equivalent are silently dropped rather than guessed at.
|
||||
package officekit
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gomarkdown/markdown/ast"
|
||||
mdparser "github.com/gomarkdown/markdown/parser"
|
||||
)
|
||||
|
||||
// MarkdownToParagraphs parses a Markdown document into the paragraph
|
||||
// vocabulary BuildDocx/AppendDocxParagraph already understand, so a
|
||||
// caller can go straight from Markdown source to a .docx via
|
||||
// BuildDocx(MarkdownToParagraphs(src)).
|
||||
func MarkdownToParagraphs(src string) []Paragraph {
|
||||
p := mdparser.NewWithExtensions(mdparser.CommonExtensions)
|
||||
doc := p.Parse([]byte(src))
|
||||
var out []Paragraph
|
||||
for _, block := range childNodes(doc) {
|
||||
out = append(out, blockToParagraphs(block)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func blockToParagraphs(n ast.Node) []Paragraph {
|
||||
switch v := n.(type) {
|
||||
case *ast.Heading:
|
||||
text := leafText(v)
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
style := StyleHeading3
|
||||
switch {
|
||||
case v.Level <= 1:
|
||||
style = StyleHeading1
|
||||
case v.Level == 2:
|
||||
style = StyleHeading2
|
||||
}
|
||||
return []Paragraph{{Text: text, Style: style}}
|
||||
case *ast.Paragraph:
|
||||
text := leafText(v)
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
return []Paragraph{{Text: text, Style: StyleNormal}}
|
||||
case *ast.List:
|
||||
var out []Paragraph
|
||||
for _, item := range childNodes(v) {
|
||||
text := leafText(item)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, Paragraph{Text: text, Style: StyleBullet})
|
||||
}
|
||||
return out
|
||||
case *ast.CodeBlock:
|
||||
text := strings.TrimRight(string(v.Literal), "\n")
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
return []Paragraph{{Text: text, Style: StyleNormal}}
|
||||
case *ast.BlockQuote:
|
||||
var out []Paragraph
|
||||
for _, c := range childNodes(v) {
|
||||
out = append(out, blockToParagraphs(c)...)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
// HorizontalRule, Table, HTMLBlock, and anything else this
|
||||
// package has no Paragraph-vocabulary equivalent for.
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// childNodes returns n's direct block-level children via gomarkdown's
|
||||
// Container/Leaf node split: a Leaf node (Text, Code, ...) has none.
|
||||
func childNodes(n ast.Node) []ast.Node {
|
||||
if c := n.AsContainer(); c != nil {
|
||||
return c.Children
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// leafText concatenates every Text/Code leaf found under n, depth
|
||||
// first — the inline-formatting-flattening step described in this
|
||||
// file's package doc.
|
||||
func leafText(n ast.Node) string {
|
||||
var sb strings.Builder
|
||||
collectText(n, &sb)
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
func collectText(n ast.Node, sb *strings.Builder) {
|
||||
switch v := n.(type) {
|
||||
case *ast.Text:
|
||||
sb.Write(v.Literal)
|
||||
return
|
||||
case *ast.Code:
|
||||
sb.Write(v.Literal)
|
||||
return
|
||||
case *ast.Hardbreak:
|
||||
sb.WriteByte(' ')
|
||||
return
|
||||
}
|
||||
for _, c := range childNodes(n) {
|
||||
collectText(c, sb)
|
||||
}
|
||||
}
|
||||
74
pkg/officekit/markdown_test.go
Normal file
74
pkg/officekit/markdown_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package officekit
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMarkdownToParagraphsStructure(t *testing.T) {
|
||||
src := "# Title\n\nAn intro paragraph.\n\n## Section\n\n- First item\n- Second item\n"
|
||||
paras := MarkdownToParagraphs(src)
|
||||
|
||||
want := []Paragraph{
|
||||
{Text: "Title", Style: StyleHeading1},
|
||||
{Text: "An intro paragraph.", Style: StyleNormal},
|
||||
{Text: "Section", Style: StyleHeading2},
|
||||
{Text: "First item", Style: StyleBullet},
|
||||
{Text: "Second item", Style: StyleBullet},
|
||||
}
|
||||
if len(paras) != len(want) {
|
||||
t.Fatalf("got %d paragraphs, want %d: %+v", len(paras), len(want), paras)
|
||||
}
|
||||
for i := range want {
|
||||
if paras[i].Text != want[i].Text || paras[i].Style != want[i].Style {
|
||||
t.Errorf("paragraph %d: got %+v, want %+v", i, paras[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownToParagraphsFlattensInlineFormatting(t *testing.T) {
|
||||
paras := MarkdownToParagraphs("This has **bold** and *italic* and `code` text.")
|
||||
if len(paras) != 1 {
|
||||
t.Fatalf("got %d paragraphs, want 1: %+v", len(paras), paras)
|
||||
}
|
||||
want := "This has bold and italic and code text."
|
||||
if paras[0].Text != want {
|
||||
t.Errorf("got %q, want %q", paras[0].Text, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownToParagraphsFeedsBuildDocx(t *testing.T) {
|
||||
paras := MarkdownToParagraphs("# Report\n\nBody text here.\n")
|
||||
data, err := BuildDocx(paras)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDocx(MarkdownToParagraphs(...)): %v", err)
|
||||
}
|
||||
text, err := ReadDocxText(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDocxText: %v", err)
|
||||
}
|
||||
for _, want := range []string{"Report", "Body text here."} {
|
||||
if !containsLine(text, want) {
|
||||
t.Errorf("expected read-back text to contain line %q, got:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func containsLine(text, line string) bool {
|
||||
for _, l := range splitLines(text) {
|
||||
if l == line {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func splitLines(s string) []string {
|
||||
var lines []string
|
||||
start := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\n' {
|
||||
lines = append(lines, s[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
lines = append(lines, s[start:])
|
||||
return lines
|
||||
}
|
||||
274
pkg/officekit/officekit_test.go
Normal file
274
pkg/officekit/officekit_test.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package officekit
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// minimalWordDoc builds a tiny, realistic .docx — using Word's own
|
||||
// "w:"-prefixed convention, not this package's unprefixed
|
||||
// default-namespace one — containing one body paragraph and one table
|
||||
// with a single cell's worth of text. This exercises the append/replace
|
||||
// operations against content this package doesn't itself model (tables)
|
||||
// without needing a real binary fixture file on disk.
|
||||
func minimalWordDoc(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
documentXML := xml.Header + `<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">` +
|
||||
`<w:body>` +
|
||||
`<w:p><w:r><w:t>Intro paragraph.</w:t></w:r></w:p>` +
|
||||
`<w:tbl><w:tr><w:tc><w:p><w:r><w:t>cell one text</w:t></w:r></w:p></w:tc></w:tr></w:tbl>` +
|
||||
`<w:sectPr><w:pgSz w:w="12240" w:h="15840"/></w:sectPr>` +
|
||||
`</w:body></w:document>`
|
||||
data, err := buildZip([]zipPart{
|
||||
{Name: "[Content_Types].xml", Body: docxContentTypes},
|
||||
{Name: "_rels/.rels", Body: docxRootRels},
|
||||
{Name: docxDocumentPart, Body: documentXML},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildZip: %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func TestBuildDocxReadRoundTrip(t *testing.T) {
|
||||
data, err := BuildDocx([]Paragraph{
|
||||
{Text: "Title", Style: StyleHeading1},
|
||||
{Text: "First item", Style: StyleBullet},
|
||||
{Text: "Second item", Style: StyleBullet},
|
||||
{Text: "Styled body text", Bold: true, Italic: true, Underline: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDocx: %v", err)
|
||||
}
|
||||
text, err := ReadDocxText(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDocxText: %v", err)
|
||||
}
|
||||
want := []string{"Title", "- First item", "- Second item", "Styled body text"}
|
||||
got := strings.Split(text, "\n")
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("expected %d lines, got %d: %q", len(want), len(got), got)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("line %d: got %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocxRunFormattingXML(t *testing.T) {
|
||||
data, err := BuildDocx([]Paragraph{{Text: "x", Bold: true, Italic: true, Underline: true}})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDocx: %v", err)
|
||||
}
|
||||
raw, ok, err := readZipPart(data, docxDocumentPart)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("readZipPart: ok=%v err=%v", ok, err)
|
||||
}
|
||||
body := string(raw)
|
||||
for _, want := range []string{"<b></b>", "<i></i>", `<u val="single">`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("expected document.xml to contain %s, got:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendDocxParagraphPreservesTable(t *testing.T) {
|
||||
data := minimalWordDoc(t)
|
||||
out, err := AppendDocxParagraph(data, Paragraph{Text: "Appended line."})
|
||||
if err != nil {
|
||||
t.Fatalf("AppendDocxParagraph: %v", err)
|
||||
}
|
||||
text, err := ReadDocxText(out)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDocxText: %v", err)
|
||||
}
|
||||
for _, want := range []string{"Intro paragraph.", "cell one text", "Appended line."} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("expected read-back text to contain %q, got:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
// The new paragraph must land before the trailing sectPr, not after
|
||||
// it — content after a body's final sectPr is invalid OOXML.
|
||||
raw, ok, err := readZipPart(out, docxDocumentPart)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("readZipPart: ok=%v err=%v", ok, err)
|
||||
}
|
||||
body := string(raw)
|
||||
idxAppended := strings.Index(body, "Appended line.")
|
||||
idxSectPr := strings.Index(body, "<w:sectPr")
|
||||
if idxAppended < 0 || idxSectPr < 0 || idxAppended > idxSectPr {
|
||||
t.Errorf("expected appended paragraph before <w:sectPr>, got appended@%d sectPr@%d:\n%s", idxAppended, idxSectPr, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceDocxTextInTable(t *testing.T) {
|
||||
data := minimalWordDoc(t)
|
||||
out, n, err := ReplaceDocxText(data, "cell one text", "updated cell text")
|
||||
if err != nil {
|
||||
t.Fatalf("ReplaceDocxText: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("expected 1 text run changed, got %d", n)
|
||||
}
|
||||
text, err := ReadDocxText(out)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDocxText: %v", err)
|
||||
}
|
||||
if strings.Contains(text, "cell one text") {
|
||||
t.Errorf("old text still present after replace: %s", text)
|
||||
}
|
||||
if !strings.Contains(text, "updated cell text") {
|
||||
t.Errorf("expected replaced text, got: %s", text)
|
||||
}
|
||||
if !strings.Contains(text, "Intro paragraph.") {
|
||||
t.Errorf("unrelated paragraph lost: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceDocxTextNotFound(t *testing.T) {
|
||||
data := minimalWordDoc(t)
|
||||
if _, _, err := ReplaceDocxText(data, "nope, not in this document", "x"); err == nil {
|
||||
t.Fatal("expected an error for text that isn't present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetXlsxCellsFormulaAndOrdering(t *testing.T) {
|
||||
data, err := BuildXlsx([]Sheet{{Name: "Sheet1", Rows: [][]string{{"Label", "Value"}}}})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildXlsx: %v", err)
|
||||
}
|
||||
|
||||
// Written deliberately out of row order, including a formula, to
|
||||
// exercise the sort-before-marshal fix.
|
||||
out, err := SetXlsxCells(data, "", map[string]string{
|
||||
"A5": "fifth",
|
||||
"A2": "second",
|
||||
"B2": "=A2",
|
||||
"A1": "first",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetXlsxCells: %v", err)
|
||||
}
|
||||
|
||||
rows, _, err := ReadXlsxCells(out, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadXlsxCells: %v", err)
|
||||
}
|
||||
// Three sparse rows exist: 1 (from BuildXlsx + overwritten A1), 2, 5.
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("expected 3 rows, got %d: %v", len(rows), rows)
|
||||
}
|
||||
if rows[0][0] != "first" {
|
||||
t.Errorf("row 1 col A: got %q, want %q", rows[0][0], "first")
|
||||
}
|
||||
if rows[1][0] != "second" {
|
||||
t.Errorf("row 2 col A: got %q, want %q", rows[1][0], "second")
|
||||
}
|
||||
if len(rows[1]) < 2 || rows[1][1] != "=A2" {
|
||||
t.Errorf("row 2 col B: got %v, want formula \"=A2\"", rows[1])
|
||||
}
|
||||
if rows[2][0] != "fifth" {
|
||||
t.Errorf("row 5 col A: got %q, want %q", rows[2][0], "fifth")
|
||||
}
|
||||
|
||||
// A real reader (Excel included) cares about the underlying
|
||||
// element order, not just what this package's own reader derives —
|
||||
// confirm the raw XML itself is in ascending row/column order.
|
||||
raw, ok, err := readZipPart(out, xlsxSheetPart(1))
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("readZipPart sheet1: ok=%v err=%v", ok, err)
|
||||
}
|
||||
body := string(raw)
|
||||
idxA1 := strings.Index(body, `r="A1"`)
|
||||
idxA2 := strings.Index(body, `r="A2"`)
|
||||
idxB2 := strings.Index(body, `r="B2"`)
|
||||
idxA5 := strings.Index(body, `r="A5"`)
|
||||
if idxA1 < 0 || idxA2 < 0 || idxB2 < 0 || idxA5 < 0 {
|
||||
t.Fatalf("expected every written cell ref present in raw XML:\n%s", body)
|
||||
}
|
||||
if !(idxA1 < idxA2 && idxA2 < idxB2 && idxB2 < idxA5) {
|
||||
t.Errorf("expected ascending order A1 < A2 < B2 < A5 in raw XML, got offsets %d,%d,%d,%d:\n%s",
|
||||
idxA1, idxA2, idxB2, idxA5, body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetXlsxCellsManyNewRowsNoDataLoss is a regression test for a bug
|
||||
// where SetXlsxCells indexed newly-created rows by a *pointer* taken
|
||||
// at append time. Go's append reallocates a slice's backing array
|
||||
// once its capacity is exceeded, which silently strands any
|
||||
// previously-taken pointer into the old array — a write applied
|
||||
// through it returns no error but never reaches the file. That bug
|
||||
// only showed up when (a) more than one cell landed in the same
|
||||
// brand-new row and (b) a slice reallocation happened to fall between
|
||||
// those two writes, so it passed or failed depending on Go's
|
||||
// (deliberately randomized) map iteration order. This test forces
|
||||
// many reallocations (far more new rows than any small initial slice
|
||||
// capacity) and writes two cells into every one of them, so a
|
||||
// reintroduction of the pointer bug fails deterministically rather
|
||||
// than intermittently.
|
||||
func TestSetXlsxCellsManyNewRowsNoDataLoss(t *testing.T) {
|
||||
data, err := BuildXlsx([]Sheet{{Name: "Sheet1", Rows: [][]string{{"seed"}}}})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildXlsx: %v", err)
|
||||
}
|
||||
|
||||
const numRows = 200
|
||||
writes := make(map[string]string, numRows*2)
|
||||
for r := 2; r <= numRows+1; r++ {
|
||||
writes[fmt.Sprintf("A%d", r)] = fmt.Sprintf("label-%d", r)
|
||||
writes[fmt.Sprintf("B%d", r)] = fmt.Sprintf("=A%d", r)
|
||||
}
|
||||
|
||||
out, err := SetXlsxCells(data, "", writes)
|
||||
if err != nil {
|
||||
t.Fatalf("SetXlsxCells: %v", err)
|
||||
}
|
||||
|
||||
rows, _, err := ReadXlsxCells(out, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadXlsxCells: %v", err)
|
||||
}
|
||||
if len(rows) != numRows+1 { // +1 for the seeded row 1
|
||||
t.Fatalf("expected %d rows, got %d", numRows+1, len(rows))
|
||||
}
|
||||
for r := 2; r <= numRows+1; r++ {
|
||||
row := rows[r-1]
|
||||
if len(row) != 2 {
|
||||
t.Fatalf("row %d: expected 2 cells, got %d: %v", r, len(row), row)
|
||||
}
|
||||
wantA := fmt.Sprintf("label-%d", r)
|
||||
wantB := fmt.Sprintf("=A%d", r)
|
||||
if row[0] != wantA {
|
||||
t.Errorf("row %d col A: got %q, want %q", r, row[0], wantA)
|
||||
}
|
||||
if row[1] != wantB {
|
||||
t.Errorf("row %d col B: got %q, want %q", r, row[1], wantB)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPptxReadRoundTrip(t *testing.T) {
|
||||
data, err := BuildPptx([]Slide{
|
||||
{Title: "Welcome"},
|
||||
{Title: "Agenda", Bullets: []string{"Point one", "Point two & three"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildPptx: %v", err)
|
||||
}
|
||||
slides, err := ReadPptxSlides(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPptxSlides: %v", err)
|
||||
}
|
||||
if len(slides) != 2 {
|
||||
t.Fatalf("expected 2 slides, got %d", len(slides))
|
||||
}
|
||||
if slides[0].Title != "Welcome" {
|
||||
t.Errorf("slide 1 title: got %q", slides[0].Title)
|
||||
}
|
||||
if slides[1].Title != "Agenda" || len(slides[1].Bullets) != 2 || slides[1].Bullets[1] != "Point two & three" {
|
||||
t.Errorf("slide 2: got %+v", slides[1])
|
||||
}
|
||||
}
|
||||
206
pkg/officekit/pdf.go
Normal file
206
pkg/officekit/pdf.go
Normal file
@@ -0,0 +1,206 @@
|
||||
// pdf.go adds best-effort PDF text extraction: read_pdf's only job is
|
||||
// "give me back the words", not full-fidelity layout, so this
|
||||
// deliberately stays a few hundred lines of pure stdlib (no PDF
|
||||
// library, no cgo, nothing to go get in an offline deployment) rather
|
||||
// than a complete PDF renderer. It walks the file's raw bytes for
|
||||
// `stream ... endstream` blocks, inflates the ones declared
|
||||
// FlateDecode (the overwhelming majority of PDFs produced by real
|
||||
// tools — Word, LibreOffice, browsers, Pandoc, Pages — all compress
|
||||
// their content streams this way), and scans the decompressed
|
||||
// PostScript-like content for the handful of text-showing operators
|
||||
// (Tj, TJ, ', ") that actually place characters on the page.
|
||||
// Everything else in the file — fonts, images, vector graphics,
|
||||
// annotations, page geometry — is ignored.
|
||||
//
|
||||
// What this can't do, and won't pretend to: scanned/image-only PDFs
|
||||
// (no embedded text at all — that needs OCR, out of scope here),
|
||||
// PDFs whose content streams use a filter other than FlateDecode
|
||||
// (rare in practice), and any PDF that encodes its text with a custom
|
||||
// font encoding and no usable mapping back to plain characters — those
|
||||
// come out as empty or as mangled text, and ReadPdfText says so rather
|
||||
// than silently returning garbage.
|
||||
package officekit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/zlib"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// pdfStreamRe finds every `stream\r?\n ... \r?\nendstream` block in
|
||||
// the file, capturing the body separately from the "stream" keyword
|
||||
// that precedes it — ReadPdfText looks backward from the keyword for
|
||||
// the object dictionary's /Filter entry rather than actually parsing
|
||||
// PDF's object/dictionary syntax.
|
||||
var pdfStreamRe = regexp.MustCompile(`(?s)stream\r?\n(.*?)\r?\nendstream`)
|
||||
|
||||
// pdfDictLookback is how far back from a `stream` keyword ReadPdfText
|
||||
// looks for that stream object's dictionary (and in particular its
|
||||
// /Filter entry) — generous enough for any realistic content-stream
|
||||
// dictionary (/Length, /Filter, /Length1, a handful of other keys)
|
||||
// without risking bleeding into the previous object.
|
||||
const pdfDictLookback = 600
|
||||
|
||||
// ReadPdfText extracts a PDF's plain text content, best-effort. See
|
||||
// the package doc above for exactly what it does and doesn't handle.
|
||||
// Returns an error only when the input isn't recognizable as a PDF,
|
||||
// or no extractable text was found anywhere in it — a PDF that fails
|
||||
// to extract from some streams but not others still returns whatever
|
||||
// text it did recover from the rest.
|
||||
func ReadPdfText(data []byte) (string, error) {
|
||||
head := data
|
||||
if len(head) > 1024 {
|
||||
head = head[:1024]
|
||||
}
|
||||
if !bytes.Contains(head, []byte("%PDF-")) {
|
||||
return "", fmt.Errorf("officekit: not a PDF file (missing %%PDF- header)")
|
||||
}
|
||||
|
||||
var out strings.Builder
|
||||
for _, loc := range pdfStreamRe.FindAllSubmatchIndex(data, -1) {
|
||||
streamStart, bodyStart, bodyEnd := loc[0], loc[2], loc[3]
|
||||
dictStart := streamStart - pdfDictLookback
|
||||
if dictStart < 0 {
|
||||
dictStart = 0
|
||||
}
|
||||
dict := data[dictStart:streamStart]
|
||||
if !bytes.Contains(dict, []byte("FlateDecode")) {
|
||||
continue // not a stream this package knows how to decompress
|
||||
}
|
||||
inflated, err := inflateZlib(data[bodyStart:bodyEnd])
|
||||
if err != nil {
|
||||
continue // FlateDecode-tagged but not plain DEFLATE (e.g. an embedded font program); skip it
|
||||
}
|
||||
if text := extractShownText(inflated); text != "" {
|
||||
if out.Len() > 0 {
|
||||
out.WriteString("\n\n")
|
||||
}
|
||||
out.WriteString(text)
|
||||
}
|
||||
}
|
||||
result := strings.TrimSpace(out.String())
|
||||
if result == "" {
|
||||
return "", fmt.Errorf("officekit: no extractable text found (the PDF may be scanned/image-only, or use an encoding this reader doesn't support)")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func inflateZlib(b []byte) ([]byte, error) {
|
||||
r, err := zlib.NewReader(bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer r.Close()
|
||||
return io.ReadAll(r)
|
||||
}
|
||||
|
||||
// pdfTextOpRe matches one text-showing operator call together with
|
||||
// its operand(s): one or more PDF "literal strings" — parenthesized,
|
||||
// with their own backslash-escape syntax decoded by
|
||||
// pdfUnescapeLiteral — followed by Tj, TJ, ' or ". A TJ call's operand
|
||||
// is actually an array, e.g. `[(Hel)-20(lo)] TJ`, which is why this
|
||||
// matches "one or more" consecutive literals rather than exactly one.
|
||||
var pdfTextOpRe = regexp.MustCompile(`(?s)((?:\((?:\\.|[^()\\])*\)[^()]*?)+)(Tj|TJ|'|")`)
|
||||
|
||||
// pdfLiteralRe pulls each individual (...) literal string out of one
|
||||
// operator call's operand span.
|
||||
var pdfLiteralRe = regexp.MustCompile(`(?s)\((?:\\.|[^()\\])*\)`)
|
||||
|
||||
// pdfNewlineOpRe matches the content-stream operators that move the
|
||||
// text cursor to a new line (Td, TD, T*) — used only as a heuristic
|
||||
// for where extractShownText inserts a newline into the recovered
|
||||
// text, since the stream itself carries no notion of "paragraph" or
|
||||
// "line" beyond cursor movement.
|
||||
var pdfNewlineOpRe = regexp.MustCompile(`\bT[dD*]\b`)
|
||||
|
||||
// extractShownText walks one decompressed content stream in document
|
||||
// order and concatenates the string(s) passed to each text-showing
|
||||
// operator call, inserting a newline wherever a line-positioning
|
||||
// operator (Td/TD/T*) appeared since the previous call and a plain
|
||||
// space otherwise — an approximation of the stream's original line
|
||||
// breaks, not a layout engine. A single TJ call's several literals
|
||||
// (PDF splits one word across more than one literal to carry kerning
|
||||
// adjustments, e.g. `[(Hel)-20(lo)] TJ`) are joined with nothing
|
||||
// between them, since the gap is a spacing nudge, not a real word
|
||||
// break — any actual space in the shown text already appears as a
|
||||
// literal " " character the way it does in a Tj call.
|
||||
func extractShownText(stream []byte) string {
|
||||
var sb strings.Builder
|
||||
pos := 0
|
||||
pendingNewline := false
|
||||
for pos < len(stream) {
|
||||
loc := pdfTextOpRe.FindSubmatchIndex(stream[pos:])
|
||||
if loc == nil {
|
||||
break
|
||||
}
|
||||
matchStart, matchEnd := pos+loc[0], pos+loc[1]
|
||||
if pdfNewlineOpRe.Match(stream[pos:matchStart]) {
|
||||
pendingNewline = true
|
||||
}
|
||||
operand := stream[pos+loc[2] : pos+loc[3]]
|
||||
var call strings.Builder
|
||||
for _, lit := range pdfLiteralRe.FindAll(operand, -1) {
|
||||
call.WriteString(pdfUnescapeLiteral(lit[1 : len(lit)-1]))
|
||||
}
|
||||
if text := call.String(); text != "" {
|
||||
if sb.Len() > 0 {
|
||||
if pendingNewline {
|
||||
sb.WriteByte('\n')
|
||||
} else {
|
||||
sb.WriteByte(' ')
|
||||
}
|
||||
}
|
||||
pendingNewline = false
|
||||
sb.WriteString(text)
|
||||
}
|
||||
pos = matchEnd
|
||||
}
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
// pdfUnescapeLiteral decodes a PDF literal string's backslash escapes
|
||||
// (\n \r \t \( \) \\ and octal \ddd) and treats every other byte as
|
||||
// raw Latin-1 — correct for the common case (a simple, non-custom
|
||||
// font encoding) and the best this package can do without parsing the
|
||||
// PDF's font /Encoding and /ToUnicode tables.
|
||||
func pdfUnescapeLiteral(b []byte) string {
|
||||
var sb strings.Builder
|
||||
for i := 0; i < len(b); i++ {
|
||||
c := b[i]
|
||||
if c != '\\' || i == len(b)-1 {
|
||||
sb.WriteByte(c)
|
||||
continue
|
||||
}
|
||||
i++
|
||||
switch n := b[i]; {
|
||||
case n == 'n':
|
||||
sb.WriteByte('\n')
|
||||
case n == 'r':
|
||||
sb.WriteByte('\r')
|
||||
case n == 't':
|
||||
sb.WriteByte('\t')
|
||||
case n == 'b' || n == 'f':
|
||||
// backspace/form-feed: not meaningful in extracted text
|
||||
case n == '(' || n == ')' || n == '\\':
|
||||
sb.WriteByte(n)
|
||||
case n == '\n':
|
||||
// backslash-newline is a line continuation, not a literal char
|
||||
case n >= '0' && n <= '7':
|
||||
j := i
|
||||
for j < len(b) && j < i+3 && b[j] >= '0' && b[j] <= '7' {
|
||||
j++
|
||||
}
|
||||
if v, err := strconv.ParseInt(string(b[i:j]), 8, 32); err == nil {
|
||||
sb.WriteByte(byte(v))
|
||||
}
|
||||
i = j - 1
|
||||
default:
|
||||
sb.WriteByte(n)
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
90
pkg/officekit/pdf_test.go
Normal file
90
pkg/officekit/pdf_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package officekit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/zlib"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// buildMinimalPdf wraps one FlateDecode-compressed content stream in
|
||||
// just enough surrounding PDF syntax for ReadPdfText's regex-based
|
||||
// scanner to find it — a %PDF- header and one `N 0 obj << ... /Filter
|
||||
// /FlateDecode >> stream ... endstream endobj` object. Not a complete,
|
||||
// schema-valid PDF (no xref table, no page tree), but ReadPdfText
|
||||
// never looks at either of those, so this is a faithful enough fixture
|
||||
// for exercising it without needing a real binary PDF checked into
|
||||
// the repo.
|
||||
func buildMinimalPdf(t *testing.T, content string) []byte {
|
||||
t.Helper()
|
||||
var compressed bytes.Buffer
|
||||
zw := zlib.NewWriter(&compressed)
|
||||
if _, err := zw.Write([]byte(content)); err != nil {
|
||||
t.Fatalf("zlib write: %v", err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("zlib close: %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("%PDF-1.4\n")
|
||||
buf.WriteString("4 0 obj\n<< /Length 999 /Filter /FlateDecode >>\nstream\n")
|
||||
buf.Write(compressed.Bytes())
|
||||
buf.WriteString("\nendstream\nendobj\n%%EOF")
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestReadPdfTextBasic(t *testing.T) {
|
||||
data := buildMinimalPdf(t, "BT /F1 12 Tf 72 712 Td (Hello World) Tj ET")
|
||||
text, err := ReadPdfText(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPdfText: %v", err)
|
||||
}
|
||||
if text != "Hello World" {
|
||||
t.Errorf("got %q, want %q", text, "Hello World")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadPdfTextLineBreaksAndEscapes(t *testing.T) {
|
||||
content := "BT /F1 12 Tf 72 712 Td (Line one \\(escaped\\)) Tj\n72 700 Td (Line two) Tj ET"
|
||||
data := buildMinimalPdf(t, content)
|
||||
text, err := ReadPdfText(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPdfText: %v", err)
|
||||
}
|
||||
if !strings.Contains(text, "Line one (escaped)") {
|
||||
t.Errorf("expected unescaped parens in %q", text)
|
||||
}
|
||||
if !strings.Contains(text, "Line two") {
|
||||
t.Errorf("expected second Td'd line's text in %q", text)
|
||||
}
|
||||
if !strings.Contains(text, "\n") {
|
||||
t.Errorf("expected a newline between Td-separated text runs, got %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadPdfTextTJArrayKerning(t *testing.T) {
|
||||
data := buildMinimalPdf(t, "BT /F1 12 Tf 72 712 Td [(Hel)-20(lo)20( world)] TJ ET")
|
||||
text, err := ReadPdfText(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPdfText: %v", err)
|
||||
}
|
||||
if text != "Hello world" {
|
||||
t.Errorf("got %q, want %q (TJ kerning numbers must not become spaces)", text, "Hello world")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadPdfTextRejectsNonPdf(t *testing.T) {
|
||||
if _, err := ReadPdfText([]byte("just some ordinary bytes, not a PDF")); err == nil {
|
||||
t.Error("expected an error for input with no %PDF- header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadPdfTextNoShownTextOperators(t *testing.T) {
|
||||
// A validly-structured FlateDecode stream (so the inflate step
|
||||
// succeeds) with no Tj/TJ/'/" calls at all — e.g. a pure image draw.
|
||||
data := buildMinimalPdf(t, "q 100 0 0 100 0 0 cm /Im0 Do Q")
|
||||
if _, err := ReadPdfText(data); err == nil {
|
||||
t.Error("expected an error when the stream has no text-showing operators")
|
||||
}
|
||||
}
|
||||
376
pkg/officekit/pptx.go
Normal file
376
pkg/officekit/pptx.go
Normal file
@@ -0,0 +1,376 @@
|
||||
package officekit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Slide is one slide's content, as the model supplies it to
|
||||
// create_presentation/edit_presentation: a title and a flat list of
|
||||
// bullet lines. Deliberately this simple — no layouts, no inherited
|
||||
// placeholders, no raw DrawingML — so the model never authors
|
||||
// PresentationML either; every slide is rendered as two independent,
|
||||
// absolutely-positioned text boxes (title, body) that don't need to
|
||||
// resolve against the slide layout/master's placeholder scheme.
|
||||
type Slide struct {
|
||||
Title string
|
||||
Bullets []string
|
||||
}
|
||||
|
||||
// --- read side: typed XML for parsing a slide's shape tree back out
|
||||
// (ReadPptxSlides). Every struct tag below is a plain, unprefixed
|
||||
// local name with no namespace component — PresentationML genuinely
|
||||
// needs several simultaneous namespaces (p: for presentation elements,
|
||||
// a: for drawing elements), so unlike docx's single-namespace case we
|
||||
// can't lean on one default namespace to cover everything. Instead we
|
||||
// rely on a different, equally standard encoding/xml behavior: a field
|
||||
// tag with no namespace component matches an element by local name
|
||||
// only, regardless of which namespace it actually resolved to. So
|
||||
// "cSld" matches <p:cSld> and "xfrm" matches <a:xfrm> alike, without
|
||||
// our Go types needing to know or care which prefix/namespace a given
|
||||
// element belongs to. (See the longer version of this note on
|
||||
// wDocument in docx.go — same underlying mechanism, applied here to
|
||||
// the harder, multi-namespace case.) ---
|
||||
|
||||
type pSld struct {
|
||||
XMLName xml.Name `xml:"sld"`
|
||||
CSld pCSld `xml:"cSld"`
|
||||
}
|
||||
|
||||
type pCSld struct {
|
||||
SpTree pSpTree `xml:"spTree"`
|
||||
}
|
||||
|
||||
type pSpTree struct {
|
||||
Sp []pSp `xml:"sp"`
|
||||
}
|
||||
|
||||
type pCNvPr struct {
|
||||
Id int `xml:"id,attr"`
|
||||
Name string `xml:"name,attr"`
|
||||
}
|
||||
|
||||
type pSp struct {
|
||||
NvSpPr pNvSpPr `xml:"nvSpPr"`
|
||||
TxBody pTxBody `xml:"txBody"`
|
||||
}
|
||||
|
||||
type pNvSpPr struct {
|
||||
CNvPr pCNvPr `xml:"cNvPr"`
|
||||
}
|
||||
|
||||
type pTxBody struct {
|
||||
Paras []pTxPara `xml:"p"`
|
||||
}
|
||||
|
||||
type pTxPara struct {
|
||||
Run pTxRun `xml:"r"`
|
||||
}
|
||||
|
||||
type pTxRun struct {
|
||||
Text string `xml:"t"`
|
||||
}
|
||||
|
||||
// --- write side: deliberately NOT built with xml.Marshal. PresentationML's
|
||||
// several simultaneous namespaces mean a struct-tag-driven Marshal would
|
||||
// need Go to auto-assign namespace prefixes, which is both unpredictable
|
||||
// and avoidable here — we fully control this template, so we write the
|
||||
// real p:/a: prefixes directly and escape every piece of user-supplied
|
||||
// text through encoding/xml's own escaper (xmlEscapeText), which is the
|
||||
// same function Marshal itself uses internally for chardata. That keeps
|
||||
// the "well-formed, correctly-escaped, never touched by the model"
|
||||
// property from the package doc comment without fighting Go's namespace
|
||||
// prefix allocation. ---
|
||||
|
||||
// xmlEscapeText XML-escapes s for use as element chardata (title and
|
||||
// bullet text — the only user-supplied strings that end up in a slide).
|
||||
func xmlEscapeText(s string) string {
|
||||
var buf bytes.Buffer
|
||||
_ = xml.EscapeText(&buf, []byte(s))
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
const titleShapeTemplate = `<p:sp><p:nvSpPr><p:cNvPr id="%d" name="Title"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr>
|
||||
<p:spPr><a:xfrm><a:off x="457200" y="274638"/><a:ext cx="8229600" cy="1143000"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr>
|
||||
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:rPr lang="en-US" sz="3200" b="1"/><a:t>%s</a:t></a:r></a:p></p:txBody>
|
||||
</p:sp>`
|
||||
|
||||
const bodyParaTemplate = `<a:p><a:pPr><a:buChar char="•"/></a:pPr><a:r><a:rPr lang="en-US" sz="2000"/><a:t>%s</a:t></a:r></a:p>`
|
||||
|
||||
const bodyShapeTemplate = `<p:sp><p:nvSpPr><p:cNvPr id="%d" name="Body"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr>
|
||||
<p:spPr><a:xfrm><a:off x="457200" y="1600200"/><a:ext cx="8229600" cy="4525963"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr>
|
||||
<p:txBody><a:bodyPr/><a:lstStyle/>%s</p:txBody>
|
||||
</p:sp>`
|
||||
|
||||
func titleShape(id int, text string) string {
|
||||
return fmt.Sprintf(titleShapeTemplate, id, xmlEscapeText(text))
|
||||
}
|
||||
|
||||
func bodyShape(id int, bullets []string) string {
|
||||
var paras strings.Builder
|
||||
for _, b := range bullets {
|
||||
fmt.Fprintf(¶s, bodyParaTemplate, xmlEscapeText(b))
|
||||
}
|
||||
return fmt.Sprintf(bodyShapeTemplate, id, paras.String())
|
||||
}
|
||||
|
||||
func slideXML(s Slide) ([]byte, error) {
|
||||
var shapes strings.Builder
|
||||
if s.Title != "" {
|
||||
shapes.WriteString(titleShape(2, s.Title))
|
||||
}
|
||||
if len(s.Bullets) > 0 {
|
||||
shapes.WriteString(bodyShape(3, s.Bullets))
|
||||
}
|
||||
out := xml.Header + `<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||
<p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/>` + shapes.String() + `</p:spTree></p:cSld>
|
||||
<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>
|
||||
</p:sld>`
|
||||
return []byte(out), nil
|
||||
}
|
||||
|
||||
func slidePath(n int) string { return fmt.Sprintf("ppt/slides/slide%d.xml", n) }
|
||||
func slideRelsPath(n int) string { return fmt.Sprintf("ppt/slides/_rels/slide%d.xml.rels", n) }
|
||||
|
||||
const slideRelsBody = xml.Header + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>
|
||||
</Relationships>`
|
||||
|
||||
// BuildPptx renders slides into a complete, valid .pptx file.
|
||||
func BuildPptx(slides []Slide) ([]byte, error) {
|
||||
if len(slides) == 0 {
|
||||
slides = []Slide{{}}
|
||||
}
|
||||
|
||||
parts := []zipPart{
|
||||
{Name: "[Content_Types].xml", Body: pptxContentTypes(len(slides))},
|
||||
{Name: "_rels/.rels", Body: pptxRootRels},
|
||||
{Name: "ppt/presentation.xml", Body: pptxPresentationXML(len(slides))},
|
||||
{Name: "ppt/_rels/presentation.xml.rels", Body: pptxPresentationRels(len(slides))},
|
||||
{Name: "ppt/slideMasters/slideMaster1.xml", Body: pptxSlideMaster},
|
||||
{Name: "ppt/slideMasters/_rels/slideMaster1.xml.rels", Body: pptxSlideMasterRels},
|
||||
{Name: "ppt/slideLayouts/slideLayout1.xml", Body: pptxSlideLayout},
|
||||
{Name: "ppt/slideLayouts/_rels/slideLayout1.xml.rels", Body: pptxSlideLayoutRels},
|
||||
{Name: "ppt/theme/theme1.xml", Body: pptxTheme},
|
||||
{Name: "docProps/core.xml", Body: officeCoreProps},
|
||||
}
|
||||
for i, s := range slides {
|
||||
n := i + 1
|
||||
sx, err := slideXML(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parts = append(parts,
|
||||
zipPart{Name: slidePath(n), Body: string(sx)},
|
||||
zipPart{Name: slideRelsPath(n), Body: slideRelsBody},
|
||||
)
|
||||
}
|
||||
return buildZip(parts)
|
||||
}
|
||||
|
||||
// ReadPptxSlides returns every slide's title + bullets, in order, for
|
||||
// the read_presentation tool. It re-derives slide order and count from
|
||||
// ppt/presentation.xml's sldIdLst / presentation.xml.rels rather than
|
||||
// assuming a fixed count, so a file this package wrote (or edited)
|
||||
// round-trips exactly.
|
||||
func ReadPptxSlides(data []byte) ([]Slide, error) {
|
||||
n, err := countSlides(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []Slide
|
||||
for i := 1; i <= n; i++ {
|
||||
raw, ok, err := readZipPart(data, slidePath(i))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var sld pSld
|
||||
if err := xml.Unmarshal(raw, &sld); err != nil {
|
||||
return nil, fmt.Errorf("officekit: parse %s: %w", slidePath(i), err)
|
||||
}
|
||||
s := Slide{}
|
||||
for _, sp := range sld.CSld.SpTree.Sp {
|
||||
switch sp.NvSpPr.CNvPr.Name {
|
||||
case "Title":
|
||||
if len(sp.TxBody.Paras) > 0 {
|
||||
s.Title = sp.TxBody.Paras[0].Run.Text
|
||||
}
|
||||
case "Body":
|
||||
for _, p := range sp.TxBody.Paras {
|
||||
s.Bullets = append(s.Bullets, p.Run.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// countSlides counts how many ppt/slides/slideN.xml parts a .pptx
|
||||
// actually contains by probing sequentially (simplest reliable
|
||||
// approach given this package writes slides as a dense 1..n sequence
|
||||
// and never deletes from the middle).
|
||||
func countSlides(data []byte) (int, error) {
|
||||
n := 0
|
||||
for {
|
||||
_, ok, err := readZipPart(data, slidePath(n+1))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
n++
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// AppendPptxSlide adds one new slide at the end and returns the new
|
||||
// file bytes, keeping [Content_Types].xml, presentation.xml and
|
||||
// presentation.xml.rels in sync with the new slide count.
|
||||
func AppendPptxSlide(data []byte, s Slide) ([]byte, error) {
|
||||
n, err := countSlides(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
newN := n + 1
|
||||
sx, err := slideXML(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out, err := addZipParts(data, []zipPart{
|
||||
{Name: slidePath(newN), Body: string(sx)},
|
||||
{Name: slideRelsPath(newN), Body: slideRelsBody},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err = replaceZipPart(out, "[Content_Types].xml", []byte(pptxContentTypes(newN)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err = replaceZipPart(out, "ppt/presentation.xml", []byte(pptxPresentationXML(newN)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return replaceZipPart(out, "ppt/_rels/presentation.xml.rels", []byte(pptxPresentationRels(newN)))
|
||||
}
|
||||
|
||||
// UpdatePptxSlide replaces one existing slide's content (1-based
|
||||
// index) in place.
|
||||
func UpdatePptxSlide(data []byte, index int, s Slide) ([]byte, error) {
|
||||
n, err := countSlides(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if index < 1 || index > n {
|
||||
return nil, fmt.Errorf("officekit: slide %d out of range (presentation has %d slide(s))", index, n)
|
||||
}
|
||||
sx, err := slideXML(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return replaceZipPart(data, slidePath(index), []byte(sx))
|
||||
}
|
||||
|
||||
func pptxContentTypes(n int) string {
|
||||
var overrides strings.Builder
|
||||
for i := 1; i <= n; i++ {
|
||||
fmt.Fprintf(&overrides, `<Override PartName="/%s" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>`, slidePath(i))
|
||||
}
|
||||
return xml.Header + `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>
|
||||
<Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/>
|
||||
<Override PartName="/ppt/slideLayouts/slideLayout1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/>
|
||||
<Override PartName="/ppt/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/>
|
||||
<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>
|
||||
` + overrides.String() + `</Types>`
|
||||
}
|
||||
|
||||
func pptxPresentationXML(n int) string {
|
||||
var ids strings.Builder
|
||||
for i := 1; i <= n; i++ {
|
||||
fmt.Fprintf(&ids, `<p:sldId id="%d" r:id="rId%d"/>`, 255+i, i+1)
|
||||
}
|
||||
return xml.Header + `<p:presentation xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
|
||||
<p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst>
|
||||
<p:sldIdLst>` + ids.String() + `</p:sldIdLst>
|
||||
<p:sldSz cx="9144000" cy="6858000"/>
|
||||
<p:notesSz cx="6858000" cy="9144000"/>
|
||||
</p:presentation>`
|
||||
}
|
||||
|
||||
func pptxPresentationRels(n int) string {
|
||||
var rels strings.Builder
|
||||
for i := 1; i <= n; i++ {
|
||||
fmt.Fprintf(&rels, `<Relationship Id="rId%d" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide%d.xml"/>`, i+1, i)
|
||||
}
|
||||
return xml.Header + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="slideMasters/slideMaster1.xml"/>
|
||||
` + rels.String() + `</Relationships>`
|
||||
}
|
||||
|
||||
const pptxRootRels = xml.Header + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/>
|
||||
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
|
||||
</Relationships>`
|
||||
|
||||
const pptxSlideMasterRels = xml.Header + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>
|
||||
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="../theme/theme1.xml"/>
|
||||
</Relationships>`
|
||||
|
||||
const pptxSlideLayoutRels = xml.Header + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="../slideMasters/slideMaster1.xml"/>
|
||||
</Relationships>`
|
||||
|
||||
const pptxSlideMaster = xml.Header + `<p:sldMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
|
||||
<p:cSld><p:bg><p:bgRef idx="1001"><a:schemeClr val="bg1"/></p:bgRef></p:bg>
|
||||
<p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/></p:spTree></p:cSld>
|
||||
<p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/>
|
||||
<p:sldLayoutIdLst><p:sldLayoutId id="2147483649" r:id="rId1"/></p:sldLayoutIdLst>
|
||||
</p:sldMaster>`
|
||||
|
||||
const pptxSlideLayout = xml.Header + `<p:sldLayout xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" type="blank" preserve="1">
|
||||
<p:cSld name="Blank">
|
||||
<p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/></p:spTree>
|
||||
</p:cSld>
|
||||
<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>
|
||||
</p:sldLayout>`
|
||||
|
||||
const pptxTheme = xml.Header + `<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="enkly">
|
||||
<a:themeElements>
|
||||
<a:clrScheme name="enkly">
|
||||
<a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1>
|
||||
<a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1>
|
||||
<a:dk2><a:srgbClr val="44546A"/></a:dk2>
|
||||
<a:lt2><a:srgbClr val="E7E6E6"/></a:lt2>
|
||||
<a:accent1><a:srgbClr val="4472C4"/></a:accent1>
|
||||
<a:accent2><a:srgbClr val="ED7D31"/></a:accent2>
|
||||
<a:accent3><a:srgbClr val="A5A5A5"/></a:accent3>
|
||||
<a:accent4><a:srgbClr val="FFC000"/></a:accent4>
|
||||
<a:accent5><a:srgbClr val="5B9BD5"/></a:accent5>
|
||||
<a:accent6><a:srgbClr val="70AD47"/></a:accent6>
|
||||
<a:hlink><a:srgbClr val="0563C1"/></a:hlink>
|
||||
<a:folHlink><a:srgbClr val="954F72"/></a:folHlink>
|
||||
</a:clrScheme>
|
||||
<a:fontScheme name="enkly">
|
||||
<a:majorFont><a:latin typeface="Calibri Light"/><a:ea typeface=""/><a:cs typeface=""/></a:majorFont>
|
||||
<a:minorFont><a:latin typeface="Calibri"/><a:ea typeface=""/><a:cs typeface=""/></a:minorFont>
|
||||
</a:fontScheme>
|
||||
<a:fmtScheme name="enkly">
|
||||
<a:fillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:fillStyleLst>
|
||||
<a:lnStyleLst><a:ln><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln><a:ln><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln><a:ln><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln></a:lnStyleLst>
|
||||
<a:effectStyleLst><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle></a:effectStyleLst>
|
||||
<a:bgFillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:bgFillStyleLst>
|
||||
</a:fmtScheme>
|
||||
</a:themeElements>
|
||||
</a:theme>`
|
||||
302
pkg/officekit/redline.go
Normal file
302
pkg/officekit/redline.go
Normal file
@@ -0,0 +1,302 @@
|
||||
// redline.go adds lightweight "track changes" (redlining) support on
|
||||
// top of the plain append/replace operations in docx.go: the same
|
||||
// kind of edit — change this document's text — but recorded as a
|
||||
// Word-reviewable <w:ins>/<w:del> pair instead of a silent in-place
|
||||
// rewrite, plus accept/reject to resolve those changes afterward.
|
||||
// Word, LibreOffice and Google Docs (on import) all read and display
|
||||
// these the same way: Review > Track Changes.
|
||||
//
|
||||
// Scope, deliberately: a tracked replace marks the WHOLE run that
|
||||
// contained a match as changed (old text struck through, new text
|
||||
// inserted next to it) rather than computing a minimal character-level
|
||||
// diff within the run — the same "whole unit, not a sub-span"
|
||||
// tradeoff ReplaceDocxText already makes implicitly by working at the
|
||||
// <w:t> level (see its doc comment in docx.go). A run in a real
|
||||
// document is usually already one coherent span of text in one
|
||||
// formatting (Word splits runs on formatting changes, not mid-word),
|
||||
// so in practice this reads as a natural redline rather than an
|
||||
// oversized strikethrough.
|
||||
package officekit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// wRunRe matches one complete <w:r>...</w:r> run — its opening tag,
|
||||
// any attributes, its full inner content, and its closing tag — so a
|
||||
// tracked replace can pull the run's formatting (its leading <w:rPr>,
|
||||
// if any) out and copy it into both halves of the del/ins pair it
|
||||
// produces. Safe as a non-nested regex because OOXML's schema never
|
||||
// allows a <w:r> to contain another <w:r> (a run is the schema's
|
||||
// smallest text-formatting unit, not a container for other runs).
|
||||
var wRunRe = regexp.MustCompile(`(?s)<((?:[A-Za-z][\w.-]*:)?r)(\s[^>]*)?>(.*?)</(?:[A-Za-z][\w.-]*:)?r>`)
|
||||
|
||||
// wRPrLeadingRe pulls a run's leading <w:rPr>...</w:rPr> block (if
|
||||
// any) off the front of its inner content.
|
||||
var wRPrLeadingRe = regexp.MustCompile(`(?s)^\s*(<(?:[A-Za-z][\w.-]*:)?rPr>.*?</(?:[A-Za-z][\w.-]*:)?rPr>)`)
|
||||
|
||||
// wInsBlockRe / wDelBlockRe match one complete <w:ins>/<w:del> block,
|
||||
// capturing its opening tag's attributes and its inner content
|
||||
// separately — AcceptAllDocxChanges, RejectAllDocxChanges and
|
||||
// ReadDocxRevisions all work by matching one or the other and either
|
||||
// keeping, dropping, or reporting on what's inside.
|
||||
var wInsBlockRe = regexp.MustCompile(`(?s)<(?:[A-Za-z][\w.-]*:)?ins(\s[^>]*)?>(.*?)</(?:[A-Za-z][\w.-]*:)?ins>`)
|
||||
var wDelBlockRe = regexp.MustCompile(`(?s)<(?:[A-Za-z][\w.-]*:)?del(\s[^>]*)?>(.*?)</(?:[A-Za-z][\w.-]*:)?del>`)
|
||||
|
||||
// wDelTextElementRe matches one <w:delText>...</w:delText> element,
|
||||
// the deletion-tracking equivalent of <w:t> used only inside <w:del>
|
||||
// runs — RejectAllDocxChanges turns these back into ordinary <w:t>
|
||||
// elements so the restored (un-deleted) text renders normally again.
|
||||
var wDelTextElementRe = regexp.MustCompile(`(?s)(<(?:[A-Za-z][\w.-]*:)?delText(?:\s[^>]*)?>)(.*?)(</(?:[A-Za-z][\w.-]*:)?delText>)`)
|
||||
|
||||
// changeAuthorRe pulls the author="..." (optionally namespace-
|
||||
// prefixed) attribute value out of a <w:ins>/<w:del> opening tag's
|
||||
// captured attribute string.
|
||||
var changeAuthorRe = regexp.MustCompile(`(?:^|\s)(?:[\w.-]+:)?author="([^"]*)"`)
|
||||
|
||||
// revisionIDRe finds every existing id="N" attribute in a
|
||||
// document.xml, so new tracked changes can continue numbering from
|
||||
// the highest ID already present rather than risking a collision with
|
||||
// revisions from an earlier edit (an uploaded document, or an earlier
|
||||
// tool call in the same session).
|
||||
var revisionIDRe = regexp.MustCompile(`id="(\d+)"`)
|
||||
|
||||
func nextRevisionIDFrom(doc []byte) int {
|
||||
max := 0
|
||||
for _, m := range revisionIDRe.FindAllSubmatch(doc, -1) {
|
||||
if n, err := strconv.Atoi(string(m[1])); err == nil && n > max {
|
||||
max = n
|
||||
}
|
||||
}
|
||||
return max + 1
|
||||
}
|
||||
|
||||
// revisionDate is w:date's expected format (ISO 8601, UTC, second
|
||||
// precision) — the same convention Word itself writes.
|
||||
func revisionDate() string {
|
||||
return time.Now().UTC().Format("2006-01-02T15:04:05Z")
|
||||
}
|
||||
|
||||
func xmlEscapeAttrVal(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "\"", """)
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
return s
|
||||
}
|
||||
|
||||
// ReplaceDocxTextTracked is ReplaceDocxText's tracked-changes sibling:
|
||||
// instead of silently rewriting matching text runs in place, it turns
|
||||
// each matching run into a <w:del>/<w:ins> pair recording who changed
|
||||
// it and when, exactly as if a human reviewer had made the edit with
|
||||
// Track Changes switched on in Word. See this file's package doc for
|
||||
// the whole-run-not-sub-span scope this operates at. author defaults
|
||||
// to "enkly agent" if empty.
|
||||
func ReplaceDocxTextTracked(data []byte, find, replace, author string) ([]byte, int, error) {
|
||||
if author == "" {
|
||||
author = "enkly agent"
|
||||
}
|
||||
raw, ok, err := readZipPart(data, docxDocumentPart)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, 0, fmt.Errorf("officekit: %s not found (not a valid .docx)", docxDocumentPart)
|
||||
}
|
||||
|
||||
nextID := nextRevisionIDFrom(raw)
|
||||
date := revisionDate()
|
||||
changed := 0
|
||||
newDoc := wRunRe.ReplaceAllFunc(raw, func(match []byte) []byte {
|
||||
g := wRunRe.FindSubmatch(match)
|
||||
inner := g[3]
|
||||
tm := wTextElementRe.FindSubmatch(inner)
|
||||
if tm == nil {
|
||||
return match // a tab, break, or field run — no <w:t> to compare against
|
||||
}
|
||||
oldText := xmlUnescapeText(tm[2])
|
||||
if !strings.Contains(oldText, find) {
|
||||
return match
|
||||
}
|
||||
newText := strings.ReplaceAll(oldText, find, replace)
|
||||
changed++
|
||||
|
||||
rPr := ""
|
||||
if m := wRPrLeadingRe.FindSubmatch(inner); m != nil {
|
||||
rPr = string(m[1])
|
||||
}
|
||||
delID, insID := nextID, nextID+1
|
||||
nextID += 2
|
||||
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, `<del id="%d" author="%s" date="%s"><r>%s<delText xml:space="preserve">%s</delText></r></del>`,
|
||||
delID, xmlEscapeAttrVal(author), date, rPr, xmlEscapeText(oldText))
|
||||
fmt.Fprintf(&sb, `<ins id="%d" author="%s" date="%s"><r>%s<t xml:space="preserve">%s</t></r></ins>`,
|
||||
insID, xmlEscapeAttrVal(author), date, rPr, xmlEscapeText(newText))
|
||||
return []byte(sb.String())
|
||||
})
|
||||
if changed == 0 {
|
||||
return nil, 0, fmt.Errorf("officekit: %q not found in any text run", find)
|
||||
}
|
||||
out, err := replaceZipPart(data, docxDocumentPart, newDoc)
|
||||
return out, changed, err
|
||||
}
|
||||
|
||||
// wIns is a <w:ins> element's content: the tracked-insertion wrapper
|
||||
// AppendDocxParagraphTracked puts around a brand-new paragraph's run,
|
||||
// mirroring ReplaceDocxTextTracked's own <ins id author date><r>...
|
||||
// shape above but built with encoding/xml (like the rest of a freshly
|
||||
// generated paragraph fragment) rather than string formatting, since
|
||||
// here — unlike a splice into arbitrary existing XML — the whole
|
||||
// fragment is this package's own output.
|
||||
type wIns struct {
|
||||
ID int `xml:"id,attr"`
|
||||
Author string `xml:"author,attr"`
|
||||
Date string `xml:"date,attr"`
|
||||
Run wRun `xml:"r"`
|
||||
}
|
||||
|
||||
// wInsParagraphFragment is wParagraphFragment's (docx.go)
|
||||
// tracked-insert counterpart: same paragraph content, but with its
|
||||
// run wrapped in <w:ins> so a reviewer sees the whole new paragraph as
|
||||
// a pending insertion rather than it silently becoming part of the
|
||||
// document.
|
||||
type wInsParagraphFragment struct {
|
||||
XMLName xml.Name `xml:"http://schemas.openxmlformats.org/wordprocessingml/2006/main p"`
|
||||
PPr *wPPr `xml:"pPr,omitempty"`
|
||||
Ins wIns `xml:"ins"`
|
||||
}
|
||||
|
||||
// AppendDocxParagraphTracked is AppendDocxParagraph's tracked-changes
|
||||
// sibling: the new paragraph is spliced in exactly the same way (see
|
||||
// AppendDocxParagraph's doc comment in docx.go), but its run is
|
||||
// wrapped in <w:ins>, so it shows up in Word as a pending insertion
|
||||
// (Review > Track Changes) instead of silently becoming part of the
|
||||
// document. author defaults to "enkly agent" if empty.
|
||||
func AppendDocxParagraphTracked(data []byte, p Paragraph, author string) ([]byte, error) {
|
||||
if author == "" {
|
||||
author = "enkly agent"
|
||||
}
|
||||
raw, ok, err := readZipPart(data, docxDocumentPart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("officekit: %s not found (not a valid .docx)", docxDocumentPart)
|
||||
}
|
||||
wp := toWParagraph(p)
|
||||
frag := wInsParagraphFragment{
|
||||
PPr: wp.PPr,
|
||||
Ins: wIns{ID: nextRevisionIDFrom(raw), Author: author, Date: revisionDate(), Run: wp.Run},
|
||||
}
|
||||
paraXML, err := xml.Marshal(frag)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("officekit: marshal tracked paragraph: %w", err)
|
||||
}
|
||||
at := bodyInsertionPoint(raw)
|
||||
if at < 0 {
|
||||
return nil, fmt.Errorf("officekit: %s has no <w:body> to append to (not a valid .docx)", docxDocumentPart)
|
||||
}
|
||||
newDoc := make([]byte, 0, len(raw)+len(paraXML))
|
||||
newDoc = append(newDoc, raw[:at]...)
|
||||
newDoc = append(newDoc, paraXML...)
|
||||
newDoc = append(newDoc, raw[at:]...)
|
||||
return replaceZipPart(data, docxDocumentPart, newDoc)
|
||||
}
|
||||
|
||||
// AcceptAllDocxChanges resolves every tracked change in the document
|
||||
// the way Word's "Accept All Changes" does: deleted text (<w:del>) is
|
||||
// removed for good, and inserted text (<w:ins>) becomes ordinary,
|
||||
// no-longer-marked content.
|
||||
func AcceptAllDocxChanges(data []byte) ([]byte, error) {
|
||||
raw, ok, err := readZipPart(data, docxDocumentPart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("officekit: %s not found (not a valid .docx)", docxDocumentPart)
|
||||
}
|
||||
newDoc := wDelBlockRe.ReplaceAll(raw, nil)
|
||||
newDoc = wInsBlockRe.ReplaceAllFunc(newDoc, func(m []byte) []byte {
|
||||
return wInsBlockRe.FindSubmatch(m)[2]
|
||||
})
|
||||
return replaceZipPart(data, docxDocumentPart, newDoc)
|
||||
}
|
||||
|
||||
// RejectAllDocxChanges resolves every tracked change the way Word's
|
||||
// "Reject All Changes" does: inserted text (<w:ins>) is discarded, and
|
||||
// deleted text (<w:del>) is restored as ordinary content.
|
||||
func RejectAllDocxChanges(data []byte) ([]byte, error) {
|
||||
raw, ok, err := readZipPart(data, docxDocumentPart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("officekit: %s not found (not a valid .docx)", docxDocumentPart)
|
||||
}
|
||||
newDoc := wInsBlockRe.ReplaceAll(raw, nil)
|
||||
newDoc = wDelBlockRe.ReplaceAllFunc(newDoc, func(m []byte) []byte {
|
||||
inner := wDelBlockRe.FindSubmatch(m)[2]
|
||||
// delText -> t, so the restored text renders as normal content
|
||||
// rather than staying tagged as a (now orphaned) deletion.
|
||||
return wDelTextElementRe.ReplaceAllFunc(inner, func(tm []byte) []byte {
|
||||
g := wDelTextElementRe.FindSubmatch(tm)
|
||||
open := bytes.Replace(g[1], []byte("delText"), []byte("t"), 1)
|
||||
closeTag := bytes.Replace(g[3], []byte("delText"), []byte("t"), 1)
|
||||
out := make([]byte, 0, len(open)+len(g[2])+len(closeTag))
|
||||
out = append(out, open...)
|
||||
out = append(out, g[2]...)
|
||||
out = append(out, closeTag...)
|
||||
return out
|
||||
})
|
||||
})
|
||||
return replaceZipPart(data, docxDocumentPart, newDoc)
|
||||
}
|
||||
|
||||
// wChangeBlockRe matches one complete <w:ins>...</w:ins> or
|
||||
// <w:del>...</w:del> block, capturing which kind it is, its opening
|
||||
// tag's attributes, and its inner content — ReadDocxRevisions' single
|
||||
// pass over the document.
|
||||
var wChangeBlockRe = regexp.MustCompile(`(?s)<(?:[A-Za-z][\w.-]*:)?(ins|del)(\s[^>]*)?>(.*?)</(?:[A-Za-z][\w.-]*:)?(?:ins|del)>`)
|
||||
|
||||
// ReadDocxRevisions lists every tracked change (insertion or deletion)
|
||||
// currently in the document — author and the affected text, in
|
||||
// document order — so a caller can review what Track Changes has
|
||||
// recorded before deciding whether to accept or reject it. Returns
|
||||
// ("", nil) if the document has no tracked changes at all.
|
||||
func ReadDocxRevisions(data []byte) (string, error) {
|
||||
raw, ok, err := readZipPart(data, docxDocumentPart)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !ok {
|
||||
return "", fmt.Errorf("officekit: %s not found (not a valid .docx)", docxDocumentPart)
|
||||
}
|
||||
matches := wChangeBlockRe.FindAllSubmatch(raw, -1)
|
||||
if len(matches) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
var sb strings.Builder
|
||||
for i, m := range matches {
|
||||
kind, attrs, inner := string(m[1]), m[2], m[3]
|
||||
author := "unknown"
|
||||
if am := changeAuthorRe.FindSubmatch(attrs); am != nil {
|
||||
author = string(am[1])
|
||||
}
|
||||
label, textRe := "inserted", wTextElementRe
|
||||
if kind == "del" {
|
||||
label, textRe = "deleted", wDelTextElementRe
|
||||
}
|
||||
var text strings.Builder
|
||||
for _, tm := range textRe.FindAllSubmatch(inner, -1) {
|
||||
text.WriteString(xmlUnescapeText(tm[2]))
|
||||
}
|
||||
fmt.Fprintf(&sb, "%d. %s by %s: %q\n", i+1, label, author, text.String())
|
||||
}
|
||||
return strings.TrimRight(sb.String(), "\n"), nil
|
||||
}
|
||||
114
pkg/officekit/redline_test.go
Normal file
114
pkg/officekit/redline_test.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package officekit
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReplaceDocxTextTrackedRoundTrip(t *testing.T) {
|
||||
data := minimalWordDoc(t)
|
||||
out, n, err := ReplaceDocxTextTracked(data, "cell one text", "updated cell text", "Reviewer A")
|
||||
if err != nil {
|
||||
t.Fatalf("ReplaceDocxTextTracked: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("expected 1 run changed, got %d", n)
|
||||
}
|
||||
|
||||
// Plain read-back should show the NEW text (deleted text is review
|
||||
// chrome, not live content) and still see the untouched paragraph.
|
||||
text, err := ReadDocxText(out)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDocxText: %v", err)
|
||||
}
|
||||
if !strings.Contains(text, "updated cell text") {
|
||||
t.Errorf("expected new text in read-back, got: %s", text)
|
||||
}
|
||||
if !strings.Contains(text, "Intro paragraph.") {
|
||||
t.Errorf("unrelated paragraph lost: %s", text)
|
||||
}
|
||||
|
||||
revisions, err := ReadDocxRevisions(out)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDocxRevisions: %v", err)
|
||||
}
|
||||
for _, want := range []string{"Reviewer A", "cell one text", "updated cell text"} {
|
||||
if !strings.Contains(revisions, want) {
|
||||
t.Errorf("expected revisions summary to contain %q, got:\n%s", want, revisions)
|
||||
}
|
||||
}
|
||||
|
||||
raw, ok, err := readZipPart(out, docxDocumentPart)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("readZipPart: ok=%v err=%v", ok, err)
|
||||
}
|
||||
body := string(raw)
|
||||
if !strings.Contains(body, "<del ") || !strings.Contains(body, "<ins ") {
|
||||
t.Errorf("expected <del>/<ins> elements in document.xml, got:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendDocxParagraphTrackedThenAccept(t *testing.T) {
|
||||
data := minimalWordDoc(t)
|
||||
out, err := AppendDocxParagraphTracked(data, Paragraph{Text: "Pending new line."}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("AppendDocxParagraphTracked: %v", err)
|
||||
}
|
||||
revisions, err := ReadDocxRevisions(out)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDocxRevisions: %v", err)
|
||||
}
|
||||
if !strings.Contains(revisions, "enkly agent") || !strings.Contains(revisions, "Pending new line.") {
|
||||
t.Errorf("expected default author + inserted text in revisions, got:\n%s", revisions)
|
||||
}
|
||||
|
||||
accepted, err := AcceptAllDocxChanges(out)
|
||||
if err != nil {
|
||||
t.Fatalf("AcceptAllDocxChanges: %v", err)
|
||||
}
|
||||
text, err := ReadDocxText(accepted)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDocxText: %v", err)
|
||||
}
|
||||
if !strings.Contains(text, "Pending new line.") {
|
||||
t.Errorf("expected accepted insertion to survive as plain text, got: %s", text)
|
||||
}
|
||||
if noRev, err := ReadDocxRevisions(accepted); err != nil || noRev != "" {
|
||||
t.Errorf("expected no revisions left after accept, got %q (err=%v)", noRev, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceDocxTextTrackedThenReject(t *testing.T) {
|
||||
data := minimalWordDoc(t)
|
||||
out, _, err := ReplaceDocxTextTracked(data, "Intro paragraph.", "Rewritten intro.", "Reviewer B")
|
||||
if err != nil {
|
||||
t.Fatalf("ReplaceDocxTextTracked: %v", err)
|
||||
}
|
||||
rejected, err := RejectAllDocxChanges(out)
|
||||
if err != nil {
|
||||
t.Fatalf("RejectAllDocxChanges: %v", err)
|
||||
}
|
||||
text, err := ReadDocxText(rejected)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDocxText: %v", err)
|
||||
}
|
||||
if !strings.Contains(text, "Intro paragraph.") {
|
||||
t.Errorf("expected original text restored after reject, got: %s", text)
|
||||
}
|
||||
if strings.Contains(text, "Rewritten intro.") {
|
||||
t.Errorf("rejected insertion should not survive, got: %s", text)
|
||||
}
|
||||
if noRev, err := ReadDocxRevisions(rejected); err != nil || noRev != "" {
|
||||
t.Errorf("expected no revisions left after reject, got %q (err=%v)", noRev, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadDocxRevisionsEmptyWhenNoChanges(t *testing.T) {
|
||||
revisions, err := ReadDocxRevisions(minimalWordDoc(t))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDocxRevisions: %v", err)
|
||||
}
|
||||
if revisions != "" {
|
||||
t.Errorf("expected no revisions in a plain document, got: %s", revisions)
|
||||
}
|
||||
}
|
||||
505
pkg/officekit/xlsx.go
Normal file
505
pkg/officekit/xlsx.go
Normal file
@@ -0,0 +1,505 @@
|
||||
package officekit
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Sheet is one worksheet's data, as the model supplies it to
|
||||
// create_spreadsheet: a name and a grid of string cell values (numbers
|
||||
// are written as plain decimal strings; anything that parses as a
|
||||
// number is stored as a numeric cell so Excel treats it as one).
|
||||
type Sheet struct {
|
||||
Name string
|
||||
Rows [][]string
|
||||
}
|
||||
|
||||
const xlsxWorkbookPart = "xl/workbook.xml"
|
||||
const xlsxWorkbookRelsPart = "xl/_rels/workbook.xml.rels"
|
||||
|
||||
// xlsxSheetPart returns the conventional worksheet part path for the
|
||||
// Nth (1-based) sheet BuildXlsx writes — sheet1.xml, sheet2.xml, ...
|
||||
func xlsxSheetPart(n int) string { return fmt.Sprintf("xl/worksheets/sheet%d.xml", n) }
|
||||
|
||||
// --- typed XML for the parts BuildXlsx/edit operations actually
|
||||
// generate from user-supplied content (worksheet XML, plus the small
|
||||
// workbook/rels indirection needed to support more than one sheet);
|
||||
// every other part is static boilerplate with no user content. ---
|
||||
|
||||
type sWorksheet struct {
|
||||
XMLName xml.Name `xml:"worksheet"`
|
||||
Xmlns string `xml:"xmlns,attr"`
|
||||
SheetData sSheetData `xml:"sheetData"`
|
||||
}
|
||||
|
||||
type sSheetData struct {
|
||||
Rows []sRow `xml:"row"`
|
||||
}
|
||||
|
||||
type sRow struct {
|
||||
R int `xml:"r,attr"`
|
||||
Cells []sCell `xml:"c"`
|
||||
}
|
||||
|
||||
// sCell's field order (after the r/t attrs) follows OOXML's CT_Cell
|
||||
// schema sequence — f (formula), v (value), is (inline string) — so a
|
||||
// formula cell with a cached value (f + v both set, as real Excel
|
||||
// always writes them) round-trips as a schema-valid element.
|
||||
type sCell struct {
|
||||
R string `xml:"r,attr"`
|
||||
Type string `xml:"t,attr,omitempty"` // "inlineStr" for text; omitted (numeric/formula) otherwise
|
||||
Formula *string `xml:"f,omitempty"` // formula cells ("=SUM(...)" minus the leading "=")
|
||||
Value *string `xml:"v,omitempty"` // numeric cells, and a formula's last-known/cached result
|
||||
Is *sIs `xml:"is,omitempty"` // inline-string cells
|
||||
}
|
||||
|
||||
type sIs struct {
|
||||
T string `xml:"t"`
|
||||
}
|
||||
|
||||
// sWorkbook is the minimal shape of xl/workbook.xml this package
|
||||
// cares about: just enough to resolve a sheet name/1-based index to
|
||||
// its relationship id, so ReadXlsxCells/SetXlsxCells can find the
|
||||
// right worksheet part even in a workbook with more than one sheet
|
||||
// (including ones not authored by BuildXlsx, e.g. a user upload).
|
||||
type sWorkbook struct {
|
||||
XMLName xml.Name `xml:"workbook"`
|
||||
Sheets []sWbName `xml:"sheets>sheet"`
|
||||
}
|
||||
|
||||
type sWbName struct {
|
||||
Name string `xml:"name,attr"`
|
||||
RID string `xml:"id,attr"` // r:id — encoding/xml matches the local name regardless of the "r" namespace prefix.
|
||||
}
|
||||
|
||||
// sRelationships is the minimal shape of a .rels part this package
|
||||
// needs: a relationship id -> target path.
|
||||
type sRelationships struct {
|
||||
XMLName xml.Name `xml:"Relationships"`
|
||||
Rel []sRelationship `xml:"Relationship"`
|
||||
}
|
||||
|
||||
type sRelationship struct {
|
||||
ID string `xml:"Id,attr"`
|
||||
Target string `xml:"Target,attr"`
|
||||
}
|
||||
|
||||
// colLetter converts a 0-based column index to its spreadsheet letter
|
||||
// (0 -> A, 25 -> Z, 26 -> AA, ...).
|
||||
func colLetter(i int) string {
|
||||
s := ""
|
||||
for {
|
||||
s = string(rune('A'+(i%26))) + s
|
||||
i = i/26 - 1
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func isNumeric(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
_, err := strconv.ParseFloat(s, 64)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func rowsToSheetXML(rows [][]string) ([]byte, error) {
|
||||
sd := sSheetData{}
|
||||
for ri, row := range rows {
|
||||
r := sRow{R: ri + 1}
|
||||
for ci, val := range row {
|
||||
cell := sCell{R: colLetter(ci) + strconv.Itoa(ri+1)}
|
||||
setCellValue(&cell, val)
|
||||
r.Cells = append(r.Cells, cell)
|
||||
}
|
||||
sd.Rows = append(sd.Rows, r)
|
||||
}
|
||||
ws := sWorksheet{Xmlns: "http://schemas.openxmlformats.org/spreadsheetml/2006/main", SheetData: sd}
|
||||
body, err := xml.Marshal(ws)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("officekit: marshal worksheet xml: %w", err)
|
||||
}
|
||||
return []byte(xml.Header + string(body)), nil
|
||||
}
|
||||
|
||||
// BuildXlsx renders one or more sheets into a complete, valid .xlsx
|
||||
// workbook — every sheet given is written as its own worksheet part
|
||||
// and listed in xl/workbook.xml in order, each with a matching
|
||||
// relationship in xl/_rels/workbook.xml.rels. An unnamed sheet gets
|
||||
// the conventional "SheetN" name.
|
||||
func BuildXlsx(sheets []Sheet) ([]byte, error) {
|
||||
if len(sheets) == 0 {
|
||||
sheets = []Sheet{{Name: "Sheet1"}}
|
||||
}
|
||||
|
||||
parts := []zipPart{
|
||||
{Name: "_rels/.rels", Body: xlsxRootRels},
|
||||
{Name: "xl/styles.xml", Body: xlsxStyles},
|
||||
{Name: "docProps/core.xml", Body: officeCoreProps},
|
||||
}
|
||||
|
||||
var sheetEntries, relEntries, overrides strings.Builder
|
||||
for i, sh := range sheets {
|
||||
idx := i + 1
|
||||
name := sh.Name
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("Sheet%d", idx)
|
||||
}
|
||||
sheetXML, err := rowsToSheetXML(sh.Rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parts = append(parts, zipPart{Name: xlsxSheetPart(idx), Body: string(sheetXML)})
|
||||
fmt.Fprintf(&sheetEntries, `<sheet name="%s" sheetId="%d" r:id="rId%d"/>`, xmlEscapeAttr(name), idx, idx)
|
||||
fmt.Fprintf(&relEntries, `<Relationship Id="rId%d" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet%d.xml"/>`, idx, idx)
|
||||
fmt.Fprintf(&overrides, `<Override PartName="/xl/worksheets/sheet%d.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`, idx)
|
||||
}
|
||||
// The styles relationship id just needs to be distinct from every
|
||||
// sheet's rId above; one past the last sheet always is.
|
||||
fmt.Fprintf(&relEntries, `<Relationship Id="rId%d" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>`, len(sheets)+1)
|
||||
|
||||
workbookXML := xml.Header + `<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>` +
|
||||
sheetEntries.String() + `</sheets></workbook>`
|
||||
workbookRels := xml.Header + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
|
||||
relEntries.String() + `</Relationships>`
|
||||
contentTypes := xml.Header + `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +
|
||||
`<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>` +
|
||||
`<Default Extension="xml" ContentType="application/xml"/>` +
|
||||
`<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>` +
|
||||
overrides.String() +
|
||||
`<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>` +
|
||||
`<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/></Types>`
|
||||
|
||||
parts = append(parts,
|
||||
zipPart{Name: "[Content_Types].xml", Body: contentTypes},
|
||||
zipPart{Name: xlsxWorkbookPart, Body: workbookXML},
|
||||
zipPart{Name: xlsxWorkbookRelsPart, Body: workbookRels},
|
||||
)
|
||||
return buildZip(parts)
|
||||
}
|
||||
|
||||
// xmlEscapeAttr escapes the handful of characters that matter inside
|
||||
// an XML attribute value. Used only for the places we interpolate
|
||||
// user text into a hand-written template string (sheet names in
|
||||
// workbook.xml) rather than through encoding/xml.
|
||||
func xmlEscapeAttr(s string) string {
|
||||
r := strings.NewReplacer(`&`, "&", `<`, "<", `>`, ">", `"`, """)
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
// ReadXlsxSheetNames returns every sheet name in the workbook, in
|
||||
// order — used by read_spreadsheet to tell the model what else is in
|
||||
// a multi-sheet file it only asked to read one sheet of.
|
||||
func ReadXlsxSheetNames(data []byte) ([]string, error) {
|
||||
wb, err := readXlsxWorkbook(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make([]string, len(wb.Sheets))
|
||||
for i, s := range wb.Sheets {
|
||||
names[i] = s.Name
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func readXlsxWorkbook(data []byte) (*sWorkbook, error) {
|
||||
raw, ok, err := readZipPart(data, xlsxWorkbookPart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("officekit: %s not found (not a valid .xlsx)", xlsxWorkbookPart)
|
||||
}
|
||||
var wb sWorkbook
|
||||
if err := xml.Unmarshal(raw, &wb); err != nil {
|
||||
return nil, fmt.Errorf("officekit: parse workbook xml: %w", err)
|
||||
}
|
||||
return &wb, nil
|
||||
}
|
||||
|
||||
// resolveXlsxSheetPart maps a sheet reference (empty for the first
|
||||
// sheet, a 1-based index as a string, or a sheet name) to that sheet's
|
||||
// actual worksheet part path, by reading xl/workbook.xml and
|
||||
// xl/_rels/workbook.xml.rels — not by assuming sheet1.xml is sheet
|
||||
// one, since that isn't guaranteed for a file this package didn't
|
||||
// write itself (a user-uploaded workbook whose sheets were reordered
|
||||
// or renumbered in Excel). Falls back to the sheet1.xml convention
|
||||
// only if the workbook's own bookkeeping can't be parsed at all.
|
||||
func resolveXlsxSheetPart(data []byte, sheetRef string) (partPath, sheetName string, err error) {
|
||||
wb, err := readXlsxWorkbook(data)
|
||||
if err != nil || len(wb.Sheets) == 0 {
|
||||
return xlsxSheetPart(1), "", nil
|
||||
}
|
||||
|
||||
var target *sWbName
|
||||
switch {
|
||||
case sheetRef == "":
|
||||
target = &wb.Sheets[0]
|
||||
default:
|
||||
if n, convErr := strconv.Atoi(sheetRef); convErr == nil {
|
||||
if n < 1 || n > len(wb.Sheets) {
|
||||
return "", "", fmt.Errorf("officekit: sheet %d out of range (workbook has %d sheet(s))", n, len(wb.Sheets))
|
||||
}
|
||||
target = &wb.Sheets[n-1]
|
||||
} else {
|
||||
for i := range wb.Sheets {
|
||||
if strings.EqualFold(wb.Sheets[i].Name, sheetRef) {
|
||||
target = &wb.Sheets[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
return "", "", fmt.Errorf("officekit: no sheet named %q", sheetRef)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
relsRaw, ok, err := readZipPart(data, xlsxWorkbookRelsPart)
|
||||
if err != nil || !ok {
|
||||
return xlsxSheetPart(1), target.Name, nil
|
||||
}
|
||||
var rels sRelationships
|
||||
if err := xml.Unmarshal(relsRaw, &rels); err != nil {
|
||||
return xlsxSheetPart(1), target.Name, nil
|
||||
}
|
||||
for _, r := range rels.Rel {
|
||||
if r.ID == target.RID {
|
||||
t := strings.TrimPrefix(r.Target, "/")
|
||||
if !strings.HasPrefix(t, "xl/") {
|
||||
t = "xl/" + t
|
||||
}
|
||||
return t, target.Name, nil
|
||||
}
|
||||
}
|
||||
return xlsxSheetPart(1), target.Name, nil
|
||||
}
|
||||
|
||||
// ReadXlsxCells returns one worksheet's cells as a row-major grid of
|
||||
// display strings, for the read_spreadsheet tool. sheet selects which
|
||||
// one: empty for the first sheet, a 1-based index ("2"), or a sheet
|
||||
// name (case-insensitive); the resolved name is returned alongside so
|
||||
// callers can report it.
|
||||
func ReadXlsxCells(data []byte, sheet string) ([][]string, string, error) {
|
||||
partPath, sheetName, err := resolveXlsxSheetPart(data, sheet)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
raw, ok, err := readZipPart(data, partPath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if !ok {
|
||||
return nil, "", fmt.Errorf("officekit: %s not found (not a valid .xlsx)", partPath)
|
||||
}
|
||||
var ws sWorksheet
|
||||
if err := xml.Unmarshal(raw, &ws); err != nil {
|
||||
return nil, "", fmt.Errorf("officekit: parse worksheet xml: %w", err)
|
||||
}
|
||||
var out [][]string
|
||||
for _, row := range ws.SheetData.Rows {
|
||||
var line []string
|
||||
for _, c := range row.Cells {
|
||||
switch {
|
||||
case c.Is != nil:
|
||||
line = append(line, c.Is.T)
|
||||
case c.Value != nil:
|
||||
// A real Excel-authored formula cell always caches its
|
||||
// last-computed value alongside the formula itself; show
|
||||
// that computed result rather than the formula text.
|
||||
line = append(line, *c.Value)
|
||||
case c.Formula != nil:
|
||||
// A formula cell this package just wrote has no cached
|
||||
// value (nothing here evaluates formulas) — show the
|
||||
// formula itself rather than a misleading blank.
|
||||
line = append(line, "="+*c.Formula)
|
||||
default:
|
||||
line = append(line, "")
|
||||
}
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
return out, sheetName, nil
|
||||
}
|
||||
|
||||
// SetXlsxCells downloads-parses-modifies-reuploads: applies a sparse
|
||||
// set of cell writes (keyed by "A1"-style reference) to one worksheet
|
||||
// (selected the same way as ReadXlsxCells) and returns the new file
|
||||
// bytes. Cells outside the current grid extend it; existing rows/cells
|
||||
// are preserved otherwise.
|
||||
func SetXlsxCells(data []byte, sheet string, writes map[string]string) ([]byte, error) {
|
||||
partPath, _, err := resolveXlsxSheetPart(data, sheet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, ok, err := readZipPart(data, partPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("officekit: %s not found (not a valid .xlsx)", partPath)
|
||||
}
|
||||
var ws sWorksheet
|
||||
if err := xml.Unmarshal(raw, &ws); err != nil {
|
||||
return nil, fmt.Errorf("officekit: parse worksheet xml: %w", err)
|
||||
}
|
||||
|
||||
// rowByNum maps a row number to its INDEX in ws.SheetData.Rows, not
|
||||
// a pointer into it. A pointer taken here (&ws.SheetData.Rows[i])
|
||||
// is only valid for the backing array that exists at the moment
|
||||
// it's taken; the very next `append` below that grows the slice
|
||||
// past its capacity allocates a new backing array and copies into
|
||||
// it, silently stranding any earlier-taken pointer on the old,
|
||||
// now-orphaned one. Writes applied through a stranded pointer
|
||||
// still "succeed" from the caller's point of view — no error, no
|
||||
// panic — but vanish, because they land on memory nothing else
|
||||
// reads. An index has no such failure mode: ws.SheetData.Rows[idx]
|
||||
// always re-resolves against whatever backing array the slice
|
||||
// header currently points to.
|
||||
rowByNum := map[int]int{}
|
||||
for i := range ws.SheetData.Rows {
|
||||
rowByNum[ws.SheetData.Rows[i].R] = i
|
||||
}
|
||||
|
||||
// Writes are applied in ascending (row, column) order rather than
|
||||
// map iteration order (which Go deliberately randomizes). This
|
||||
// doesn't affect correctness — sortSheetData below restores final
|
||||
// order regardless — but it keeps behavior deterministic and easy
|
||||
// to reason about/debug when a batch of cell writes is logged or
|
||||
// replayed.
|
||||
refs := make([]string, 0, len(writes))
|
||||
for ref := range writes {
|
||||
refs = append(refs, ref)
|
||||
}
|
||||
sort.Slice(refs, func(a, b int) bool {
|
||||
ra, ca, _ := parseCellRef(refs[a])
|
||||
rb, cb, _ := parseCellRef(refs[b])
|
||||
if ra != rb {
|
||||
return ra < rb
|
||||
}
|
||||
return ca < cb
|
||||
})
|
||||
|
||||
for _, ref := range refs {
|
||||
val := writes[ref]
|
||||
rowNum, _, err := parseCellRef(ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
idx, ok := rowByNum[rowNum]
|
||||
if !ok {
|
||||
ws.SheetData.Rows = append(ws.SheetData.Rows, sRow{R: rowNum})
|
||||
idx = len(ws.SheetData.Rows) - 1
|
||||
rowByNum[rowNum] = idx
|
||||
}
|
||||
row := &ws.SheetData.Rows[idx]
|
||||
replaced := false
|
||||
for i := range row.Cells {
|
||||
if row.Cells[i].R == ref {
|
||||
setCellValue(&row.Cells[i], val)
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
c := sCell{R: ref}
|
||||
setCellValue(&c, val)
|
||||
row.Cells = append(row.Cells, c)
|
||||
}
|
||||
}
|
||||
|
||||
// Excel is strict about row/cell order within xl/worksheets/sheetN.xml
|
||||
// (rows ascending by r, cells within a row ascending by column) —
|
||||
// writes above can append a row/cell out of order (e.g. writing "A1"
|
||||
// after "A5" already existed), which a lenient reader shrugs off but
|
||||
// Excel itself can flag as needing "repair". Restore ascending order
|
||||
// before marshaling so a sparse, arbitrarily-ordered set of writes
|
||||
// always produces a schema-valid worksheet.
|
||||
sortSheetData(&ws.SheetData)
|
||||
|
||||
body, err := xml.Marshal(ws)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("officekit: marshal worksheet xml: %w", err)
|
||||
}
|
||||
return replaceZipPart(data, partPath, []byte(xml.Header+string(body)))
|
||||
}
|
||||
|
||||
// sortSheetData reorders rows ascending by row number and, within each
|
||||
// row, cells ascending by column — the order SetXlsxCells' sparse,
|
||||
// map-driven writes don't otherwise guarantee.
|
||||
func sortSheetData(sd *sSheetData) {
|
||||
sort.Slice(sd.Rows, func(i, j int) bool { return sd.Rows[i].R < sd.Rows[j].R })
|
||||
for i := range sd.Rows {
|
||||
cells := sd.Rows[i].Cells
|
||||
sort.Slice(cells, func(a, b int) bool { return cellColumn(cells[a].R) < cellColumn(cells[b].R) })
|
||||
}
|
||||
}
|
||||
|
||||
// cellColumn returns a cell reference's 0-based column index (e.g.
|
||||
// "B12" -> 1), or 0 if ref doesn't parse as one — used only to order
|
||||
// cells within a row for output, so a malformed ref sorts first rather
|
||||
// than failing a write this deep into the process.
|
||||
func cellColumn(ref string) int {
|
||||
_, col, err := parseCellRef(ref)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return col
|
||||
}
|
||||
|
||||
// setCellValue classifies val and sets exactly the sCell fields that
|
||||
// representation needs, clearing the others: a leading "=" (with at
|
||||
// least one character after it) stores a formula, same convention
|
||||
// Excel itself uses to distinguish "=SUM(A1:A2)" from the literal text
|
||||
// "=SUM(A1:A2)"; otherwise a value that parses as a plain number is
|
||||
// numeric, and everything else is inline text.
|
||||
func setCellValue(c *sCell, val string) {
|
||||
switch {
|
||||
case strings.HasPrefix(val, "=") && len(val) > 1:
|
||||
f := val[1:]
|
||||
c.Formula, c.Value, c.Is, c.Type = &f, nil, nil, ""
|
||||
case isNumeric(val):
|
||||
v := val
|
||||
c.Formula, c.Value, c.Is, c.Type = nil, &v, nil, ""
|
||||
default:
|
||||
c.Formula, c.Value = nil, nil
|
||||
c.Is, c.Type = &sIs{T: val}, "inlineStr"
|
||||
}
|
||||
}
|
||||
|
||||
// parseCellRef splits "B12" into (row=12, col=1). Only used to locate
|
||||
// a row bucket for SetXlsxCells; the column stays embedded in the "r"
|
||||
// attribute string as-is.
|
||||
func parseCellRef(ref string) (row, col int, err error) {
|
||||
i := 0
|
||||
for i < len(ref) && ref[i] >= 'A' && ref[i] <= 'Z' {
|
||||
col = col*26 + int(ref[i]-'A'+1)
|
||||
i++
|
||||
}
|
||||
if i == 0 || i == len(ref) {
|
||||
return 0, 0, fmt.Errorf("officekit: invalid cell reference %q (expected e.g. \"A1\")", ref)
|
||||
}
|
||||
row, err = strconv.Atoi(ref[i:])
|
||||
if err != nil || row < 1 {
|
||||
return 0, 0, fmt.Errorf("officekit: invalid cell reference %q (expected e.g. \"A1\")", ref)
|
||||
}
|
||||
return row, col - 1, nil
|
||||
}
|
||||
|
||||
const xlsxRootRels = xml.Header + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
|
||||
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
|
||||
</Relationships>`
|
||||
|
||||
const xlsxStyles = xml.Header + `<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||
<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>
|
||||
<fills count="1"><fill><patternFill patternType="none"/></fill></fills>
|
||||
<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>
|
||||
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
|
||||
<cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs>
|
||||
</styleSheet>`
|
||||
181
pkg/officekit/zipkit.go
Normal file
181
pkg/officekit/zipkit.go
Normal file
@@ -0,0 +1,181 @@
|
||||
// Package officekit creates and edits real, native Office Open XML
|
||||
// files — .docx (Word), .xlsx (Excel), .pptx (PowerPoint) — as plain
|
||||
// Go structs and byte slices, with no dependency beyond the standard
|
||||
// library (archive/zip + encoding/xml).
|
||||
//
|
||||
// # Why this package exists
|
||||
//
|
||||
// enkly's agent talks to a Qwen model through a vLLM OpenAI-compatible
|
||||
// tool-calling API (pkg/vllmkit). Tool calls and tool results travel
|
||||
// as structured JSON on that wire (an "arguments" string vLLM's
|
||||
// tool-call parser extracts from the model's raw generation), not
|
||||
// wrapped in the XML-ish chat-template delimiters an Ollama deployment
|
||||
// of this same model would have used — so the specific "XML syntax
|
||||
// error on line N" failure mode an earlier, Ollama-backed version of
|
||||
// this service saw when a tool result contained raw angle-bracket
|
||||
// markup no longer applies verbatim. vLLM's tool-call parser can still
|
||||
// fail to extract a clean call from the model's output, surfaced
|
||||
// client-side as vllmkit.IsToolCallParseError (see
|
||||
// agentrun/agent.go's compactToolHistory doc comment for the retry
|
||||
// strategy built on top of it) — a different failure mode, but the
|
||||
// same underlying risk: anything that makes the model's raw output
|
||||
// harder for its tool-call parser to read is worth avoiding.
|
||||
//
|
||||
// The structural fix lives here regardless of transport: the model
|
||||
// NEVER authors or receives OOXML. It only ever calls a tool with
|
||||
// plain structured arguments
|
||||
// (a title, a list of paragraphs, a grid of cell values, a list of
|
||||
// slide bullets) and gets back a short plain-text summary. This
|
||||
// package does 100% of the actual XML generation and parsing,
|
||||
// server-side, using Go's typed XML encoder/decoder (which always
|
||||
// produces well-formed, correctly-escaped XML) — and the resulting
|
||||
// bytes travel to/from the workspace service over the existing
|
||||
// UploadFile/DownloadFile RPCs, which carry raw []byte, not the
|
||||
// text-oriented ReadFile/WriteFile string fields. No proto changes,
|
||||
// no binary-over-text encoding, and no XML ever touches the LLM.
|
||||
package officekit
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// zipPart is one file inside an OOXML package (which is just a zip
|
||||
// archive with a prescribed internal layout).
|
||||
type zipPart struct {
|
||||
Name string
|
||||
Body string
|
||||
}
|
||||
|
||||
// buildZip packages parts into an OOXML container. Office is picky
|
||||
// about [Content_Types].xml being present; beyond that, part order
|
||||
// doesn't matter to any reader in practice, but we write it first
|
||||
// anyway since that's the convention most real Office-authored files
|
||||
// follow.
|
||||
func buildZip(parts []zipPart) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
for _, p := range parts {
|
||||
w, err := zw.Create(p.Name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("officekit: create %s: %w", p.Name, err)
|
||||
}
|
||||
if _, err := w.Write([]byte(p.Body)); err != nil {
|
||||
return nil, fmt.Errorf("officekit: write %s: %w", p.Name, err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("officekit: close zip: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// readZipPart extracts one named part's raw bytes from an OOXML
|
||||
// package, or returns ok=false if it isn't present.
|
||||
func readZipPart(data []byte, name string) ([]byte, bool, error) {
|
||||
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("officekit: not a valid Office file (not a zip archive): %w", err)
|
||||
}
|
||||
for _, f := range zr.File {
|
||||
if f.Name == name {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("officekit: open %s: %w", name, err)
|
||||
}
|
||||
defer rc.Close()
|
||||
var buf bytes.Buffer
|
||||
if _, err := buf.ReadFrom(rc); err != nil {
|
||||
return nil, false, fmt.Errorf("officekit: read %s: %w", name, err)
|
||||
}
|
||||
return buf.Bytes(), true, nil
|
||||
}
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
// addZipParts returns a copy of an OOXML package with every existing
|
||||
// part copied through unchanged, plus the given new parts appended
|
||||
// (used by AppendPptxSlide to add a slideN.xml/slideN.xml.rels pair
|
||||
// without disturbing anything already in the archive).
|
||||
func addZipParts(data []byte, newParts []zipPart) ([]byte, error) {
|
||||
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("officekit: not a valid Office file (not a zip archive): %w", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
for _, f := range zr.File {
|
||||
w, err := zw.Create(f.Name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("officekit: create %s: %w", f.Name, err)
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("officekit: open %s: %w", f.Name, err)
|
||||
}
|
||||
_, copyErr := io.Copy(w, rc)
|
||||
rc.Close()
|
||||
if copyErr != nil {
|
||||
return nil, fmt.Errorf("officekit: copy %s: %w", f.Name, copyErr)
|
||||
}
|
||||
}
|
||||
for _, p := range newParts {
|
||||
w, err := zw.Create(p.Name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("officekit: create %s: %w", p.Name, err)
|
||||
}
|
||||
if _, err := w.Write([]byte(p.Body)); err != nil {
|
||||
return nil, fmt.Errorf("officekit: write %s: %w", p.Name, err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("officekit: close zip: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// replaceZipPart returns a copy of an OOXML package with one part's
|
||||
// body replaced (used by the edit_* operations: download, patch one
|
||||
// XML part, re-upload). Every other part is copied through unchanged.
|
||||
func replaceZipPart(data []byte, name string, newBody []byte) ([]byte, error) {
|
||||
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("officekit: not a valid Office file (not a zip archive): %w", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
replaced := false
|
||||
for _, f := range zr.File {
|
||||
w, err := zw.Create(f.Name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("officekit: create %s: %w", f.Name, err)
|
||||
}
|
||||
if f.Name == name {
|
||||
replaced = true
|
||||
if _, err := w.Write(newBody); err != nil {
|
||||
return nil, fmt.Errorf("officekit: write %s: %w", f.Name, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("officekit: open %s: %w", f.Name, err)
|
||||
}
|
||||
_, copyErr := io.Copy(w, rc)
|
||||
rc.Close()
|
||||
if copyErr != nil {
|
||||
return nil, fmt.Errorf("officekit: copy %s: %w", f.Name, copyErr)
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
return nil, fmt.Errorf("officekit: part %s not found in archive", name)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("officekit: close zip: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
107
pkg/pgkit/pgkit.go
Normal file
107
pkg/pgkit/pgkit.go
Normal file
@@ -0,0 +1,107 @@
|
||||
// Package pgkit is the small piece of internal/db/db.go that wasn't
|
||||
// specific to any one bounded context: connecting with a startup retry
|
||||
// loop (docker compose's "healthy" and "actually accepting
|
||||
// connections" can be a beat apart) and applying an idempotent
|
||||
// schema.sql-style migration under an advisory lock so two replicas of
|
||||
// the same service starting at once don't race each other.
|
||||
//
|
||||
// Each data-owning service (iam, billing, conversation, knowledge) gets
|
||||
// its own *pgkit.DB over its own database — see each service's
|
||||
// migrations/0001_init.sql — never a shared one. That split is the
|
||||
// difference between "microservices" and "one database with several
|
||||
// front ends" (see ARCHITECTURE.md, "Database-per-service").
|
||||
package pgkit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// Sentinel errors every repository package in this repo returns, so
|
||||
// application-layer use cases can errors.Is against one shared set
|
||||
// instead of each service inventing its own (matching internal/db's
|
||||
// original ErrNotFound/ErrEmailTaken/ErrInvalidToken).
|
||||
var (
|
||||
ErrNotFound = errors.New("pgkit: not found")
|
||||
ErrAlreadyTaken = errors.New("pgkit: unique constraint violated")
|
||||
ErrInvalid = errors.New("pgkit: invalid or expired")
|
||||
)
|
||||
|
||||
// DB wraps *sql.DB exactly as internal/db.DB did.
|
||||
type DB struct {
|
||||
*sql.DB
|
||||
}
|
||||
|
||||
// Open connects to Postgres and retries pinging it for up to ~30s.
|
||||
func Open(dsn string) (*DB, error) {
|
||||
sqlDB, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pgkit: open: %w", err)
|
||||
}
|
||||
|
||||
// Pool sizing: each microservice is still a single logical writer
|
||||
// per table, same reasoning as the monolith's own comment here —
|
||||
// a handful of connections covers concurrent gRPC calls without
|
||||
// exhausting Postgres' default max_connections (100) once you
|
||||
// multiply by four data-owning services.
|
||||
sqlDB.SetMaxOpenConns(20)
|
||||
sqlDB.SetMaxIdleConns(5)
|
||||
sqlDB.SetConnMaxLifetime(30 * time.Minute)
|
||||
sqlDB.SetConnMaxIdleTime(5 * time.Minute)
|
||||
|
||||
var lastErr error
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
for {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
lastErr = sqlDB.PingContext(ctx)
|
||||
cancel()
|
||||
if lastErr == nil {
|
||||
return &DB{sqlDB}, nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
_ = sqlDB.Close()
|
||||
return nil, fmt.Errorf("pgkit: ping (gave up after 30s): %w", lastErr)
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
// migrationLockID is an arbitrary constant used with pg_advisory_lock.
|
||||
// Shared across services since each connects to its own database (the
|
||||
// lock is scoped per-database by Postgres), so there's no cross-service
|
||||
// collision risk in reusing one constant.
|
||||
const migrationLockID = 7_402_113_915
|
||||
|
||||
// Migrate applies schema (expected to be idempotent — CREATE ... IF NOT
|
||||
// EXISTS throughout, as with the monolith's schema.sql) under an
|
||||
// advisory lock.
|
||||
func (d *DB) Migrate(ctx context.Context, schema string) error {
|
||||
conn, err := d.Conn(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pgkit: migrate: acquire connection: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if _, err := conn.ExecContext(ctx, `SELECT pg_advisory_lock($1)`, migrationLockID); err != nil {
|
||||
return fmt.Errorf("pgkit: migrate: lock: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = conn.ExecContext(context.Background(), `SELECT pg_advisory_unlock($1)`, migrationLockID)
|
||||
}()
|
||||
|
||||
if _, err := conn.ExecContext(ctx, schema); err != nil {
|
||||
return fmt.Errorf("pgkit: migrate: apply schema: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsUniqueViolation reports whether err is Postgres' unique_violation (23505).
|
||||
func IsUniqueViolation(err error) bool {
|
||||
var pqErr *pq.Error
|
||||
return errors.As(err, &pqErr) && pqErr.Code == "23505"
|
||||
}
|
||||
359
pkg/renderkit/highlight.go
Normal file
359
pkg/renderkit/highlight.go
Normal file
@@ -0,0 +1,359 @@
|
||||
package renderkit
|
||||
|
||||
import (
|
||||
stdhtml "html"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// A small, dependency-free syntax highlighter for fenced code blocks.
|
||||
//
|
||||
// It is a single-pass lexer, not a parser: it recognises comments, strings,
|
||||
// numbers, keywords, built-in types, constants, function-call names and (for
|
||||
// shell) $variables, and wraps each in <span class="tok-...">. That is
|
||||
// enough to make code readable without pulling in a highlighting library or
|
||||
// a CDN (this stack must work fully offline).
|
||||
//
|
||||
// Safety: input is gomarkdown's already-escaped block body. It is unescaped,
|
||||
// tokenised, and every emitted piece is re-escaped, so no model-controlled
|
||||
// text can ever become markup. The visible text is unchanged (the copy
|
||||
// button still copies the original code).
|
||||
|
||||
// maxHighlightBytes skips highlighting for huge blocks; they are rare and
|
||||
// the lexer is per-byte.
|
||||
const maxHighlightBytes = 128 * 1024
|
||||
|
||||
type langSpec struct {
|
||||
keywords, types, consts map[string]bool
|
||||
lineComments []string
|
||||
blockStart, blockEnd string
|
||||
quotes string
|
||||
tripleQuotes bool
|
||||
foldCase bool // keywords are case-insensitive (SQL)
|
||||
shellVars bool
|
||||
jsonKeys bool
|
||||
}
|
||||
|
||||
func wordSet(words string) map[string]bool {
|
||||
m := make(map[string]bool)
|
||||
for _, w := range strings.Fields(words) {
|
||||
m[w] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
var (
|
||||
goSpec = &langSpec{
|
||||
keywords: wordSet(`break case chan const continue default defer else fallthrough for func go goto if
|
||||
import interface map package range return select struct switch type var`),
|
||||
types: wordSet(`string int int8 int16 int32 int64 uint uint8 uint16 uint32 uint64 uintptr byte rune
|
||||
float32 float64 complex64 complex128 bool error any`),
|
||||
consts: wordSet(`true false nil iota`),
|
||||
lineComments: []string{"//"}, blockStart: "/*", blockEnd: "*/",
|
||||
quotes: "\"'`",
|
||||
}
|
||||
jsSpec = &langSpec{
|
||||
keywords: wordSet(`async await break case catch class const continue debugger default delete do else
|
||||
export extends finally for from function if import in instanceof let new of return static super
|
||||
switch this throw try typeof var void while with yield interface type enum implements namespace
|
||||
declare readonly public private protected abstract as`),
|
||||
types: wordSet(`string number boolean any unknown never object Promise Array Map Set`),
|
||||
consts: wordSet(`true false null undefined NaN Infinity`),
|
||||
lineComments: []string{"//"}, blockStart: "/*", blockEnd: "*/",
|
||||
quotes: "\"'`",
|
||||
}
|
||||
pySpec = &langSpec{
|
||||
keywords: wordSet(`and as assert async await break class continue def del elif else except finally for
|
||||
from global if import in is lambda nonlocal not or pass raise return try while with yield match case`),
|
||||
types: wordSet(`int str float bool list dict set tuple bytes object`),
|
||||
consts: wordSet(`True False None`),
|
||||
lineComments: []string{"#"},
|
||||
quotes: "\"'", tripleQuotes: true,
|
||||
}
|
||||
shSpec = &langSpec{
|
||||
keywords: wordSet(`if then else elif fi for while until do done case esac in function select time
|
||||
return exit break continue export local readonly declare unset source alias`),
|
||||
types: wordSet(`echo cd ls cat grep sed awk find git go make docker curl mkdir rm cp mv chmod chown
|
||||
touch sudo npm pip python gofmt`),
|
||||
lineComments: []string{"#"},
|
||||
quotes: "\"'", shellVars: true,
|
||||
}
|
||||
jsonSpec = &langSpec{
|
||||
consts: wordSet(`true false null`),
|
||||
quotes: `"`, jsonKeys: true,
|
||||
}
|
||||
sqlSpec = &langSpec{
|
||||
keywords: wordSet(`select from where insert into values update set delete create table alter drop index
|
||||
join left right inner outer full cross on group by order having limit offset as and or not in is
|
||||
like between exists union all distinct case when then else end primary key foreign references
|
||||
default unique constraint with returning begin commit rollback extension if`),
|
||||
types: wordSet(`int integer bigint smallint serial bigserial text varchar char boolean bool date
|
||||
timestamp timestamptz numeric decimal float double real jsonb json uuid vector`),
|
||||
consts: wordSet(`true false null`),
|
||||
lineComments: []string{"--"}, blockStart: "/*", blockEnd: "*/",
|
||||
quotes: "\"'", foldCase: true,
|
||||
}
|
||||
cSpec = &langSpec{
|
||||
keywords: wordSet(`auto break case const continue default do else enum extern for goto if inline
|
||||
register return sizeof static struct switch typedef union volatile while class namespace
|
||||
template typename public private protected virtual new delete using try catch throw`),
|
||||
types: wordSet(`void char short int long float double signed unsigned bool size_t int8_t int16_t
|
||||
int32_t int64_t uint8_t uint16_t uint32_t uint64_t string`),
|
||||
consts: wordSet(`true false NULL nullptr`),
|
||||
lineComments: []string{"//"}, blockStart: "/*", blockEnd: "*/",
|
||||
quotes: "\"'",
|
||||
}
|
||||
javaSpec = &langSpec{
|
||||
keywords: wordSet(`abstract assert break case catch class const continue default do else enum extends
|
||||
final finally for if implements import instanceof interface native new package private protected
|
||||
public return static super switch synchronized this throw throws try void volatile while`),
|
||||
types: wordSet(`boolean byte char short int long float double String Object List Map Set`),
|
||||
consts: wordSet(`true false null`), lineComments: []string{"//"},
|
||||
blockStart: "/*", blockEnd: "*/", quotes: "\"'",
|
||||
}
|
||||
// No ' in quotes: Rust lifetimes ('a) would swallow the rest of the line.
|
||||
rustSpec = &langSpec{
|
||||
keywords: wordSet(`as async await break const continue crate dyn else enum extern fn for if impl in
|
||||
let loop match mod move mut pub ref return self Self static struct super trait type unsafe use
|
||||
where while`),
|
||||
types: wordSet(`i8 i16 i32 i64 i128 isize u8 u16 u32 u64 u128 usize f32 f64 bool char str String
|
||||
Vec Option Result Box`),
|
||||
consts: wordSet(`true false None Some Ok Err`), lineComments: []string{"//"},
|
||||
blockStart: "/*", blockEnd: "*/", quotes: `"`,
|
||||
}
|
||||
|
||||
langByName = map[string]*langSpec{
|
||||
"go": goSpec, "golang": goSpec,
|
||||
"js": jsSpec, "javascript": jsSpec, "jsx": jsSpec, "ts": jsSpec, "typescript": jsSpec, "tsx": jsSpec,
|
||||
"py": pySpec, "python": pySpec,
|
||||
"sh": shSpec, "bash": shSpec, "shell": shSpec, "zsh": shSpec, "console": shSpec,
|
||||
"json": jsonSpec,
|
||||
"sql": sqlSpec, "postgres": sqlSpec, "psql": sqlSpec,
|
||||
"c": cSpec, "cpp": cSpec, "c++": cSpec, "cc": cSpec, "h": cSpec, "hpp": cSpec,
|
||||
"java": javaSpec,
|
||||
"rust": rustSpec, "rs": rustSpec,
|
||||
}
|
||||
)
|
||||
|
||||
func isDigit(c byte) bool { return c >= '0' && c <= '9' }
|
||||
func isIdentStart(c byte) bool { return c == '_' || c == '$' || (c|0x20 >= 'a' && c|0x20 <= 'z') }
|
||||
func isIdent(c byte) bool { return isIdentStart(c) || isDigit(c) }
|
||||
|
||||
func spanned(b *strings.Builder, class, text string) {
|
||||
if class == "" {
|
||||
b.WriteString(stdhtml.EscapeString(text))
|
||||
return
|
||||
}
|
||||
b.WriteString(`<span class="tok-`)
|
||||
b.WriteString(class)
|
||||
b.WriteString(`">`)
|
||||
b.WriteString(stdhtml.EscapeString(text))
|
||||
b.WriteString(`</span>`)
|
||||
}
|
||||
|
||||
// highlight takes a fenced block's language and its HTML-escaped body and
|
||||
// returns the body with token spans. Unknown languages come back unchanged.
|
||||
func highlight(lang, escapedBody string) string {
|
||||
lang = strings.ToLower(strings.TrimSpace(lang))
|
||||
if len(escapedBody) > maxHighlightBytes {
|
||||
return escapedBody
|
||||
}
|
||||
if lang == "diff" || lang == "patch" {
|
||||
return highlightDiff(stdhtml.UnescapeString(escapedBody))
|
||||
}
|
||||
spec := langByName[lang]
|
||||
if spec == nil {
|
||||
return escapedBody
|
||||
}
|
||||
return spec.lex(stdhtml.UnescapeString(escapedBody))
|
||||
}
|
||||
|
||||
func highlightDiff(src string) string {
|
||||
var b strings.Builder
|
||||
lines := strings.Split(src, "\n")
|
||||
for i, ln := range lines {
|
||||
class := ""
|
||||
switch {
|
||||
case strings.HasPrefix(ln, "+++") || strings.HasPrefix(ln, "---"):
|
||||
class = "hunk"
|
||||
case strings.HasPrefix(ln, "@@"):
|
||||
class = "hunk"
|
||||
case strings.HasPrefix(ln, "+"):
|
||||
class = "add"
|
||||
case strings.HasPrefix(ln, "-"):
|
||||
class = "del"
|
||||
}
|
||||
spanned(&b, class, ln)
|
||||
if i < len(lines)-1 {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (s *langSpec) lex(src string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(src) + len(src)/2)
|
||||
i := 0
|
||||
for i < len(src) {
|
||||
c := src[i]
|
||||
rest := src[i:]
|
||||
|
||||
if n := s.commentLen(src, i); n > 0 {
|
||||
spanned(&b, "cmt", rest[:n])
|
||||
i += n
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.IndexByte(s.quotes, c) >= 0 {
|
||||
n := s.stringLen(rest)
|
||||
class := "str"
|
||||
if s.jsonKeys {
|
||||
k := i + n
|
||||
for k < len(src) && (src[k] == ' ' || src[k] == '\t') {
|
||||
k++
|
||||
}
|
||||
if k < len(src) && src[k] == ':' {
|
||||
class = "key"
|
||||
}
|
||||
}
|
||||
spanned(&b, class, rest[:n])
|
||||
i += n
|
||||
continue
|
||||
}
|
||||
|
||||
if isDigit(c) && (i == 0 || !isIdent(src[i-1])) {
|
||||
j := i + 1
|
||||
for j < len(src) && (isIdent(src[j]) || (src[j] == '.' && j+1 < len(src) && isDigit(src[j+1]))) {
|
||||
j++
|
||||
}
|
||||
spanned(&b, "num", src[i:j])
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
|
||||
if s.shellVars && c == '$' && i+1 < len(src) && (isIdentStart(src[i+1]) || src[i+1] == '{') {
|
||||
j := i + 1
|
||||
if src[j] == '{' {
|
||||
if k := strings.IndexAny(src[j:], "}\n"); k > 0 && src[j+k] == '}' {
|
||||
j += k + 1
|
||||
} else {
|
||||
j++
|
||||
}
|
||||
} else {
|
||||
for j < len(src) && isIdent(src[j]) {
|
||||
j++
|
||||
}
|
||||
}
|
||||
spanned(&b, "var", src[i:j])
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
|
||||
if isIdentStart(c) {
|
||||
j := i + 1
|
||||
for j < len(src) && isIdent(src[j]) {
|
||||
j++
|
||||
}
|
||||
spanned(&b, s.classify(src[i:j], src[j:]), src[i:j])
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
|
||||
_, size := utf8.DecodeRuneInString(rest)
|
||||
spanned(&b, "", rest[:size])
|
||||
i += size
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (s *langSpec) classify(word, after string) string {
|
||||
w := word
|
||||
if s.foldCase {
|
||||
w = strings.ToLower(word)
|
||||
}
|
||||
switch {
|
||||
case s.keywords[w]:
|
||||
return "kw"
|
||||
case s.types[w]:
|
||||
return "type"
|
||||
case s.consts[w]:
|
||||
return "const"
|
||||
}
|
||||
if len(s.types) > 0 || len(s.keywords) > 0 { // JSON has no functions
|
||||
for k := 0; k < len(after); k++ {
|
||||
if after[k] == ' ' || after[k] == '\t' {
|
||||
continue
|
||||
}
|
||||
if after[k] == '(' {
|
||||
return "fn"
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// commentLen returns the length of a comment starting at src[i], or 0.
|
||||
func (s *langSpec) commentLen(src string, i int) int {
|
||||
rest := src[i:]
|
||||
for _, lc := range s.lineComments {
|
||||
if !strings.HasPrefix(rest, lc) {
|
||||
continue
|
||||
}
|
||||
// A "#" only starts a comment at a word boundary, so "a#b" and
|
||||
// "$#" aren't swallowed.
|
||||
if lc == "#" && i > 0 && src[i-1] != ' ' && src[i-1] != '\t' && src[i-1] != '\n' && src[i-1] != ';' {
|
||||
continue
|
||||
}
|
||||
if k := strings.IndexByte(rest, '\n'); k >= 0 {
|
||||
return k
|
||||
}
|
||||
return len(rest)
|
||||
}
|
||||
if s.blockStart != "" && strings.HasPrefix(rest, s.blockStart) {
|
||||
if k := strings.Index(rest[len(s.blockStart):], s.blockEnd); k >= 0 {
|
||||
return len(s.blockStart) + k + len(s.blockEnd)
|
||||
}
|
||||
return len(rest)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// stringLen returns the length of the string literal starting at rest[0].
|
||||
// Unterminated ' and " literals end at the newline, so one stray quote
|
||||
// can't colour the rest of the block.
|
||||
func (s *langSpec) stringLen(rest string) int {
|
||||
q := rest[0]
|
||||
if s.tripleQuotes {
|
||||
if t := strings.Repeat(string(q), 3); strings.HasPrefix(rest, t) {
|
||||
if k := strings.Index(rest[3:], t); k >= 0 {
|
||||
return 3 + k + 3
|
||||
}
|
||||
return len(rest)
|
||||
}
|
||||
}
|
||||
if q == '`' { // raw / multi-line
|
||||
if k := strings.IndexByte(rest[1:], '`'); k >= 0 {
|
||||
return 1 + k + 1
|
||||
}
|
||||
return len(rest)
|
||||
}
|
||||
j := 1
|
||||
for j < len(rest) {
|
||||
switch rest[j] {
|
||||
case '\\':
|
||||
j += 2
|
||||
continue
|
||||
case q:
|
||||
return j + 1
|
||||
case '\n':
|
||||
return j
|
||||
}
|
||||
j++
|
||||
}
|
||||
if j > len(rest) {
|
||||
j = len(rest)
|
||||
}
|
||||
return j
|
||||
}
|
||||
81
pkg/renderkit/highlight_test.go
Normal file
81
pkg/renderkit/highlight_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package renderkit
|
||||
|
||||
import (
|
||||
stdhtml "html"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var tagRe = regexp.MustCompile(`<[^>]*>`)
|
||||
|
||||
// visibleText strips the span markup and unescapes, i.e. what the user
|
||||
// sees and what the copy button copies.
|
||||
func visibleText(s string) string { return stdhtml.UnescapeString(tagRe.ReplaceAllString(s, "")) }
|
||||
|
||||
func TestHighlightPreservesText(t *testing.T) {
|
||||
samples := map[string]string{
|
||||
"go": "package main\n\n// hi\nfunc main() {\n\tx := \"a<b>\" + `raw\nline`\n\tif x != nil { return 42 }\n}\n",
|
||||
"py": "def f(x):\n \"\"\"doc\"\"\"\n return x # c\n",
|
||||
"bash": "echo \"$HOME\" ${A} | grep 'x' # note\nfoo#bar $#\n",
|
||||
"json": "{\"a\": [1, 2.5, true, null], \"b\": \"x\"}",
|
||||
"sql": "SELECT count(*) FROM t WHERE a = 'x' -- c\n",
|
||||
"rust": "fn f<'a>(x: &'a str) -> u32 { 1 }\n",
|
||||
"diff": "@@ -1 +1 @@\n-old\n+new\n ctx\n",
|
||||
}
|
||||
for lang, src := range samples {
|
||||
out := highlight(lang, stdhtml.EscapeString(src))
|
||||
if got := visibleText(out); got != src {
|
||||
t.Errorf("%s: text changed\nwant %q\ngot %q", lang, src, got)
|
||||
}
|
||||
if !strings.Contains(out, "<span class=\"tok-") {
|
||||
t.Errorf("%s: nothing was highlighted: %s", lang, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHighlightTokens(t *testing.T) {
|
||||
out := highlight("go", stdhtml.EscapeString("func main() { return \"a<b>\" } // done"))
|
||||
for _, want := range []string{
|
||||
`<span class="tok-kw">func</span>`,
|
||||
`<span class="tok-fn">main</span>`,
|
||||
`<span class="tok-kw">return</span>`,
|
||||
`<span class="tok-str">"a<b>"</span>`,
|
||||
`<span class="tok-cmt">// done</span>`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("missing %s in %s", want, out)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(highlight("json", `"k": 1`), `tok-key`) {
|
||||
t.Error("json key not detected")
|
||||
}
|
||||
if !strings.Contains(highlight("sql", "select 1"), `<span class="tok-kw">select</span>`) {
|
||||
t.Error("sql keywords should be case-insensitive")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHighlightNeverInjectsMarkup(t *testing.T) {
|
||||
evil := "x := \"</span><script>alert(1)</script>\" // <img src=x onerror=1>"
|
||||
out := highlight("go", stdhtml.EscapeString(evil))
|
||||
if strings.Contains(out, "<script") || strings.Contains(out, "<img") {
|
||||
t.Fatalf("markup leaked: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHighlightUnknownLanguageUnchanged(t *testing.T) {
|
||||
in := "a < b"
|
||||
if got := highlight("brainfuck", in); got != in {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if got := highlight("", in); got != in {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownHighlightsFencedCode(t *testing.T) {
|
||||
out := Markdown("```go\nfunc main() {}\n```")
|
||||
if !strings.Contains(out, `<span class="tok-kw">func</span>`) || !strings.Contains(out, "copy-btn") {
|
||||
t.Fatalf("not highlighted: %s", out)
|
||||
}
|
||||
}
|
||||
94
pkg/renderkit/markdown.go
Normal file
94
pkg/renderkit/markdown.go
Normal file
@@ -0,0 +1,94 @@
|
||||
// Package render turns raw assistant text into safe HTML: Markdown for
|
||||
// finished messages, and simple HTML-escaping (with <br> line breaks)
|
||||
// for the plain-text tokens shown while a reply is still streaming.
|
||||
package renderkit
|
||||
|
||||
import (
|
||||
stdhtml "html"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gomarkdown/markdown"
|
||||
mdhtml "github.com/gomarkdown/markdown/html"
|
||||
mdparser "github.com/gomarkdown/markdown/parser"
|
||||
)
|
||||
|
||||
// gomarkdown's Parser and Renderer are single-use and stateful ("Parser is
|
||||
// not reusable" — a second Parse on the same instance panics, and sharing
|
||||
// one across goroutines would race), so both are built fresh per call.
|
||||
const (
|
||||
mdParserExtensions = mdparser.CommonExtensions | mdparser.AutoHeadingIDs | mdparser.FencedCode
|
||||
mdRendererFlags = mdhtml.CommonFlags | mdhtml.HrefTargetBlank | mdhtml.SkipHTML | mdhtml.Safelink
|
||||
)
|
||||
|
||||
// fencedCodeBlock matches gomarkdown's output for a fenced code block:
|
||||
// <pre><code class="language-xxx">...escaped content...</code></pre>
|
||||
// (the language class is only present when the fence specified one,
|
||||
// e.g. ```go). (?s) lets "." match newlines, since code blocks are
|
||||
// almost always multi-line.
|
||||
var fencedCodeBlock = regexp.MustCompile(`(?s)<pre><code(?: class="language-([\w+.-]*)")?>(.*?)</code></pre>`)
|
||||
|
||||
// Markdown renders a complete Markdown document (the agent's finished
|
||||
// reply) to HTML for embedding in a chat bubble.
|
||||
//
|
||||
// The text being rendered is model output, and the model reads files
|
||||
// and command output that may contain attacker-controlled text (prompt
|
||||
// injection), so it is treated as untrusted: raw HTML in the Markdown
|
||||
// source is dropped (SkipHTML) and only safe link schemes are linked
|
||||
// (Safelink — no javascript: URLs). Code spans and fenced blocks are
|
||||
// still HTML-escaped by gomarkdown, so showing "<div>" in code works.
|
||||
//
|
||||
// Fenced code blocks get an extra post-processing pass (addCodeBlockChrome)
|
||||
// to wrap them in a header bar with a language label and copy button,
|
||||
// matching the rest of the UI's terminal-chat styling — done as plain
|
||||
// string manipulation on gomarkdown's already-escaped output rather
|
||||
// than pulling in a syntax-highlighting dependency (see the module's
|
||||
// deliberately small go.mod). Keywords, strings, comments, numbers and so
|
||||
// on are coloured by the small built-in lexer in highlight.go.
|
||||
func Markdown(src string) string {
|
||||
return addCodeBlockChrome(rawMarkdown(src))
|
||||
}
|
||||
|
||||
// rawMarkdown is gomarkdown's output before addCodeBlockChrome. Split out so
|
||||
// Snippets (snippets.go) can enumerate fenced blocks in exactly the same
|
||||
// order Markdown renders them.
|
||||
func rawMarkdown(src string) string {
|
||||
p := mdparser.NewWithExtensions(mdParserExtensions)
|
||||
r := mdhtml.NewRenderer(mdhtml.RendererOptions{Flags: mdRendererFlags})
|
||||
return string(markdown.ToHTML([]byte(src), p, r))
|
||||
}
|
||||
|
||||
// addCodeBlockChrome wraps every fenced code block in a `.code-block`
|
||||
// container with a header (language label + copy button) so it reads
|
||||
// like a small file viewer instead of a bare <pre>. The copy button has
|
||||
// no inline JS handler — web/static/js/app.js listens for clicks on
|
||||
// `.copy-btn` via event delegation and reads the code from a sibling
|
||||
// <pre>, so this still degrades gracefully (the button just does
|
||||
// nothing) if that script is blocked, matching the rest of the app's
|
||||
// zero-required-JS posture.
|
||||
func addCodeBlockChrome(html string) string {
|
||||
return fencedCodeBlock.ReplaceAllStringFunc(html, func(block string) string {
|
||||
m := fencedCodeBlock.FindStringSubmatch(block)
|
||||
lang := m[1]
|
||||
body := m[2]
|
||||
label := lang
|
||||
if label == "" {
|
||||
label = "text"
|
||||
}
|
||||
return `<div class="code-block">` +
|
||||
`<div class="code-block-header">` +
|
||||
`<span class="code-block-lang">` + stdhtml.EscapeString(label) + `</span>` +
|
||||
`<button type="button" class="copy-btn" aria-label="Copy code">Copy</button>` +
|
||||
`</div>` +
|
||||
`<pre><code class="language-` + stdhtml.EscapeString(label) + `">` + highlight(lang, body) + `</code></pre>` +
|
||||
`</div>`
|
||||
})
|
||||
}
|
||||
|
||||
// PlainStream escapes raw text and converts newlines to <br>, for
|
||||
// rendering the in-progress streamed response before the final Markdown
|
||||
// pass runs on the "done" event.
|
||||
func PlainStream(src string) string {
|
||||
escaped := stdhtml.EscapeString(src)
|
||||
return strings.ReplaceAll(escaped, "\n", "<br>")
|
||||
}
|
||||
49
pkg/renderkit/markdown_test.go
Normal file
49
pkg/renderkit/markdown_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package renderkit
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMarkdownDropsRawHTMLAndUnsafeLinks(t *testing.T) {
|
||||
out := Markdown("hi <script>alert(1)</script> <img src=x onerror=alert(1)> [x](javascript:alert(1))\n\n<div onclick=\"x()\">boom</div>")
|
||||
for _, bad := range []string{"<script", "<img", "onerror", "onclick", `href="javascript:`} {
|
||||
if strings.Contains(out, bad) {
|
||||
t.Fatalf("output contains %q:\n%s", bad, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownKeepsCodeAndFormatting(t *testing.T) {
|
||||
out := Markdown("**bold**\n\n```go\nx := \"<div>\" + `a`\n```\n\n`<b>`")
|
||||
for _, want := range []string{"<strong>bold</strong>", "code-block", "copy-btn", "<div>", "<b>"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("missing %q in:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlainStreamEscapes(t *testing.T) {
|
||||
if got := PlainStream("<b>a</b>\nb"); got != "<b>a</b><br>b" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: gomarkdown's Parser panics if reused, which used to make every
|
||||
// reply after the first fail. Also exercised concurrently for the race detector.
|
||||
func TestMarkdownIsReusableAndConcurrencySafe(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
for i := 0; i < 8; i++ {
|
||||
go func() {
|
||||
defer func() { done <- struct{}{} }()
|
||||
for j := 0; j < 20; j++ {
|
||||
if out := Markdown("# title\n\ntext"); !strings.Contains(out, "<h1") {
|
||||
t.Error(out)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
for i := 0; i < 8; i++ {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
76
pkg/renderkit/snippets.go
Normal file
76
pkg/renderkit/snippets.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package renderkit
|
||||
|
||||
import (
|
||||
stdhtml "html"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Snippet describes one fenced code block in an assistant reply that is
|
||||
// worth offering as a download.
|
||||
type Snippet struct {
|
||||
Index int // position among ALL fenced blocks in the reply (matches the DOM order of .code-block)
|
||||
Lang string // lower-cased fence language
|
||||
Ext string // file extension including the dot, e.g. ".go"
|
||||
Label string // display name of the type, e.g. "Go"
|
||||
Lines int
|
||||
Bytes int
|
||||
// Filename is set when the block's first line is a comment naming a
|
||||
// file with the matching extension (e.g. "// internal/db/users.go"),
|
||||
// which models often write; only the base name is kept.
|
||||
Filename string
|
||||
}
|
||||
|
||||
var firstLineFilename = regexp.MustCompile(`^\s*(?://|#|--|/\*|<!--)\s*([\w./-]+\.\w+)\s*(?:\*/|-->)?\s*$`)
|
||||
|
||||
func filenameHint(body, ext string) string {
|
||||
first, _, _ := strings.Cut(body, "\n")
|
||||
m := firstLineFilename.FindStringSubmatch(first)
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
base := path.Base(m[1])
|
||||
if path.Ext(base) != ext || base == ext {
|
||||
return ""
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
type snippetType struct{ ext, label string }
|
||||
|
||||
var snippetTypes = map[string]snippetType{
|
||||
"go": {".go", "Go"}, "golang": {".go", "Go"},
|
||||
"js": {".js", "JavaScript"}, "javascript": {".js", "JavaScript"},
|
||||
"jsx": {".jsx", "JSX"}, "ts": {".ts", "TypeScript"}, "typescript": {".ts", "TypeScript"}, "tsx": {".tsx", "TSX"},
|
||||
"py": {".py", "Python"}, "python": {".py", "Python"},
|
||||
"sh": {".sh", "Shell"}, "bash": {".sh", "Shell"}, "shell": {".sh", "Shell"}, "zsh": {".sh", "Shell"},
|
||||
"json": {".json", "JSON"}, "sql": {".sql", "SQL"},
|
||||
"c": {".c", "C"}, "h": {".h", "C header"}, "cpp": {".cpp", "C++"}, "c++": {".cpp", "C++"},
|
||||
"java": {".java", "Java"}, "rust": {".rs", "Rust"}, "rs": {".rs", "Rust"},
|
||||
"diff": {".patch", "Patch"}, "patch": {".patch", "Patch"},
|
||||
"yaml": {".yml", "YAML"}, "yml": {".yml", "YAML"}, "toml": {".toml", "TOML"},
|
||||
"html": {".html", "HTML"}, "css": {".css", "CSS"},
|
||||
"md": {".md", "Markdown"}, "markdown": {".md", "Markdown"},
|
||||
}
|
||||
|
||||
// Snippets lists the fenced code blocks in src that have a recognised
|
||||
// language, in the order Markdown renders them. Blocks with no language (or
|
||||
// an unknown one) are skipped, but still counted in Index.
|
||||
func Snippets(src string) []Snippet {
|
||||
var out []Snippet
|
||||
for i, m := range fencedCodeBlock.FindAllStringSubmatch(rawMarkdown(src), -1) {
|
||||
lang := strings.ToLower(m[1])
|
||||
t, ok := snippetTypes[lang]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
body := stdhtml.UnescapeString(m[2])
|
||||
out = append(out, Snippet{
|
||||
Index: i, Lang: lang, Ext: t.ext, Label: t.label,
|
||||
Lines: strings.Count(strings.TrimRight(body, "\n"), "\n") + 1,
|
||||
Bytes: len(body), Filename: filenameHint(body, t.ext),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
34
pkg/renderkit/snippets_test.go
Normal file
34
pkg/renderkit/snippets_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package renderkit
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSnippets(t *testing.T) {
|
||||
src := "intro\n\n```\nno lang\n```\n\n```go\npackage main\n\nfunc main() {}\n```\n\n```python\nprint(1)\n```\n\n```weird\nx\n```\n"
|
||||
got := Snippets(src)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("want 2 snippets, got %+v", got)
|
||||
}
|
||||
if got[0].Index != 1 || got[0].Ext != ".go" || got[0].Lines != 3 {
|
||||
t.Errorf("go snippet wrong: %+v", got[0])
|
||||
}
|
||||
if got[1].Index != 2 || got[1].Ext != ".py" || got[1].Label != "Python" {
|
||||
t.Errorf("python snippet wrong: %+v", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnippetFilenameHint(t *testing.T) {
|
||||
src := "```go\n// internal/db/users.go\npackage db\n\nvar X = 1\n```\n\n```go\n// just a comment\npackage x\n\nvar Y = 2\n```\n\n```python\n# notes.txt\nprint(1)\nprint(2)\n```\n"
|
||||
got := Snippets(src)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("want 3, got %+v", got)
|
||||
}
|
||||
if got[0].Filename != "users.go" {
|
||||
t.Errorf("want users.go, got %q", got[0].Filename)
|
||||
}
|
||||
if got[1].Filename != "" {
|
||||
t.Errorf("plain comment must not become a filename: %q", got[1].Filename)
|
||||
}
|
||||
if got[2].Filename != "" {
|
||||
t.Errorf("extension mismatch (.txt vs .py) must be ignored: %q", got[2].Filename)
|
||||
}
|
||||
}
|
||||
208
pkg/skillkit/rag.go
Normal file
208
pkg/skillkit/rag.go
Normal file
@@ -0,0 +1,208 @@
|
||||
// rag.go adds retrieval-augmented search on top of the plain
|
||||
// read-a-whole-file skill lookup in skillkit.go. The read_office_skill
|
||||
// tool (skillkit.Read) is the right fit when the model already knows
|
||||
// which format it needs and wants that skill's full guidance before a
|
||||
// multi-step task — but for a narrow, specific question ("can a cell
|
||||
// hold a formula", "how do I bold part of a paragraph"), returning an
|
||||
// entire 3-4KB skill file just to answer it is the same imprecision
|
||||
// Claude Code's own skills avoid by splitting a large skill into a
|
||||
// short SKILL.md plus separate reference files loaded on demand.
|
||||
// Because every skill here already follows one convention — a "##"
|
||||
// heading per topic — each section is naturally a single, coherent
|
||||
// chunk, and because the whole corpus is a few dozen short sections
|
||||
// fixed at compile time (go:embed, never changes between deploys),
|
||||
// embedding it once per process and ranking in memory is simpler and
|
||||
// faster than standing up a vector-DB round trip for content this
|
||||
// small and this static. search_codebase, by contrast, indexes a
|
||||
// workspace's actual source files — large, per-user, and changing —
|
||||
// which is exactly the shape services/knowledge's Postgres/pgvector
|
||||
// pipeline exists for; that pipeline would be the wrong tool here, not
|
||||
// a missing piece.
|
||||
package skillkit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Embedder is the subset of *vllmkit.Client this package needs —
|
||||
// declared as a narrow interface (rather than importing vllmkit
|
||||
// directly) so the caller supplies whatever embedding client it
|
||||
// already has, and so this package stays testable without a live
|
||||
// vLLM instance.
|
||||
type Embedder interface {
|
||||
Embed(ctx context.Context, text string) ([]float32, error)
|
||||
}
|
||||
|
||||
// Chunk is one semantically coherent section of a skill file — in
|
||||
// practice, one "## Heading" and the text under it (or the file's
|
||||
// title/intro, for the section before the first heading).
|
||||
type Chunk struct {
|
||||
Skill string // the skill this section belongs to, e.g. "docx"
|
||||
Heading string // the section's heading, e.g. "Editing an existing document" — "Overview" for the lead-in before any "##"
|
||||
Text string // the section's full text, heading line included — what gets embedded, and what Search returns
|
||||
}
|
||||
|
||||
// chunkSkill splits one skill's raw markdown body into Chunks, cutting
|
||||
// at every "## " line. This mirrors the actual structure every file in
|
||||
// skills/*.md already uses, so no separate chunk-size tuning
|
||||
// (domain.ChunkText's char budget, needed for arbitrary source code) is
|
||||
// necessary here — a skill's own section boundaries are already the
|
||||
// right retrieval granularity.
|
||||
func chunkSkill(name, body string) []Chunk {
|
||||
var (
|
||||
chunks []Chunk
|
||||
heading string
|
||||
cur []string
|
||||
)
|
||||
flush := func() {
|
||||
text := strings.TrimSpace(strings.Join(cur, "\n"))
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
h := heading
|
||||
if h == "" {
|
||||
h = "Overview"
|
||||
}
|
||||
chunks = append(chunks, Chunk{Skill: name, Heading: h, Text: text})
|
||||
}
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
if strings.HasPrefix(line, "## ") {
|
||||
flush()
|
||||
heading = strings.TrimPrefix(line, "## ")
|
||||
cur = []string{line}
|
||||
continue
|
||||
}
|
||||
cur = append(cur, line)
|
||||
}
|
||||
flush()
|
||||
return chunks
|
||||
}
|
||||
|
||||
// allChunks returns every skill's chunks, in skillkit.Names() order.
|
||||
func allChunks() []Chunk {
|
||||
var out []Chunk
|
||||
for _, name := range Names() {
|
||||
out = append(out, chunkSkill(name, registry[name].Body)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Match is one ranked search result.
|
||||
type Match struct {
|
||||
Chunk
|
||||
Score float64 // cosine similarity to the query, higher is more relevant
|
||||
}
|
||||
|
||||
type vectorChunk struct {
|
||||
Chunk
|
||||
vec []float32
|
||||
}
|
||||
|
||||
// Index is a lazily-built, in-memory semantic index over every skill's
|
||||
// chunks. The zero value is not usable — construct with NewIndex. An
|
||||
// Index is safe for concurrent use and is meant to be built once and
|
||||
// reused for the life of the process (see package doc for why an
|
||||
// in-memory index, not a persisted one, is the right call here).
|
||||
type Index struct {
|
||||
embedder Embedder
|
||||
|
||||
mu sync.Mutex
|
||||
vectors []vectorChunk // nil until a build has succeeded at least once
|
||||
}
|
||||
|
||||
// NewIndex returns an Index that will embed every skill chunk, once,
|
||||
// the first time Search is called (not eagerly here) — so a
|
||||
// deployment that never calls search_office_skills never pays the
|
||||
// embedding cost at all, and the first real call pays it exactly once.
|
||||
func NewIndex(embedder Embedder) *Index {
|
||||
return &Index{embedder: embedder}
|
||||
}
|
||||
|
||||
// ensureBuilt embeds every chunk if that hasn't already succeeded.
|
||||
// Deliberately NOT a sync.Once: if vLLM is unreachable on the very
|
||||
// first call, that failure must not be cached forever — the next
|
||||
// Search should try again rather than fail for the rest of the
|
||||
// process's life over one transient outage. Concurrent callers during
|
||||
// a build simply serialize on the mutex rather than building twice.
|
||||
func (ix *Index) ensureBuilt(ctx context.Context) error {
|
||||
ix.mu.Lock()
|
||||
defer ix.mu.Unlock()
|
||||
if ix.vectors != nil {
|
||||
return nil
|
||||
}
|
||||
chunks := allChunks()
|
||||
vectors := make([]vectorChunk, 0, len(chunks))
|
||||
for _, c := range chunks {
|
||||
vec, err := ix.embedder.Embed(ctx, c.Text)
|
||||
if err != nil {
|
||||
return fmt.Errorf("skillkit: embed %s/%s: %w", c.Skill, c.Heading, err)
|
||||
}
|
||||
vectors = append(vectors, vectorChunk{Chunk: c, vec: vec})
|
||||
}
|
||||
ix.vectors = vectors
|
||||
return nil
|
||||
}
|
||||
|
||||
// Search embeds query and returns the topK chunks whose own embedding
|
||||
// is most cosine-similar to it, best first. skillFilter, if non-empty,
|
||||
// restricts the search to one skill's chunks (matched the same way
|
||||
// Read does: case-insensitive against the skill name). topK<=0 defaults
|
||||
// to 4.
|
||||
func (ix *Index) Search(ctx context.Context, query string, topK int, skillFilter string) ([]Match, error) {
|
||||
if topK <= 0 {
|
||||
topK = 4
|
||||
}
|
||||
if err := ix.ensureBuilt(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
qvec, err := ix.embedder.Embed(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("skillkit: embed query: %w", err)
|
||||
}
|
||||
|
||||
ix.mu.Lock()
|
||||
vectors := ix.vectors
|
||||
ix.mu.Unlock()
|
||||
|
||||
matches := make([]Match, 0, len(vectors))
|
||||
for _, v := range vectors {
|
||||
if skillFilter != "" && !strings.EqualFold(v.Skill, skillFilter) {
|
||||
continue
|
||||
}
|
||||
matches = append(matches, Match{Chunk: v.Chunk, Score: cosineSimilarity(qvec, v.vec)})
|
||||
}
|
||||
sort.Slice(matches, func(i, j int) bool { return matches[i].Score > matches[j].Score })
|
||||
if len(matches) > topK {
|
||||
matches = matches[:topK]
|
||||
}
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
// cosineSimilarity is the standard cosine of the angle between two
|
||||
// vectors, in [-1, 1] for any nonzero inputs (in practice embeddings
|
||||
// from one model land in a narrower positive range). Defensively walks
|
||||
// only the shorter of the two lengths and returns 0 for a zero vector,
|
||||
// rather than panicking or dividing by zero, though in normal operation
|
||||
// every vector here comes from the same embedding model and is the
|
||||
// same length.
|
||||
func cosineSimilarity(a, b []float32) float64 {
|
||||
n := len(a)
|
||||
if len(b) < n {
|
||||
n = len(b)
|
||||
}
|
||||
var dot, normA, normB float64
|
||||
for i := 0; i < n; i++ {
|
||||
dot += float64(a[i]) * float64(b[i])
|
||||
normA += float64(a[i]) * float64(a[i])
|
||||
normB += float64(b[i]) * float64(b[i])
|
||||
}
|
||||
if normA == 0 || normB == 0 {
|
||||
return 0
|
||||
}
|
||||
return dot / (math.Sqrt(normA) * math.Sqrt(normB))
|
||||
}
|
||||
113
pkg/skillkit/skillkit.go
Normal file
113
pkg/skillkit/skillkit.go
Normal file
@@ -0,0 +1,113 @@
|
||||
// Package skillkit implements, for enkly's local agent, the same
|
||||
// progressive-disclosure "skill" pattern Claude Code uses for its
|
||||
// own document-editing skills: short, versioned markdown files that
|
||||
// document how to use a tool family well — conventions, best
|
||||
// practices, and honestly-stated limitations — kept out of the tool
|
||||
// schema itself (which the model sees on every single request) and
|
||||
// instead loaded on demand, once, the first time the model is about
|
||||
// to actually use that tool family.
|
||||
//
|
||||
// Today that's the three office document skills (docx, xlsx, pptx),
|
||||
// exposed to the model through the read_office_skill tool (see
|
||||
// toolexec/office_skill.go and toolcatalog/catalog.go). The skill
|
||||
// files themselves live in skills/*.md as plain, human-editable
|
||||
// markdown — updating a skill's guidance is a markdown edit, not a Go
|
||||
// code change, the same way updating a Claude Code skill is.
|
||||
package skillkit
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed skills/*.md
|
||||
var skillFS embed.FS
|
||||
|
||||
// Skill is one loaded SKILL.md-equivalent file.
|
||||
type Skill struct {
|
||||
// Name is the catalog key the model passes to read_office_skill —
|
||||
// the file's basename without ".md" (e.g. "docx").
|
||||
Name string
|
||||
// Description is the one-line summary (the "> ..." blockquote
|
||||
// line right after the file's title) shown in the
|
||||
// read_office_skill tool description, so the model can pick the
|
||||
// right skill without having read any of them yet — mirroring how
|
||||
// Claude Code lists available skills by their one-line
|
||||
// descriptions before reading a full SKILL.md.
|
||||
Description string
|
||||
// Body is the file's full content, returned verbatim to the model
|
||||
// when it reads this skill.
|
||||
Body string
|
||||
}
|
||||
|
||||
var registry = mustLoad()
|
||||
|
||||
func mustLoad() map[string]Skill {
|
||||
entries, err := skillFS.ReadDir("skills")
|
||||
if err != nil {
|
||||
// Embedded at build time; a failure here means the package
|
||||
// itself is broken, not a runtime condition callers can
|
||||
// recover from.
|
||||
panic(fmt.Sprintf("skillkit: embedded skills directory unreadable: %v", err))
|
||||
}
|
||||
reg := make(map[string]Skill, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
|
||||
continue
|
||||
}
|
||||
raw, err := skillFS.ReadFile("skills/" + entry.Name())
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("skillkit: reading embedded skills/%s: %v", entry.Name(), err))
|
||||
}
|
||||
name := strings.TrimSuffix(entry.Name(), ".md")
|
||||
reg[name] = Skill{Name: name, Description: parseDescription(string(raw)), Body: string(raw)}
|
||||
}
|
||||
return reg
|
||||
}
|
||||
|
||||
// parseDescription pulls the one-line "> ..." blockquote that follows
|
||||
// each skill file's title — see skills/*.md for the convention.
|
||||
// Falls back to the empty string if a file doesn't follow it, rather
|
||||
// than failing the whole package load over one malformed doc comment.
|
||||
func parseDescription(body string) string {
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "> ") {
|
||||
return strings.TrimPrefix(line, "> ")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// List returns every available skill (name + description, no body),
|
||||
// sorted by name, for building the read_office_skill tool's
|
||||
// description and for any other "what skills exist" listing.
|
||||
func List() []Skill {
|
||||
out := make([]Skill, 0, len(registry))
|
||||
for _, s := range registry {
|
||||
out = append(out, Skill{Name: s.Name, Description: s.Description})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
// Read returns one skill's full content by name, or ok=false if no
|
||||
// such skill exists.
|
||||
func Read(name string) (Skill, bool) {
|
||||
s, ok := registry[strings.ToLower(strings.TrimSpace(name))]
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// Names returns every valid skill name, sorted — used to build the
|
||||
// read_office_skill tool's JSON-schema "enum" so the model can only
|
||||
// ever request a skill that actually exists.
|
||||
func Names() []string {
|
||||
names := make([]string, 0, len(registry))
|
||||
for name := range registry {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
127
pkg/skillkit/skills/docx.md
Normal file
127
pkg/skillkit/skills/docx.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# docx — Word documents
|
||||
|
||||
> Best practices for create_document / read_document / edit_document: structure, styles, and what these tools can't do yet.
|
||||
|
||||
## Content model
|
||||
|
||||
A `.docx` here is just an ordered list of paragraphs. Each paragraph has:
|
||||
|
||||
- `text` — plain text (no inline bold/italic *within* one paragraph — see
|
||||
Limitations; `bold`/`italic`/`underline` apply to the whole paragraph).
|
||||
- `style` — one of `normal`, `heading1`, `heading2`, `heading3`, `bullet`.
|
||||
Defaults to `normal`.
|
||||
- `bold` — bolds the whole paragraph. Implied `true` for headings; set it
|
||||
explicitly on a `normal`/`bullet` paragraph if you want it bold too.
|
||||
- `italic` — italicizes the whole paragraph. Defaults to `false`.
|
||||
- `underline` — underlines the whole paragraph. Defaults to `false`.
|
||||
Combine freely with `bold`/`italic` and with any `style`.
|
||||
|
||||
There is no separate "title page" or "section" concept — structure a
|
||||
document the way you'd structure Markdown: one `heading1` for the
|
||||
document title, `heading2`/`heading3` for subsections, `bullet`
|
||||
paragraphs for lists, `normal` for body copy.
|
||||
|
||||
## Writing a good structured document
|
||||
|
||||
- Lead with a `heading1`. Don't put the document's title in a `normal`
|
||||
paragraph — it won't render distinctly and won't show up in Word's
|
||||
navigation pane.
|
||||
- Keep paragraphs as actual paragraphs, not single giant blocks — split
|
||||
by idea the way you would in prose, not by sentence.
|
||||
- Use `bullet` for genuinely list-shaped content (steps, options,
|
||||
short items), not as a substitute for paragraph breaks.
|
||||
- A short memo doesn't need every heading level — `heading1` +
|
||||
`normal`/`bullet` is often enough. Reach for `heading2`/`heading3` only
|
||||
when the document actually has that many levels of nesting.
|
||||
|
||||
## Editing an existing document
|
||||
|
||||
`edit_document` supports exactly two operations:
|
||||
|
||||
- `append_paragraph` — adds one new paragraph at the end. Same fields as
|
||||
a `create_document` paragraph (including `bold`/`italic`/`underline`).
|
||||
This preserves everything already in the file — tables, images,
|
||||
headers, other formatting — untouched; only a new paragraph is added.
|
||||
- `replace_text` — a literal, case-sensitive find/replace across every
|
||||
text run in the document, including text inside table cells. This is
|
||||
the structured analogue of `edit_file`'s `old_string`/`new_string`: it
|
||||
matches plain text only, not formatting, and every occurrence is
|
||||
replaced (there's no "exactly one match" requirement the way
|
||||
`edit_file` has). One real caveat: Word sometimes splits what reads as
|
||||
one phrase across more than one internal text run (spell-check and
|
||||
revision tracking both do this), and `find` only matches within a
|
||||
single run — if a replacement you expect to apply doesn't, try a
|
||||
shorter, more literal substring, or `read_document` first to see the
|
||||
text as the model sees it.
|
||||
|
||||
There's no "insert paragraph at position N" or "delete paragraph"
|
||||
operation yet — if a document needs real restructuring (not just adding
|
||||
to the end or swapping text), the practical option today is
|
||||
`read_document` to get the current content, then `create_document` a
|
||||
replacement with the full new paragraph list (after confirming with the
|
||||
person you're overwriting it, the same judgment call you'd make before
|
||||
`write_file`-replacing an existing text file).
|
||||
|
||||
Always `read_document` first if you're not certain what's already in the
|
||||
file — don't guess at existing paragraph text before a `replace_text`
|
||||
call.
|
||||
|
||||
## Redlining (Track Changes)
|
||||
|
||||
Two of `edit_document`'s operations record the edit as a reviewable
|
||||
tracked change instead of rewriting the text in place:
|
||||
|
||||
- `append_paragraph_tracked` — same fields as `append_paragraph`, but
|
||||
the new paragraph shows up in Word as a pending insertion (Review >
|
||||
Track Changes), not as ordinary content.
|
||||
- `replace_text_tracked` — same fields as `replace_text`, but instead
|
||||
of silently rewriting matching text it marks the containing run as
|
||||
changed: the old text struck through (a deletion) and the new text
|
||||
inserted beside it. This works at whole-run granularity, not a
|
||||
minimal character diff — in practice a run is usually already one
|
||||
coherent span of matching formatting, so this reads as a natural
|
||||
redline rather than an oversized strikethrough.
|
||||
- Both accept an optional `author` (defaults to `"enkly agent"`) —
|
||||
the reviewer name Word shows on hover and in the Reviewing pane.
|
||||
|
||||
Resolving changes: `accept_all_changes` makes every pending tracked
|
||||
change in the document permanent (deletions removed, insertions kept
|
||||
as ordinary text); `reject_all_changes` discards every pending
|
||||
insertion and restores every pending deletion. Both act on *every*
|
||||
tracked change in the file at once — there's no per-change accept or
|
||||
reject yet, so say so if someone asks for that.
|
||||
|
||||
`read_document` automatically reports any pending tracked changes
|
||||
(author + the affected text) after the plain text, so you don't need a
|
||||
separate call to discover a document already has open redlines before
|
||||
deciding whether to add more, accept, or reject them.
|
||||
|
||||
## Converting Markdown into a document
|
||||
|
||||
`create_document` accepts `markdown` as an alternative to `paragraphs`:
|
||||
pass a Markdown document's source text and headings (`#`/`##`/`###`),
|
||||
paragraphs, and bullet/numbered lists are converted into the matching
|
||||
paragraph styles automatically (numbered lists come out as bullets —
|
||||
there's no numbered-list style yet). Inline formatting (`**bold**`,
|
||||
`*italic*`, links, inline code) is flattened to plain text, the same
|
||||
whole-paragraph-only limit `paragraphs` already has. Tables and raw
|
||||
HTML blocks in the source are dropped, not approximated — say so if a
|
||||
Markdown document being converted has either.
|
||||
|
||||
## Limitations (so you don't promise more than the tool does)
|
||||
|
||||
- No inline run-level formatting — a paragraph is bold/italic/underline
|
||||
or it isn't; there's no bolding one word mid-sentence.
|
||||
- Can't create tables, images, headers/footers, footnotes, page breaks,
|
||||
or columns. `read_document` and `replace_text` *do* see and reach
|
||||
text already inside a table in a file you didn't create with this
|
||||
tool (e.g. a user upload) — they just can't add a new table.
|
||||
- No numbered lists — `bullet` is always a bullet, never `1.`/`2.`/`3.`.
|
||||
- `read_document` extracts plain text only (bullets prefixed `"- "`,
|
||||
one line per paragraph wherever it occurs, table cells included);
|
||||
images and complex formatting in a real user-uploaded `.docx` are
|
||||
still invisible to it — just the text survives.
|
||||
|
||||
If someone explicitly asks for one of these (a table, an image, inline
|
||||
bold mid-paragraph), say plainly that today's document tool doesn't
|
||||
support it rather than silently producing something close but different.
|
||||
62
pkg/skillkit/skills/overview.md
Normal file
62
pkg/skillkit/skills/overview.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Office document skills — overview
|
||||
|
||||
> Read this first if you're not sure which office skill applies, or if the task spans more than one document type.
|
||||
|
||||
enkly can create and edit real Word (`.docx`), Excel (`.xlsx`) and PowerPoint (`.pptx`)
|
||||
files through nine tools (`create_document`/`read_document`/`edit_document`,
|
||||
`create_spreadsheet`/`read_spreadsheet`/`edit_spreadsheet`,
|
||||
`create_presentation`/`read_presentation`/`edit_presentation`), plus a
|
||||
tenth, read-only `read_pdf` for extracting text out of a PDF someone
|
||||
uploaded. Every creation/editing tool takes plain structured fields —
|
||||
text, styles, cell values, slide titles and bullets — and never raw
|
||||
OOXML/XML. All the file-format work happens server-side in
|
||||
`pkg/officekit`; you only ever describe *content*.
|
||||
|
||||
## Required first step
|
||||
|
||||
Before your **first** call to any `create_*`/`edit_*` office tool in a
|
||||
conversation, call `read_office_skill` with the matching skill name
|
||||
(`docx`, `xlsx`, or `pptx`). Each skill documents the exact shape that
|
||||
tool family expects and the specific things it can't do yet — that detail
|
||||
isn't repeated in the tool schema itself, the same way Claude Code's own
|
||||
document skills work. You don't need to re-read a skill you've already
|
||||
read earlier in the same conversation. `read_pdf` is simple enough that
|
||||
its own tool description is usually enough; read the **pdf** skill only
|
||||
if a result looks wrong or someone asks what it can/can't extract.
|
||||
|
||||
## Picking the right skill
|
||||
|
||||
- A letter, report, memo, or any text-first document people will read top
|
||||
to bottom → **docx**. Need redlining/Track Changes, or converting an
|
||||
existing Markdown document straight into one? Still **docx** — see its
|
||||
"Redlining" and "Converting Markdown" sections.
|
||||
- Tabular data, a budget, a grid of numbers, anything with rows and
|
||||
columns, including a workbook with more than one tab → **xlsx**.
|
||||
- Slides for a talk, a pitch, or a deck summarizing something in bullet
|
||||
points → **pptx**.
|
||||
- Extracting text from an uploaded PDF (a report, contract, paper) →
|
||||
**pdf** / `read_pdf`.
|
||||
- A request that genuinely needs more than one of these (e.g. "write a
|
||||
report and also a slide deck summarizing it") still gets one skill read
|
||||
per format you'll actually create — read docx and pptx, not one
|
||||
combined skill.
|
||||
|
||||
## What these tools are *not* for
|
||||
|
||||
There is no `create_pdf`/`edit_pdf` — `read_pdf` is extraction only. If
|
||||
someone specifically needs a new `.pdf` produced, say so plainly rather
|
||||
than silently substituting a `.docx` or `.pptx` — don't imply the file
|
||||
you created is a PDF, and don't invent a conversion step that doesn't
|
||||
exist.
|
||||
|
||||
## General working pattern
|
||||
|
||||
1. Read the matching skill (once per conversation, per format).
|
||||
2. If you're changing an existing file rather than creating a new one,
|
||||
`read_*` it first — don't guess at what paragraphs/cells/slides are
|
||||
already there.
|
||||
3. `create_*` for a brand-new file (it fails loudly if the path already
|
||||
exists, by design — this is the structured-content analogue of
|
||||
`write_file` vs `edit_file`), `edit_*` for a targeted change to one.
|
||||
4. Report back in plain language what you made or changed — the file
|
||||
itself is the deliverable, not a transcript of its contents.
|
||||
51
pkg/skillkit/skills/pdf.md
Normal file
51
pkg/skillkit/skills/pdf.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# pdf — reading PDFs
|
||||
|
||||
> Best practices for read_pdf: what it can extract, what it can't, and how to tell the difference.
|
||||
|
||||
## What it does
|
||||
|
||||
`read_pdf` extracts a PDF's plain text content, best-effort, and
|
||||
returns it as one block of text — no tool call needed to set anything
|
||||
up first. It works well on PDFs that have a real embedded text layer,
|
||||
which is the overwhelming majority of PDFs produced by real software:
|
||||
Word or LibreOffice's "Export/Save as PDF", a browser's "Print to
|
||||
PDF", Pandoc, Pages, most invoicing/reporting tools, and so on.
|
||||
|
||||
There's no `create_pdf` or `edit_pdf` — this skill (and `read_pdf`)
|
||||
is read-only, for extracting data out of a PDF someone uploaded, not
|
||||
for producing one. If someone wants a *new* PDF-shaped deliverable,
|
||||
build it as a `.docx`/`.pptx`/`.xlsx` with `create_document` /
|
||||
`create_presentation` / `create_spreadsheet` instead and say plainly
|
||||
that this deployment can't export straight to PDF yet.
|
||||
|
||||
## What it can't do
|
||||
|
||||
- **No OCR.** A scanned PDF (a photographed or scanned document saved
|
||||
as a PDF of page images, with no underlying text layer) has nothing
|
||||
for `read_pdf` to extract — it returns an error saying so rather
|
||||
than returning nothing silently or guessing at content. If that
|
||||
happens, say plainly that the PDF appears to be scanned/image-only
|
||||
and this tool can't OCR it.
|
||||
- **No layout.** Tables, multi-column text, headers/footers, and
|
||||
figure captions all come back as part of the same flat text stream,
|
||||
roughly in reading order but with no structural markers — a
|
||||
two-column PDF's columns may interleave line by line rather than
|
||||
reading column-by-column. Don't present extracted text as if it
|
||||
preserves a PDF's original formatting or table structure.
|
||||
- **Unusual font encodings** (a PDF whose fonts remap character codes
|
||||
in a nonstandard way, with no usable mapping back to plain text) can
|
||||
come out as mangled or missing text for just that portion of the
|
||||
document, even when the rest extracts cleanly.
|
||||
|
||||
## Using it well
|
||||
|
||||
- Read the whole PDF first before answering questions about it — the
|
||||
tool has no "just page 3" mode yet, it always returns everything it
|
||||
can extract in one call.
|
||||
- If the extracted text looks suspiciously short relative to what the
|
||||
person describes the PDF as containing, say so rather than treating
|
||||
a short result as the complete document — it may be mostly scanned
|
||||
pages with only a cover page or two of real text.
|
||||
- Don't infer a PDF's page count, visual layout, or anything about
|
||||
images/charts in it from `read_pdf`'s output; it only ever reports
|
||||
what text it found, never a page-by-page breakdown.
|
||||
59
pkg/skillkit/skills/pptx.md
Normal file
59
pkg/skillkit/skills/pptx.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# pptx — PowerPoint decks
|
||||
|
||||
> Best practices for create_presentation / read_presentation / edit_presentation: slide structure and what these tools can't do yet.
|
||||
|
||||
## Content model
|
||||
|
||||
Every slide is exactly a title plus a flat list of bullet lines — two
|
||||
fixed text boxes, nothing else. There's no layout to choose, no manual
|
||||
positioning, no per-bullet nesting (sub-bullets). If a slide's content
|
||||
doesn't reduce cleanly to "one title, some bullets," it's being forced
|
||||
into a shape it won't fit — say so rather than cramming a table or a
|
||||
diagram's content into bullet text and calling it equivalent.
|
||||
|
||||
## Writing a good deck
|
||||
|
||||
- One idea per slide. If a slide's bullet list is getting long (much
|
||||
past 5-6 lines), that's usually a sign it should be two slides, not
|
||||
one dense one.
|
||||
- Titles should be short and specific ("Q3 Revenue by Region", not
|
||||
"Update") — they're the only thing distinguishing slides in
|
||||
`read_presentation`'s output and in Word's/PowerPoint's own slide
|
||||
sorter.
|
||||
- Bullets are terse fragments, not full paragraphs — if a "bullet" is
|
||||
three sentences long, trim it or split it into more bullets.
|
||||
- A deck needs a title slide too: make slide 1 a title-only slide (title
|
||||
set, `bullets` omitted or empty) rather than starting straight into
|
||||
content.
|
||||
|
||||
## Editing an existing deck
|
||||
|
||||
`edit_presentation` supports exactly two operations:
|
||||
|
||||
- `add_slide` — appends one new slide at the end. Same `title`/`bullets`
|
||||
shape as `create_presentation`.
|
||||
- `update_slide` — replaces one existing slide's title and bullets
|
||||
entirely, by 1-based `index`. This **replaces the whole slide's
|
||||
content**, not a single bullet — if you only want to change one line,
|
||||
`read_presentation` first, take its current title/bullets, and pass
|
||||
back the full set with just that one line changed.
|
||||
|
||||
There's no "insert slide at position N," "delete slide," or "reorder
|
||||
slides" operation yet. `read_presentation` first if you're not sure how
|
||||
many slides exist or what index to target — don't guess at slide
|
||||
numbers on a deck you haven't read in this conversation.
|
||||
|
||||
## Limitations (so you don't promise more than the tool does)
|
||||
|
||||
- No images, charts, tables, or speaker notes.
|
||||
- No custom layouts or manual text-box positioning — always the same
|
||||
title-plus-bullets shape, two fixed text boxes.
|
||||
- No slide deletion or reordering.
|
||||
- `read_presentation` extracts title + bullet text only; a real
|
||||
user-uploaded `.pptx` with images, custom layouts or multiple text
|
||||
boxes per slide will lose all of that and return text only.
|
||||
|
||||
If someone explicitly asks for an image, a chart, or a custom layout,
|
||||
say plainly that today's presentation tool doesn't support it rather
|
||||
than silently producing a bullet-only slide and calling it the same
|
||||
thing.
|
||||
79
pkg/skillkit/skills/xlsx.md
Normal file
79
pkg/skillkit/skills/xlsx.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# xlsx — Excel workbooks
|
||||
|
||||
> Best practices for create_spreadsheet / read_spreadsheet / edit_spreadsheet: sheets, cell values, and sparse edits.
|
||||
|
||||
## Content model
|
||||
|
||||
A workbook is one or more sheets, each a row-major grid of plain-text
|
||||
cell values. Row 1 is the top row, column A is the leftmost column — the
|
||||
grid you pass is literally what lands in the file, top-to-bottom,
|
||||
left-to-right. Each value is classified the same way on both
|
||||
`create_spreadsheet` and `edit_spreadsheet`:
|
||||
|
||||
- Starts with `=` (e.g. `"=SUM(A1:A4)"`, `"=B2*C2"`) → stored as a live
|
||||
Excel **formula**, same as typing it into a cell in Excel. Excel
|
||||
computes and displays the result when the file is opened; this tool
|
||||
never evaluates formulas itself, so `read_spreadsheet` right after
|
||||
writing one shows the formula text (prefixed `=`), not a computed
|
||||
number — that's expected, not a bug.
|
||||
- Otherwise parses as a plain number (`"92"`, `"3.5"`, `"-1"`) → stored
|
||||
as a **numeric** cell.
|
||||
- Anything else, including something that merely *looks* numeric-ish
|
||||
like `"$92"` or `"92%"`, is stored as **text**.
|
||||
|
||||
If you want a literal computed total rather than a live formula (e.g.
|
||||
because the person asked for a static report, not a workbook they'll
|
||||
keep editing), compute it yourself — with `solve_ml_math` for anything
|
||||
nontrivial — and write the plain number instead of a formula string.
|
||||
|
||||
## Single sheet vs. multiple sheets
|
||||
|
||||
`create_spreadsheet` has two forms:
|
||||
|
||||
- Single sheet: `rows` (+ optional `sheet_name`, defaults to `"Sheet1"`).
|
||||
- Multiple sheets: `sheets`, an array of `{name, rows}` — one entry per
|
||||
tab, in the order they should appear. Use this whenever the request
|
||||
implies more than one tab ("a workbook with a summary sheet and a
|
||||
detail sheet", "separate sheets per quarter") — don't flatten
|
||||
multi-sheet data onto one sheet with a column that says which sheet it
|
||||
"would" be.
|
||||
|
||||
Pick one form, not both, in a single `create_spreadsheet` call.
|
||||
|
||||
## Reading and editing a specific sheet
|
||||
|
||||
`read_spreadsheet` and `edit_spreadsheet` both take an optional `sheet`
|
||||
field: a 1-based index (`"2"`) or a sheet name (case-insensitive).
|
||||
Omitting it means the first sheet. When a workbook has more than one
|
||||
sheet, `read_spreadsheet`'s result starts with a line naming which sheet
|
||||
you got and listing every sheet's name — read that line before assuming
|
||||
you're looking at the sheet you meant, especially on a user-uploaded
|
||||
file whose sheet order you don't already know.
|
||||
|
||||
`edit_spreadsheet` writes a **sparse** set of cells by A1-style
|
||||
reference (e.g. `"B12"`) to one sheet; every other cell on that sheet is
|
||||
left exactly as it was, and a reference past the current grid extends
|
||||
it rather than erroring. There's no "edit multiple sheets in one call" —
|
||||
make one `edit_spreadsheet` call per sheet that needs a change.
|
||||
|
||||
Always `read_spreadsheet` first if you're not sure what's already in a
|
||||
sheet or what the other sheets are called, the same way you'd
|
||||
`read_file` before `edit_file`.
|
||||
|
||||
## Limitations (so you don't promise more than the tool does)
|
||||
|
||||
- No cell formatting (bold, colors, borders, column widths, number
|
||||
formats like currency or percentages).
|
||||
- No charts.
|
||||
- No sheet deletion or reordering, and no renaming a sheet after
|
||||
creation.
|
||||
- `read_spreadsheet` returns display strings only — no distinction
|
||||
between a cell's underlying value and how Excel would format it. For
|
||||
a formula cell, it shows Excel's own cached result when one exists
|
||||
(always true for a real Excel-authored or user-uploaded file), or the
|
||||
formula text itself (prefixed `=`) when nothing has computed it yet
|
||||
(always true immediately after this tool writes one).
|
||||
|
||||
If someone explicitly asks for formatting or a chart, say plainly that
|
||||
today's tool doesn't support it rather than quietly writing a plain,
|
||||
unformatted workbook and calling it done.
|
||||
196
pkg/turnopts/turnopts.go
Normal file
196
pkg/turnopts/turnopts.go
Normal file
@@ -0,0 +1,196 @@
|
||||
// Package turnopts parses a small set of leading slash-directives off a
|
||||
// chat prompt — /effort, /fast, /system — into structured per-turn
|
||||
// options. It exists as a pragmatic bridge: api/proto/agent/v1/
|
||||
// agent.proto documents the "real" target API surface for effort
|
||||
// control, fast mode and live system-entry updates (new StartTurnRequest
|
||||
// fields), but wiring those through means regenerating gen/agent/v1
|
||||
// with protoc — a build step this authoring environment has no network
|
||||
// access to run (see ARCHITECTURE.md §6, "What's verified, what isn't",
|
||||
// for the project's own established pattern of calling this out rather
|
||||
// than hand-editing generated code and hoping). Parsing a few directive
|
||||
// lines off the existing plain-string StartTurnRequest.prompt field
|
||||
// needs no proto change at all, so it's what actually ships working
|
||||
// today; application.Service.StartTurn calls Parse before anything else
|
||||
// and the gateway's chat box documents the directives inline. Once
|
||||
// `make proto` is run against the extended .proto, a thin follow-up can
|
||||
// read the structured fields directly and this package becomes the
|
||||
// fallback path for a plain-text client instead of the only path.
|
||||
package turnopts
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"enkly/pkg/effort"
|
||||
)
|
||||
|
||||
// SystemEntryKind mirrors agentrun.SystemEntryKind's three values
|
||||
// without importing the agentrun package (which would pull the agent
|
||||
// service's whole internal/infrastructure tree into a leaf pkg/
|
||||
// package that belongs to no particular service) — application.Service
|
||||
// translates this to the real agentrun.SystemEntryKind at the one call
|
||||
// site that actually constructs an agentrun.SystemEntry.
|
||||
type SystemEntryKind string
|
||||
|
||||
const (
|
||||
SystemEntryInstruction SystemEntryKind = "instruction"
|
||||
SystemEntryPermission SystemEntryKind = "permission"
|
||||
SystemEntryBudget SystemEntryKind = "budget"
|
||||
)
|
||||
|
||||
// SystemEntry is one /system directive's parsed kind + content.
|
||||
type SystemEntry struct {
|
||||
Kind SystemEntryKind
|
||||
Content string
|
||||
}
|
||||
|
||||
// Options is everything Parse extracts from a prompt's leading
|
||||
// directive lines.
|
||||
type Options struct {
|
||||
// Effort is the requested reasoning-depth level, or "" if no
|
||||
// /effort directive was present — application.Service.StartTurn
|
||||
// leaves the Agent's own default in force in that case rather than
|
||||
// forcing a level.
|
||||
Effort effort.Level
|
||||
// Fast requests fast mode for this turn — see /fast's doc comment
|
||||
// below and application.Service's fast-mode client selection.
|
||||
Fast bool
|
||||
// SystemEntries are every /system directive present, in the order
|
||||
// they appeared.
|
||||
SystemEntries []SystemEntry
|
||||
// HadDirectives reports whether anything was actually parsed out of
|
||||
// the prompt — lets a caller skip rebuilding a cleaned prompt
|
||||
// string (Clean) when there's nothing to clean, which is the
|
||||
// overwhelmingly common case.
|
||||
HadDirectives bool
|
||||
}
|
||||
|
||||
// directivePrefixes lists every recognized directive, longest-match
|
||||
// order doesn't matter since each is matched as a whole leading token.
|
||||
var directivePrefixes = []string{"/effort", "/fast", "/system"}
|
||||
|
||||
// isDirectiveLine reports whether line (already trimmed) opens with one
|
||||
// of directivePrefixes followed by either nothing or whitespace — so
|
||||
// "/effortless" (a real sentence someone might type) is correctly NOT
|
||||
// treated as an /effort directive.
|
||||
func isDirectiveLine(line string) (prefix string, rest string, ok bool) {
|
||||
for _, p := range directivePrefixes {
|
||||
if line == p {
|
||||
return p, "", true
|
||||
}
|
||||
if strings.HasPrefix(line, p+" ") || strings.HasPrefix(line, p+"\t") {
|
||||
return p, strings.TrimSpace(line[len(p):]), true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// Parse reads directive lines off the START of prompt — one per line,
|
||||
// stopping at the first line that isn't a recognized directive — and
|
||||
// returns the parsed Options plus the remaining prompt text (clean,
|
||||
// with the directive lines and any single blank line immediately after
|
||||
// them removed). A prompt with no leading directives at all returns the
|
||||
// zero Options and prompt unchanged, byte for byte, so the overwhelming
|
||||
// majority of turns pay no cost here beyond one cheap prefix check per
|
||||
// line.
|
||||
//
|
||||
// Directives, each on its own line at the very start of the message:
|
||||
//
|
||||
// /effort low|medium|high|extra|max — reasoning depth for this turn
|
||||
// (see pkg/effort); unrecognized
|
||||
// text is reported back as part
|
||||
// of the tool result, not silently
|
||||
// ignored — see Parse's errs return.
|
||||
// /fast on|off — fast mode for this turn (off is
|
||||
// the default; "/fast" alone means on)
|
||||
// /system <instruction text> — adds a live "instruction" system
|
||||
// entry (agentrun.SystemEntry) for
|
||||
// this turn onward; repeatable
|
||||
// /system permission: <text> — same, kind "permission"
|
||||
// /system budget: <text> — same, kind "budget"
|
||||
//
|
||||
// Directives are deliberately NOT required to be the only content of a
|
||||
// message — "/effort high\n\nnow refactor the billing package" works
|
||||
// exactly as "/effort high" on its own line followed immediately by the
|
||||
// real prompt. A message that's directives with nothing else after them
|
||||
// (clean comes back empty) is treated by the caller the same as any
|
||||
// other empty prompt.
|
||||
func Parse(prompt string) (opts Options, clean string) {
|
||||
lines := strings.Split(prompt, "\n")
|
||||
i := 0
|
||||
for ; i < len(lines); i++ {
|
||||
trimmed := strings.TrimSpace(lines[i])
|
||||
if trimmed == "" {
|
||||
// A blank line before any directive is just normal prompt
|
||||
// formatting, not a directive terminator — only stop
|
||||
// consuming on a non-directive, non-blank line, or once at
|
||||
// least one directive has been seen and a blank line
|
||||
// follows it (handled by the break conditions below).
|
||||
if opts.HadDirectives {
|
||||
i++ // swallow the one blank line right after directives
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
prefix, rest, ok := isDirectiveLine(trimmed)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
opts.HadDirectives = true
|
||||
switch prefix {
|
||||
case "/effort":
|
||||
if lvl, err := effort.Parse(rest); err == nil {
|
||||
opts.Effort = lvl
|
||||
}
|
||||
// An unparseable level is silently dropped rather than
|
||||
// failing the whole turn over a typo in a directive line —
|
||||
// application.Service still runs the turn at the Agent's
|
||||
// default effort. A future structured API surface (the
|
||||
// proto fields) can report this as a real validation
|
||||
// error; a best-effort text convention shouldn't block a
|
||||
// turn over it.
|
||||
case "/fast":
|
||||
opts.Fast = parseOnOff(rest, true) // bare "/fast" means on
|
||||
case "/system":
|
||||
if entry, ok := parseSystemDirective(rest); ok {
|
||||
opts.SystemEntries = append(opts.SystemEntries, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !opts.HadDirectives {
|
||||
return opts, prompt
|
||||
}
|
||||
clean = strings.TrimLeft(strings.Join(lines[i:], "\n"), "\n")
|
||||
return opts, clean
|
||||
}
|
||||
|
||||
func parseOnOff(s string, defaultWhenEmpty bool) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "":
|
||||
return defaultWhenEmpty
|
||||
case "on", "true", "1", "yes":
|
||||
return true
|
||||
case "off", "false", "0", "no":
|
||||
return false
|
||||
default:
|
||||
return defaultWhenEmpty
|
||||
}
|
||||
}
|
||||
|
||||
func parseSystemDirective(rest string) (SystemEntry, bool) {
|
||||
rest = strings.TrimSpace(rest)
|
||||
if rest == "" {
|
||||
return SystemEntry{}, false
|
||||
}
|
||||
for _, kind := range []SystemEntryKind{SystemEntryPermission, SystemEntryBudget} {
|
||||
p := string(kind) + ":"
|
||||
if strings.HasPrefix(strings.ToLower(rest), p) {
|
||||
content := strings.TrimSpace(rest[len(p):])
|
||||
if content == "" {
|
||||
return SystemEntry{}, false
|
||||
}
|
||||
return SystemEntry{Kind: kind, Content: content}, true
|
||||
}
|
||||
}
|
||||
return SystemEntry{Kind: SystemEntryInstruction, Content: rest}, true
|
||||
}
|
||||
107
pkg/turnopts/turnopts_test.go
Normal file
107
pkg/turnopts/turnopts_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package turnopts
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"enkly/pkg/effort"
|
||||
)
|
||||
|
||||
func TestParse_NoDirectives(t *testing.T) {
|
||||
prompt := "just refactor the billing package please"
|
||||
opts, clean := Parse(prompt)
|
||||
if opts.HadDirectives {
|
||||
t.Fatal("HadDirectives = true, want false for a plain prompt")
|
||||
}
|
||||
if clean != prompt {
|
||||
t.Fatalf("clean = %q, want unchanged %q", clean, prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_EffortAndRest(t *testing.T) {
|
||||
opts, clean := Parse("/effort high\n\nnow refactor the billing package")
|
||||
if !opts.HadDirectives {
|
||||
t.Fatal("HadDirectives = false, want true")
|
||||
}
|
||||
if opts.Effort != effort.High {
|
||||
t.Fatalf("Effort = %q, want %q", opts.Effort, effort.High)
|
||||
}
|
||||
if clean != "now refactor the billing package" {
|
||||
t.Fatalf("clean = %q", clean)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_FastBare(t *testing.T) {
|
||||
opts, clean := Parse("/fast\nmake this quick")
|
||||
if !opts.Fast {
|
||||
t.Fatal("Fast = false, want true for bare /fast")
|
||||
}
|
||||
if clean != "make this quick" {
|
||||
t.Fatalf("clean = %q", clean)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_FastOff(t *testing.T) {
|
||||
opts, _ := Parse("/fast off\nhello")
|
||||
if opts.Fast {
|
||||
t.Fatal("Fast = true, want false for /fast off")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_SystemEntries(t *testing.T) {
|
||||
opts, clean := Parse(
|
||||
"/system never touch the prod/ directory\n" +
|
||||
"/system permission: you may now run destructive migrations here\n" +
|
||||
"/system budget: 4 tool calls left\n" +
|
||||
"go ahead and start",
|
||||
)
|
||||
if len(opts.SystemEntries) != 3 {
|
||||
t.Fatalf("got %d system entries, want 3: %+v", len(opts.SystemEntries), opts.SystemEntries)
|
||||
}
|
||||
if opts.SystemEntries[0].Kind != SystemEntryInstruction || opts.SystemEntries[0].Content != "never touch the prod/ directory" {
|
||||
t.Errorf("entry 0 = %+v", opts.SystemEntries[0])
|
||||
}
|
||||
if opts.SystemEntries[1].Kind != SystemEntryPermission || opts.SystemEntries[1].Content != "you may now run destructive migrations here" {
|
||||
t.Errorf("entry 1 = %+v", opts.SystemEntries[1])
|
||||
}
|
||||
if opts.SystemEntries[2].Kind != SystemEntryBudget || opts.SystemEntries[2].Content != "4 tool calls left" {
|
||||
t.Errorf("entry 2 = %+v", opts.SystemEntries[2])
|
||||
}
|
||||
if clean != "go ahead and start" {
|
||||
t.Fatalf("clean = %q", clean)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_NotADirectiveLookalike(t *testing.T) {
|
||||
// "/effortless" must NOT be parsed as "/effort" + "less".
|
||||
prompt := "/effortless attempts at this keep failing, please help"
|
||||
opts, clean := Parse(prompt)
|
||||
if opts.HadDirectives {
|
||||
t.Fatalf("HadDirectives = true for %q, want false", prompt)
|
||||
}
|
||||
if clean != prompt {
|
||||
t.Fatalf("clean = %q, want unchanged", clean)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_UnknownEffortValueIgnored(t *testing.T) {
|
||||
opts, clean := Parse("/effort super-duper\ndo the thing")
|
||||
if !opts.HadDirectives {
|
||||
t.Fatal("HadDirectives = false, want true (the line was still a directive)")
|
||||
}
|
||||
if opts.Effort != "" {
|
||||
t.Fatalf("Effort = %q, want empty for an unrecognized value", opts.Effort)
|
||||
}
|
||||
if clean != "do the thing" {
|
||||
t.Fatalf("clean = %q", clean)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_DirectivesOnlyNoBody(t *testing.T) {
|
||||
opts, clean := Parse("/effort max\n/fast on")
|
||||
if !opts.HadDirectives {
|
||||
t.Fatal("HadDirectives = false, want true")
|
||||
}
|
||||
if clean != "" {
|
||||
t.Fatalf("clean = %q, want empty", clean)
|
||||
}
|
||||
}
|
||||
845
pkg/vllmkit/client.go
Normal file
845
pkg/vllmkit/client.go
Normal file
@@ -0,0 +1,845 @@
|
||||
// Package vllmkit talks to the production vLLM OpenAI-compatible
|
||||
// inference servers that host Qwen 3.5 (chat + tool calling) and a
|
||||
// separate embedding model. Shared by the agent service (ChatStream)
|
||||
// and the knowledge service (Embed) — the same internal/llm.Client the
|
||||
// monolith had, split out since both of those bounded contexts need it
|
||||
// and neither should depend on the other.
|
||||
//
|
||||
// This package previously spoke Ollama's native /api/chat and
|
||||
// /api/embed protocol directly against a single local daemon. It now
|
||||
// speaks the OpenAI /v1/chat/completions and /v1/embeddings wire
|
||||
// format that `vllm serve` exposes, against two independently scaled,
|
||||
// independently deployed vLLM servers — one per model, since a single
|
||||
// vLLM engine process serves exactly one model. See
|
||||
// ENKLY_VLLM_CHAT_URL / ENKLY_VLLM_EMBED_URL in cmd/server/main.go and
|
||||
// k8s/11-vllm.yaml for how those servers are configured and deployed.
|
||||
package vllmkit
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultChatModel is the served model name the chat vLLM
|
||||
// deployment must be started with (`vllm serve ... --served-model-name
|
||||
// qwen3.5`). vLLM matches the request body's "model" field against
|
||||
// that flag's value, not against a Hugging Face repo id, so this
|
||||
// is a deployment-chosen alias, not a model identifier — keep it
|
||||
// in sync with k8s/11-vllm.yaml's --served-model-name.
|
||||
DefaultChatModel = "qwen3.5"
|
||||
// DefaultEmbedModel is the served model name for the embedding
|
||||
// vLLM deployment. vLLM's pooling/embedding model support covers
|
||||
// nomic-ai/nomic-embed-text-v1.5 (what this deployment actually
|
||||
// runs; it needs `--trust-remote-code` since Nomic's config uses
|
||||
// custom modeling code) — small, fast, and a good CPU/GPU-both
|
||||
// fit. Update the `vector(768)` dimension in schema.sql first if
|
||||
// you point this at a model with a different output size.
|
||||
DefaultEmbedModel = "nomic-embed-text"
|
||||
)
|
||||
|
||||
// Options mirrors the subset of the OpenAI-compatible chat-completions
|
||||
// request body that's worth tuning per call. Unlike Ollama's
|
||||
// num_ctx/num_gpu/num_thread, context length and GPU/tensor-parallel
|
||||
// layout are not per-request knobs under vLLM — they're fixed at
|
||||
// server start (--max-model-len, --tensor-parallel-size,
|
||||
// --gpu-memory-utilization; see k8s/11-vllm.yaml) and apply to every
|
||||
// request the deployment serves. Zero values are omitted from the wire
|
||||
// request, so the server's own defaults apply unless explicitly
|
||||
// overridden — see the ENKLY_CHAT_* env vars wired up in
|
||||
// cmd/server/main.go.
|
||||
type Options struct {
|
||||
Temperature float64 // sampling temperature (temperature)
|
||||
TopP float64 // nucleus sampling cutoff (top_p)
|
||||
MaxTokens int // max tokens to generate this turn (max_tokens); 0 = server default
|
||||
}
|
||||
|
||||
// Client is a thin wrapper around two vLLM OpenAI-compatible HTTP
|
||||
// APIs: one serving the chat model, one serving the embedding model.
|
||||
type Client struct {
|
||||
// ChatBaseURL / EmbedBaseURL point at each vLLM deployment's
|
||||
// OpenAI-compatible router, e.g. "https://vllm-chat.enkly.org/v1" and
|
||||
// "https://vllm-embed.enkly.org/v1". They are deliberately separate
|
||||
// fields (rather than one shared host) because a chat model and
|
||||
// an embedding model are different vLLM engine processes in
|
||||
// production — usually on different Services/Deployments with
|
||||
// different GPU shapes — not two endpoints behind the same daemon
|
||||
// the way Ollama modeled it.
|
||||
ChatBaseURL string
|
||||
EmbedBaseURL string
|
||||
ChatModel string
|
||||
EmbedModel string
|
||||
HTTP *http.Client
|
||||
|
||||
// APIKey, when set, is sent as "Authorization: Bearer <APIKey>" on
|
||||
// every request — vLLM's --api-key flag. Leave empty for a
|
||||
// cluster-internal deployment with no auth in front of it (the
|
||||
// Service is only reachable from inside the mesh/namespace); set
|
||||
// it for anything exposed beyond that boundary. Set via
|
||||
// ENKLY_VLLM_API_KEY.
|
||||
APIKey string
|
||||
|
||||
// Think controls extended "thinking" on chat requests via vLLM's
|
||||
// chat_template_kwargs extra-body extension: "" leaves it to the
|
||||
// chat template's own default (Qwen3's thinking-capable template
|
||||
// defaults to thinking on), "true"/"false" (and the usual
|
||||
// on/off/yes/no spellings) force it on/off. A "low"/"medium"/
|
||||
// "high" effort string (see pkg/effort) is also accepted for
|
||||
// compatibility with the Anthropic-style effort vocabulary the
|
||||
// rest of the agent uses — vLLM/Qwen3's reasoning toggle itself is
|
||||
// a single on/off switch with no server-side notion of graded
|
||||
// effort, so any non-empty, non-"false" level here just turns
|
||||
// thinking on; depth is instead shaped by Options.MaxTokens and
|
||||
// agentrun's own round/compaction budgets (see effort.Profile).
|
||||
// When on, the reasoning trace streams back separately from the
|
||||
// answer, in OpenAI's "reasoning_content" delta field
|
||||
// (ChatEvent.Thinking) — requires the chat deployment to be
|
||||
// started with a reasoning parser, e.g. `--reasoning-parser
|
||||
// qwen3`. Set via ENKLY_CHAT_THINK.
|
||||
Think string
|
||||
// ChatOptions is sent as the top-level temperature/top_p/max_tokens
|
||||
// fields of the chat-completions request.
|
||||
ChatOptions Options
|
||||
|
||||
// embedCache memoizes Embed() results within this process's
|
||||
// lifetime, keyed by (model, text) hash. Embedding the exact same
|
||||
// string twice is common — repeated search_codebase queries within
|
||||
// a conversation, or an unchanged chunk re-indexed after a
|
||||
// no-op edit that rag.Store's content-hash check didn't already
|
||||
// short-circuit — and skipping the round trip to vLLM for a
|
||||
// repeat is a pure latency win with no correctness downside, since
|
||||
// embeddings are a deterministic function of (model, text).
|
||||
embedCache *embedCache
|
||||
}
|
||||
|
||||
// New builds a Client pointed at the given chat and embed vLLM
|
||||
// deployments, with connection reuse tuned for the request pattern
|
||||
// here: a handful of long-lived streaming chat requests plus frequent,
|
||||
// short embedding calls, each against its own vLLM host. Either URL
|
||||
// may be left empty if this Client will only ever be used for the
|
||||
// other call (e.g. the knowledge service never calls ChatStream, so it
|
||||
// passes "" for chatBaseURL).
|
||||
func New(chatBaseURL, embedBaseURL, chatModel, embedModel string) *Client {
|
||||
if chatBaseURL == "" {
|
||||
chatBaseURL = "http://localhost:8000/v1"
|
||||
}
|
||||
if embedBaseURL == "" {
|
||||
embedBaseURL = "http://localhost:8001/v1"
|
||||
}
|
||||
if chatModel == "" {
|
||||
chatModel = DefaultChatModel
|
||||
}
|
||||
if embedModel == "" {
|
||||
embedModel = DefaultEmbedModel
|
||||
}
|
||||
transport := &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
KeepAlive: 60 * time.Second,
|
||||
}).DialContext,
|
||||
// Two vLLM hosts, but chat + embed + concurrent requests (see
|
||||
// agent.go's parallel tool execution, which can itself fan out
|
||||
// concurrent search_codebase embed calls) can be in flight
|
||||
// together — a small pool avoids a fresh TCP+HTTP handshake on
|
||||
// every call, a real chunk of latency in practice even against
|
||||
// a fast in-cluster backend.
|
||||
MaxIdleConns: 64,
|
||||
MaxIdleConnsPerHost: 32,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
}
|
||||
return &Client{
|
||||
ChatBaseURL: chatBaseURL,
|
||||
EmbedBaseURL: embedBaseURL,
|
||||
ChatModel: chatModel,
|
||||
EmbedModel: embedModel,
|
||||
HTTP: &http.Client{
|
||||
Transport: transport,
|
||||
// No overall timeout: chat streams can legitimately run for
|
||||
// minutes while the agent loop executes several tool calls.
|
||||
// Callers should thread a context deadline through ctx instead.
|
||||
},
|
||||
embedCache: newEmbedCache(500),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) authHeader(req *http.Request) {
|
||||
if c.APIKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Chat (streaming, with tool calling) — OpenAI /v1/chat/completions
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// wireToolCall is the OpenAI wire shape for a tool call, both on a
|
||||
// complete assistant message (echoed back into history) and,
|
||||
// incrementally, on a streamed delta (see toolCallAccumulator).
|
||||
type wireToolCall struct {
|
||||
Index *int `json:"index,omitempty"` // present only on streamed deltas
|
||||
ID string `json:"id,omitempty"`
|
||||
Type string `json:"type,omitempty"` // always "function"
|
||||
Function wireToolCallFunction `json:"function"`
|
||||
}
|
||||
|
||||
type wireToolCallFunction struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
// Arguments is a JSON-encoded string on the wire (OpenAI's
|
||||
// function-calling contract), not a nested object the way Ollama's
|
||||
// native API sent it — toWireMessages/fromWireToolCalls marshal
|
||||
// and unmarshal it at this package's boundary so the rest of the
|
||||
// codebase still deals in map[string]any.
|
||||
Arguments string `json:"arguments,omitempty"`
|
||||
}
|
||||
|
||||
type wireMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content *string `json:"content"` // nil (omitted) for an assistant message that is tool_calls-only
|
||||
ToolCalls []wireToolCall `json:"tool_calls,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"` // role="tool" only
|
||||
Name string `json:"name,omitempty"` // role="tool" only; some parsers want the tool name too
|
||||
}
|
||||
|
||||
type wireToolDef struct {
|
||||
Type string `json:"type"` // always "function"
|
||||
Function wireToolFunction `json:"function"`
|
||||
}
|
||||
|
||||
type wireToolFunction struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Parameters map[string]any `json:"parameters"`
|
||||
}
|
||||
|
||||
type chatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []wireMessage `json:"messages"`
|
||||
Tools []wireToolDef `json:"tools,omitempty"`
|
||||
Stream bool `json:"stream"`
|
||||
// ChatTemplateKwargs is vLLM's extra-body extension point for
|
||||
// per-request chat-template flags; "enable_thinking" is how
|
||||
// Qwen3's template (and vLLM's qwen3 reasoning parser) take the
|
||||
// thinking on/off toggle. Opaque to the OpenAI spec itself, so
|
||||
// this is marshaled as a bare map rather than a typed field.
|
||||
ChatTemplateKwargs map[string]any `json:"chat_template_kwargs,omitempty"`
|
||||
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
TopP float64 `json:"top_p,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
}
|
||||
|
||||
// chatStreamChunk is one Server-Sent-Events "data:" line of an OpenAI
|
||||
// streaming chat-completions response.
|
||||
type chatStreamChunk struct {
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
ToolCalls []wireToolCall `json:"tool_calls"`
|
||||
} `json:"delta"`
|
||||
FinishReason *string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
// Error is set when a vLLM server streams a mid-response failure
|
||||
// as a normal 200 SSE line rather than an HTTP error status (seen
|
||||
// e.g. when the tool-call parser can't make sense of the model's
|
||||
// raw output partway through generation).
|
||||
Error *wireError `json:"error"`
|
||||
}
|
||||
|
||||
// ErrUpstreamUnavailable wraps a transport-level failure to reach a
|
||||
// vLLM deployment at all — connection refused, DNS failure, dial/TLS
|
||||
// timeout — as opposed to vLLM responding with an error status or a
|
||||
// malformed body. This is exactly the signature of a vllm-chat/
|
||||
// vllm-embed pod that's Pending, still cold-starting (model load, see
|
||||
// k8s/11-vllm.yaml's startupProbe), or mid-rollout (Recreate strategy
|
||||
// briefly has zero replicas) — not a request vLLM itself rejected.
|
||||
//
|
||||
// Callers use IsUpstreamUnavailable to tell the two apart: the agent
|
||||
// service turns this into a short "model is starting up" message for
|
||||
// the end user instead of a raw dial error (see
|
||||
// services/agent/internal/application/service.go), and the knowledge
|
||||
// service's gRPC layer maps it onto codes.Unavailable (safe to retry)
|
||||
// instead of codes.Internal (opaque failure).
|
||||
var ErrUpstreamUnavailable = errors.New("vllm: upstream unreachable")
|
||||
|
||||
// IsUpstreamUnavailable reports whether err is, or wraps,
|
||||
// ErrUpstreamUnavailable.
|
||||
func IsUpstreamUnavailable(err error) bool { return errors.Is(err, ErrUpstreamUnavailable) }
|
||||
|
||||
// looksTransient reports whether err is a transport-level failure
|
||||
// (connection refused, DNS, dial/handshake timeout, connection reset)
|
||||
// worth a couple of quick retries, as opposed to an error vLLM itself
|
||||
// returned after accepting the connection.
|
||||
func looksTransient(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) {
|
||||
return true // covers dial timeout/refused, DNS failure, TLS handshake failure, etc.
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "connection refused") ||
|
||||
strings.Contains(msg, "no such host") ||
|
||||
strings.Contains(msg, "connection reset")
|
||||
}
|
||||
|
||||
// retryDelays bounds how long doWithRetry rides out a transient
|
||||
// failure before giving up: short and few on purpose. This exists to
|
||||
// smooth over a brief reconnect window — e.g. a Recreate rollout's old
|
||||
// pod having just terminated — not to mask a genuinely Pending pod,
|
||||
// which is a scheduling problem no amount of HTTP retrying fixes (see
|
||||
// k8s/scripts/gpu-preflight.sh for that diagnosis).
|
||||
var retryDelays = []time.Duration{200 * time.Millisecond, 600 * time.Millisecond}
|
||||
|
||||
// doWithRetry POSTs buf as JSON to url, retrying only on a transient
|
||||
// transport failure (see looksTransient) — never after a response has
|
||||
// actually been received, since a non-2xx status or a stream already
|
||||
// in progress isn't safe or meaningful to blindly resend.
|
||||
func (c *Client) doWithRetry(ctx context.Context, url string, buf []byte, sse bool) (*http.Response, error) {
|
||||
for attempt := 0; ; attempt++ {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(buf))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if sse {
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
}
|
||||
c.authHeader(req)
|
||||
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
if attempt >= len(retryDelays) || !looksTransient(err) {
|
||||
return nil, err
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(retryDelays[attempt]):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type wireError struct {
|
||||
Message string `json:"message"`
|
||||
Type string `json:"type"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
func (e *wireError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
if e.Type != "" {
|
||||
return fmt.Sprintf("%s: %s", e.Type, e.Message)
|
||||
}
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// errMalformedToolArgs is wrapped into the error ChatStream reports
|
||||
// when vLLM's tool-call parser hands back an "arguments" string that
|
||||
// doesn't parse as JSON. This is this package's own client-side
|
||||
// detection of a broken tool-call round (vLLM never surfaces this as a
|
||||
// distinct HTTP/wire error itself) — see IsToolCallParseError.
|
||||
var errMalformedToolArgs = errors.New("vllm: tool call arguments were not valid JSON")
|
||||
|
||||
// toolCallParseErrPattern matches the family of messages vLLM's
|
||||
// OpenAI-compatible server reports when a configured tool-call parser
|
||||
// (hermes, llama3_json, qwen3, mistral, …) fails to extract a
|
||||
// structured call from the model's raw generation — e.g. "Failed to
|
||||
// parse tool calls", "could not parse function call", "invalid tool
|
||||
// call arguments produced by model". Server-side wording differs by
|
||||
// parser plugin and vLLM version, so this matches on the stable,
|
||||
// shared vocabulary ("tool call" / "function call" alongside "parse"
|
||||
// or "invalid") rather than one exact phrasing.
|
||||
func toolCallParseErrPattern(msg string) bool {
|
||||
msg = strings.ToLower(msg)
|
||||
mentionsCall := strings.Contains(msg, "tool call") || strings.Contains(msg, "tool_call") ||
|
||||
strings.Contains(msg, "function call") || strings.Contains(msg, "function_call")
|
||||
mentionsFailure := strings.Contains(msg, "parse") || strings.Contains(msg, "invalid") || strings.Contains(msg, "malformed")
|
||||
return mentionsCall && mentionsFailure
|
||||
}
|
||||
|
||||
// IsToolCallParseError reports whether err is a transient failure to
|
||||
// extract a structured tool call from the model's output — either
|
||||
// vLLM's own tool-call parser rejecting the raw generation server-side
|
||||
// (see toolCallParseErrPattern), or this client failing to decode the
|
||||
// "arguments" JSON string vLLM did return (errMalformedToolArgs). Both
|
||||
// are almost always a one-off decoding hiccup — something in the
|
||||
// model's raw output didn't match what the configured parser expected
|
||||
// — rather than a reason to give up the whole agent turn. Callers
|
||||
// (agentrun.Agent.Run) use this to retry the round, and if it keeps
|
||||
// recurring, to simplify the most recent tool results before one final
|
||||
// retry.
|
||||
//
|
||||
// This replaces IsTemplateParseError, which detected the equivalent
|
||||
// failure mode under Ollama's native chat-template/XML tool-call
|
||||
// encoding — a protocol vLLM's OpenAI-compatible server doesn't use,
|
||||
// so that detection logic (XML syntax errors, angle-bracket tag
|
||||
// sniffing) no longer applies, but the role it plays in the retry loop
|
||||
// is unchanged.
|
||||
func IsToolCallParseError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, errMalformedToolArgs) {
|
||||
return true
|
||||
}
|
||||
return toolCallParseErrPattern(err.Error())
|
||||
}
|
||||
|
||||
// thinkWire maps a Think setting onto vLLM's chat_template_kwargs
|
||||
// extra body. It returns nil when thinking should be left to the
|
||||
// template's own default (omitting chat_template_kwargs entirely), and
|
||||
// otherwise a map with "enable_thinking" set to true/false.
|
||||
//
|
||||
// override, when non-empty, takes precedence over c.Think for this one
|
||||
// call — see ChatStream's think parameter. This is what lets a caller
|
||||
// (agentrun.Agent's ThinkPolicy) dial reasoning on or off on a
|
||||
// per-round basis — e.g. thinking on for the first, planning round of
|
||||
// an agentic tool-use turn, thinking off for the rounds that just
|
||||
// digest a tool result.
|
||||
func thinkWire(setting, override string) map[string]any {
|
||||
s := override
|
||||
if s == "" {
|
||||
s = setting
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "":
|
||||
return nil
|
||||
case "false", "off", "0", "no":
|
||||
return map[string]any{"enable_thinking": false}
|
||||
default:
|
||||
// "true"/"on"/"1"/"yes" and the effort levels ("low", "medium",
|
||||
// "high", …) all just mean "thinking on" — see Think's doc
|
||||
// comment for why vLLM/Qwen3 has no graded-effort equivalent.
|
||||
return map[string]any{"enable_thinking": true}
|
||||
}
|
||||
}
|
||||
|
||||
// ChatEvent is one item streamed back from ChatStream: either a token of
|
||||
// assistant content, a batch of tool calls the model wants executed, or
|
||||
// the terminal done/error signal.
|
||||
type ChatEvent struct {
|
||||
Token string
|
||||
Thinking string // a chunk of the model's reasoning trace (when thinking is on)
|
||||
ToolCalls []ToolCallRequest
|
||||
Done bool
|
||||
Err error
|
||||
}
|
||||
|
||||
// ChatStream sends the full message history (plus tool definitions) to
|
||||
// the chat vLLM deployment and streams the reply back over the
|
||||
// returned channel. The channel is closed after a Done or Err event.
|
||||
//
|
||||
// think overrides c.Think for this one call when non-empty (see
|
||||
// thinkWire); pass "" to just use the Client's configured default.
|
||||
func (c *Client) ChatStream(ctx context.Context, history []Message, tools []ToolDef, think string) (<-chan ChatEvent, error) {
|
||||
reqBody := chatRequest{
|
||||
Model: c.ChatModel,
|
||||
Messages: toWireMessages(history),
|
||||
Tools: toWireTools(tools),
|
||||
Stream: true,
|
||||
ChatTemplateKwargs: thinkWire(c.Think, think),
|
||||
}
|
||||
reqBody.Temperature = c.ChatOptions.Temperature
|
||||
reqBody.TopP = c.ChatOptions.TopP
|
||||
reqBody.MaxTokens = c.ChatOptions.MaxTokens
|
||||
|
||||
buf, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("llm: marshal chat request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.doWithRetry(ctx, c.ChatBaseURL+"/chat/completions", buf, true)
|
||||
if err != nil {
|
||||
if looksTransient(err) {
|
||||
return nil, fmt.Errorf("llm: chat request failed (is vLLM running at %s?): %w: %w", c.ChatBaseURL, err, ErrUpstreamUnavailable)
|
||||
}
|
||||
return nil, fmt.Errorf("llm: chat request failed (is vLLM running at %s?): %w", c.ChatBaseURL, err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
defer resp.Body.Close()
|
||||
body := readErrorBody(resp)
|
||||
if resp.StatusCode == http.StatusServiceUnavailable {
|
||||
return nil, fmt.Errorf("llm: vllm returned %s: %s: %w", resp.Status, body, ErrUpstreamUnavailable)
|
||||
}
|
||||
return nil, fmt.Errorf("llm: vllm returned %s: %s", resp.Status, body)
|
||||
}
|
||||
|
||||
events := make(chan ChatEvent, 16)
|
||||
|
||||
go func() {
|
||||
defer resp.Body.Close()
|
||||
defer close(events)
|
||||
|
||||
// send delivers an event unless the consumer has gone away (ctx
|
||||
// cancelled), so this goroutine can never block forever on a
|
||||
// channel nobody is reading.
|
||||
send := func(ev ChatEvent) bool {
|
||||
select {
|
||||
case events <- ev:
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
acc := newToolCallAccumulator()
|
||||
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||
|
||||
sawDone := false
|
||||
for scanner.Scan() {
|
||||
line := bytes.TrimSpace(scanner.Bytes())
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
data, ok := bytes.CutPrefix(line, []byte("data:"))
|
||||
if !ok {
|
||||
// Non-"data:" SSE lines (comments, "event:", blank
|
||||
// keep-alives) carry nothing this client needs.
|
||||
continue
|
||||
}
|
||||
data = bytes.TrimSpace(data)
|
||||
if string(data) == "[DONE]" {
|
||||
sawDone = true
|
||||
break
|
||||
}
|
||||
|
||||
var chunk chatStreamChunk
|
||||
if err := json.Unmarshal(data, &chunk); err != nil {
|
||||
send(ChatEvent{Err: fmt.Errorf("llm: decode stream chunk: %w", err)})
|
||||
return
|
||||
}
|
||||
if chunk.Error != nil {
|
||||
send(ChatEvent{Err: fmt.Errorf("llm: vllm error: %w", chunk.Error)})
|
||||
return
|
||||
}
|
||||
if len(chunk.Choices) == 0 {
|
||||
continue
|
||||
}
|
||||
choice := chunk.Choices[0]
|
||||
|
||||
if choice.Delta.ReasoningContent != "" {
|
||||
if !send(ChatEvent{Thinking: choice.Delta.ReasoningContent}) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if choice.Delta.Content != "" {
|
||||
if !send(ChatEvent{Token: choice.Delta.Content}) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(choice.Delta.ToolCalls) > 0 {
|
||||
acc.add(choice.Delta.ToolCalls)
|
||||
}
|
||||
|
||||
if choice.FinishReason != nil {
|
||||
if acc.len() > 0 {
|
||||
calls, err := acc.finish()
|
||||
if err != nil {
|
||||
send(ChatEvent{Err: fmt.Errorf("llm: %w: %w", errMalformedToolArgs, err)})
|
||||
return
|
||||
}
|
||||
if !send(ChatEvent{ToolCalls: calls}) {
|
||||
return
|
||||
}
|
||||
}
|
||||
sawDone = true
|
||||
send(ChatEvent{Done: true})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
send(ChatEvent{Err: fmt.Errorf("llm: reading stream: %w", err)})
|
||||
return
|
||||
}
|
||||
// A [DONE] sentinel with no finish_reason chunk (or EOF with
|
||||
// neither) means vLLM dropped the stream before completing the
|
||||
// reply (crash, OOM-kill, network cut) — surface it rather
|
||||
// than treating a truncated reply as a complete one.
|
||||
if !sawDone && ctx.Err() == nil {
|
||||
send(ChatEvent{Err: fmt.Errorf("llm: vllm closed the stream before finishing the reply")})
|
||||
return
|
||||
}
|
||||
if sawDone {
|
||||
send(ChatEvent{Done: true})
|
||||
}
|
||||
}()
|
||||
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func readErrorBody(resp *http.Response) string {
|
||||
body := make([]byte, 2048)
|
||||
n, _ := resp.Body.Read(body)
|
||||
raw := body[:n]
|
||||
|
||||
var envelope struct {
|
||||
Error wireError `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &envelope); err == nil && envelope.Error.Message != "" {
|
||||
return envelope.Error.Error()
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// toolCallAccumulator: reassembles a streamed, index-keyed sequence of
|
||||
// partial tool_calls deltas into complete calls. The OpenAI streaming
|
||||
// contract sends a tool call's id/name once (on the chunk that first
|
||||
// introduces that index) and its "arguments" JSON string in arbitrary-
|
||||
// length fragments across however many subsequent chunks it takes to
|
||||
// finish generating them — chunk boundaries do not align with JSON
|
||||
// token boundaries at all, so nothing is parseable until finish().
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
type toolCallAccumulator struct {
|
||||
order []int
|
||||
byIdx map[int]*accumulatingCall
|
||||
}
|
||||
|
||||
type accumulatingCall struct {
|
||||
id, name string
|
||||
args strings.Builder
|
||||
}
|
||||
|
||||
func newToolCallAccumulator() *toolCallAccumulator {
|
||||
return &toolCallAccumulator{byIdx: map[int]*accumulatingCall{}}
|
||||
}
|
||||
|
||||
func (a *toolCallAccumulator) len() int { return len(a.byIdx) }
|
||||
|
||||
func (a *toolCallAccumulator) add(deltas []wireToolCall) {
|
||||
for _, d := range deltas {
|
||||
idx := 0
|
||||
if d.Index != nil {
|
||||
idx = *d.Index
|
||||
}
|
||||
call, ok := a.byIdx[idx]
|
||||
if !ok {
|
||||
call = &accumulatingCall{}
|
||||
a.byIdx[idx] = call
|
||||
a.order = append(a.order, idx)
|
||||
}
|
||||
if d.ID != "" {
|
||||
call.id = d.ID
|
||||
}
|
||||
if d.Function.Name != "" {
|
||||
call.name = d.Function.Name
|
||||
}
|
||||
call.args.WriteString(d.Function.Arguments)
|
||||
}
|
||||
}
|
||||
|
||||
// finish parses every accumulated call's argument string as JSON and
|
||||
// returns them in the order their indices first appeared. An empty
|
||||
// arguments string (a tool with no parameters) is treated as "{}",
|
||||
// matching what every vLLM tool-call parser actually emits for a
|
||||
// no-arg call.
|
||||
func (a *toolCallAccumulator) finish() ([]ToolCallRequest, error) {
|
||||
sort.Ints(a.order)
|
||||
out := make([]ToolCallRequest, 0, len(a.order))
|
||||
for _, idx := range a.order {
|
||||
call := a.byIdx[idx]
|
||||
raw := strings.TrimSpace(call.args.String())
|
||||
if raw == "" {
|
||||
raw = "{}"
|
||||
}
|
||||
var args map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &args); err != nil {
|
||||
return nil, fmt.Errorf("tool %q (id %s): %w", call.name, call.id, err)
|
||||
}
|
||||
out = append(out, ToolCallRequest{ID: call.id, Name: call.name, Arguments: args})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func toWireMessages(history []Message) []wireMessage {
|
||||
out := make([]wireMessage, 0, len(history))
|
||||
for _, m := range history {
|
||||
content := m.Content
|
||||
wm := wireMessage{
|
||||
Role: string(m.Role),
|
||||
Content: &content,
|
||||
ToolCallID: m.ToolCallID,
|
||||
Name: m.ToolName,
|
||||
}
|
||||
// An assistant message that only carries tool calls (no reply
|
||||
// text) sends content as null, not "" — some vLLM tool-call
|
||||
// parsers treat an empty-string content as "the model also
|
||||
// said something" and choke on it.
|
||||
if m.Role == RoleAssistant && len(m.ToolCalls) > 0 && m.Content == "" {
|
||||
wm.Content = nil
|
||||
}
|
||||
for _, tc := range m.ToolCalls {
|
||||
args := tc.Arguments
|
||||
if args == nil {
|
||||
args = map[string]any{}
|
||||
}
|
||||
argsJSON, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
// Arguments is always built from values this process
|
||||
// itself constructed (see toolcatalog/executor), so a
|
||||
// marshal failure here would be a programming error,
|
||||
// not a runtime condition — fall back to "{}" rather
|
||||
// than drop the whole request.
|
||||
argsJSON = []byte("{}")
|
||||
}
|
||||
wm.ToolCalls = append(wm.ToolCalls, wireToolCall{
|
||||
ID: tc.ID,
|
||||
Type: "function",
|
||||
Function: wireToolCallFunction{Name: tc.Name, Arguments: string(argsJSON)},
|
||||
})
|
||||
}
|
||||
out = append(out, wm)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func toWireTools(tools []ToolDef) []wireToolDef {
|
||||
out := make([]wireToolDef, 0, len(tools))
|
||||
for _, t := range tools {
|
||||
out = append(out, wireToolDef{
|
||||
Type: "function",
|
||||
Function: wireToolFunction{
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
Parameters: t.Parameters,
|
||||
},
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Embeddings — OpenAI /v1/embeddings
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
type embedRequest struct {
|
||||
Model string `json:"model"`
|
||||
Input string `json:"input"`
|
||||
}
|
||||
|
||||
type embedResponse struct {
|
||||
Data []struct {
|
||||
Embedding []float32 `json:"embedding"`
|
||||
Index int `json:"index"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// Embed returns the embedding vector for a single string, using the
|
||||
// embed vLLM deployment's /v1/embeddings endpoint. Identical (model,
|
||||
// text) pairs are served from an in-memory cache instead of
|
||||
// round-tripping to vLLM again — see the embedCache doc comment on
|
||||
// Client.
|
||||
func (c *Client) Embed(ctx context.Context, text string) ([]float32, error) {
|
||||
if c.embedCache != nil {
|
||||
if v, ok := c.embedCache.get(c.EmbedModel, text); ok {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
|
||||
reqBody := embedRequest{Model: c.EmbedModel, Input: text}
|
||||
buf, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("llm: marshal embed request: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := c.doWithRetry(ctx, c.EmbedBaseURL+"/embeddings", buf, false)
|
||||
if err != nil {
|
||||
if looksTransient(err) {
|
||||
return nil, fmt.Errorf("llm: embed request failed (is vLLM running at %s?): %w: %w", c.EmbedBaseURL, err, ErrUpstreamUnavailable)
|
||||
}
|
||||
return nil, fmt.Errorf("llm: embed request failed (is vLLM running at %s?): %w", c.EmbedBaseURL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body := readErrorBody(resp)
|
||||
if resp.StatusCode == http.StatusServiceUnavailable {
|
||||
return nil, fmt.Errorf("llm: vllm embed returned %s: %s: %w", resp.Status, body, ErrUpstreamUnavailable)
|
||||
}
|
||||
return nil, fmt.Errorf("llm: vllm embed returned %s: %s", resp.Status, body)
|
||||
}
|
||||
|
||||
var parsed embedResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
|
||||
return nil, fmt.Errorf("llm: decode embed response: %w", err)
|
||||
}
|
||||
if len(parsed.Data) == 0 {
|
||||
return nil, fmt.Errorf("llm: embed response contained no vectors")
|
||||
}
|
||||
|
||||
vec := parsed.Data[0].Embedding
|
||||
if c.embedCache != nil {
|
||||
c.embedCache.put(c.EmbedModel, text, vec)
|
||||
}
|
||||
return vec, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// embedCache: small bounded in-memory cache for Embed() results
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
type embedCache struct {
|
||||
mu sync.Mutex
|
||||
cap int
|
||||
order []string // insertion order, for cheap FIFO eviction
|
||||
values map[string][]float32
|
||||
}
|
||||
|
||||
func newEmbedCache(capacity int) *embedCache {
|
||||
return &embedCache{cap: capacity, values: make(map[string][]float32, capacity)}
|
||||
}
|
||||
|
||||
func embedCacheKey(model, text string) string {
|
||||
h := sha256.Sum256([]byte(model + "\x00" + text))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func (c *embedCache) get(model, text string) ([]float32, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
v, ok := c.values[embedCacheKey(model, text)]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func (c *embedCache) put(model, text string, vec []float32) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
key := embedCacheKey(model, text)
|
||||
if _, exists := c.values[key]; !exists {
|
||||
if len(c.order) >= c.cap {
|
||||
// Evict the oldest entry (simple FIFO — good enough for a
|
||||
// latency optimization, not a correctness-critical cache).
|
||||
oldest := c.order[0]
|
||||
c.order = c.order[1:]
|
||||
delete(c.values, oldest)
|
||||
}
|
||||
c.order = append(c.order, key)
|
||||
}
|
||||
c.values[key] = vec
|
||||
}
|
||||
210
pkg/vllmkit/client_test.go
Normal file
210
pkg/vllmkit/client_test.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package vllmkit
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestThinkWire(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
setting, override string
|
||||
wantEnable *bool // nil means "chat_template_kwargs omitted entirely"
|
||||
}{
|
||||
{"both empty", "", "", nil},
|
||||
{"setting only, bool true", "true", "", boolPtr(true)},
|
||||
{"setting only, bool false", "false", "", boolPtr(false)},
|
||||
{"setting only, level", "high", "", boolPtr(true)},
|
||||
{"override wins over setting", "low", "high", boolPtr(true)},
|
||||
{"override alone", "", "medium", boolPtr(true)},
|
||||
{"override explicit false wins", "true", "false", boolPtr(false)},
|
||||
{"case/space insensitive", " TRUE ", "", boolPtr(true)},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := thinkWire(tc.setting, tc.override)
|
||||
if tc.wantEnable == nil {
|
||||
if got != nil {
|
||||
t.Errorf("thinkWire(%q, %q) = %#v, want nil", tc.setting, tc.override, got)
|
||||
}
|
||||
return
|
||||
}
|
||||
gotEnable, ok := got["enable_thinking"].(bool)
|
||||
if !ok || gotEnable != *tc.wantEnable {
|
||||
t.Errorf("thinkWire(%q, %q) = %#v, want enable_thinking=%v", tc.setting, tc.override, got, *tc.wantEnable)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func boolPtr(b bool) *bool { return &b }
|
||||
|
||||
// TestChatStream_ThinkOverride confirms the per-call think parameter
|
||||
// reaches the wire request's chat_template_kwargs.enable_thinking,
|
||||
// taking precedence over the Client's configured default exactly as
|
||||
// thinkWire specifies — this is the knob agentrun.Agent.ThinkPolicy
|
||||
// relies on to vary reasoning on or off by round.
|
||||
func TestChatStream_ThinkOverride(t *testing.T) {
|
||||
var gotKwargs map[string]any
|
||||
sawKwargs := false
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req chatRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
gotKwargs = req.ChatTemplateKwargs
|
||||
sawKwargs = req.ChatTemplateKwargs != nil
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
fmt.Fprintf(w, "data: %s\n\n", mustJSON(finishedChunk()))
|
||||
fmt.Fprintf(w, "data: [DONE]\n\n")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
run := func(clientThink, callThink string) (map[string]any, bool) {
|
||||
c := New(srv.URL, "", "test-model", "")
|
||||
c.Think = clientThink
|
||||
events, err := c.ChatStream(context.Background(), []Message{{Role: RoleUser, Content: "hi"}}, nil, callThink)
|
||||
if err != nil {
|
||||
t.Fatalf("ChatStream: %v", err)
|
||||
}
|
||||
for ev := range events {
|
||||
if ev.Err != nil {
|
||||
t.Fatalf("stream error: %v", ev.Err)
|
||||
}
|
||||
}
|
||||
return gotKwargs, sawKwargs
|
||||
}
|
||||
|
||||
if got, saw := run("", ""); saw || got != nil {
|
||||
t.Errorf("client default empty, no override: got kwargs=%#v saw=%v, want omitted", got, saw)
|
||||
}
|
||||
if got, _ := run("low", ""); got["enable_thinking"] != true {
|
||||
t.Errorf("client default only: got kwargs=%#v, want enable_thinking=true", got)
|
||||
}
|
||||
if got, _ := run("false", "high"); got["enable_thinking"] != true {
|
||||
t.Errorf("override should win: got kwargs=%#v, want enable_thinking=true", got)
|
||||
}
|
||||
if got, _ := run("true", "false"); got["enable_thinking"] != false {
|
||||
t.Errorf("override alone (explicit false): got kwargs=%#v, want enable_thinking=false", got)
|
||||
}
|
||||
}
|
||||
|
||||
func finishedChunk() chatStreamChunk {
|
||||
var c chatStreamChunk
|
||||
c.Choices = make([]struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
ToolCalls []wireToolCall `json:"tool_calls"`
|
||||
} `json:"delta"`
|
||||
FinishReason *string `json:"finish_reason"`
|
||||
}, 1)
|
||||
reason := "stop"
|
||||
c.Choices[0].FinishReason = &reason
|
||||
return c
|
||||
}
|
||||
|
||||
func mustJSON(v any) string {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// TestChatStream_StreamsTokens is a sanity check that the SSE-based
|
||||
// stream reader in ChatStream still behaves with an
|
||||
// bufio.Scanner-friendly response (guards against accidentally
|
||||
// breaking the "data: " framing while touching this file).
|
||||
func TestChatStream_StreamsTokens(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
bw := bufio.NewWriter(w)
|
||||
|
||||
write := func(content string, done bool) {
|
||||
var c chatStreamChunk
|
||||
c.Choices = make([]struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
ToolCalls []wireToolCall `json:"tool_calls"`
|
||||
} `json:"delta"`
|
||||
FinishReason *string `json:"finish_reason"`
|
||||
}, 1)
|
||||
c.Choices[0].Delta.Content = content
|
||||
if done {
|
||||
reason := "stop"
|
||||
c.Choices[0].FinishReason = &reason
|
||||
}
|
||||
fmt.Fprintf(bw, "data: %s\n\n", mustJSON(c))
|
||||
}
|
||||
write("hel", false)
|
||||
write("lo", true)
|
||||
fmt.Fprint(bw, "data: [DONE]\n\n")
|
||||
bw.Flush()
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL, "", "test-model", "")
|
||||
events, err := c.ChatStream(context.Background(), []Message{{Role: RoleUser, Content: "hi"}}, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ChatStream: %v", err)
|
||||
}
|
||||
var got string
|
||||
for ev := range events {
|
||||
if ev.Err != nil {
|
||||
t.Fatalf("stream error: %v", ev.Err)
|
||||
}
|
||||
got += ev.Token
|
||||
}
|
||||
if got != "hello" {
|
||||
t.Errorf("got tokens %q, want %q", got, "hello")
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolCallAccumulator_Streamed confirms incremental, index-keyed
|
||||
// tool_calls deltas (arguments split across several chunks, as a real
|
||||
// vLLM server streams them) reassemble into one complete call.
|
||||
func TestToolCallAccumulator_Streamed(t *testing.T) {
|
||||
idx := 0
|
||||
acc := newToolCallAccumulator()
|
||||
acc.add([]wireToolCall{{Index: &idx, ID: "call_1", Function: wireToolCallFunction{Name: "grep"}}})
|
||||
acc.add([]wireToolCall{{Index: &idx, Function: wireToolCallFunction{Arguments: `{"pat`}}})
|
||||
acc.add([]wireToolCall{{Index: &idx, Function: wireToolCallFunction{Arguments: `tern":"foo"}`}}})
|
||||
|
||||
calls, err := acc.finish()
|
||||
if err != nil {
|
||||
t.Fatalf("finish: %v", err)
|
||||
}
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("got %d calls, want 1", len(calls))
|
||||
}
|
||||
if calls[0].ID != "call_1" || calls[0].Name != "grep" {
|
||||
t.Errorf("got %+v, want id=call_1 name=grep", calls[0])
|
||||
}
|
||||
if calls[0].Arguments["pattern"] != "foo" {
|
||||
t.Errorf("got arguments %#v, want pattern=foo", calls[0].Arguments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsToolCallParseError(t *testing.T) {
|
||||
if !IsToolCallParseError(fmt.Errorf("wrap: %w", errMalformedToolArgs)) {
|
||||
t.Error("wrapped errMalformedToolArgs should be detected")
|
||||
}
|
||||
if !IsToolCallParseError(fmt.Errorf("llm: vllm error: BadRequestError: Failed to parse tool calls from model output")) {
|
||||
t.Error("server-side tool-call parse failure should be detected")
|
||||
}
|
||||
if IsToolCallParseError(fmt.Errorf("llm: vllm returned 500 Internal Server Error: out of memory")) {
|
||||
t.Error("unrelated server error should not be classified as a tool-call parse error")
|
||||
}
|
||||
if IsToolCallParseError(nil) {
|
||||
t.Error("nil error should never be a tool-call parse error")
|
||||
}
|
||||
}
|
||||
73
pkg/vllmkit/types.go
Normal file
73
pkg/vllmkit/types.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package vllmkit
|
||||
|
||||
// Role, Message, ToolCallRequest and ToolDef are local equivalents of
|
||||
// the monolith's internal/models types — kept here, scoped to exactly
|
||||
// what the OpenAI-compatible chat-completions wire protocol vLLM
|
||||
// serves needs, so this package has no dependency beyond the Go
|
||||
// standard library. The agent service (the only caller of ChatStream)
|
||||
// translates its own domain messages to and from these at the
|
||||
// boundary; see services/agent/internal/infrastructure/agentrun.
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleUser Role = "user"
|
||||
RoleAssistant Role = "assistant"
|
||||
RoleSystem Role = "system"
|
||||
RoleTool Role = "tool"
|
||||
)
|
||||
|
||||
// Message is one turn in a conversation, as sent to/received from
|
||||
// vLLM's OpenAI-compatible /v1/chat/completions endpoint.
|
||||
type Message struct {
|
||||
Role Role
|
||||
Content string
|
||||
ToolName string
|
||||
Thinking string // reasoning trace (OpenAI wire: "reasoning_content"); sent back to the model, never persisted
|
||||
|
||||
// ToolCalls is set on an assistant message that requested one or
|
||||
// more tool invocations — see ToolCallRequest.
|
||||
ToolCalls []ToolCallRequest
|
||||
|
||||
// ToolCallID identifies, on a role="tool" message, which of the
|
||||
// preceding assistant message's ToolCalls this is the result for.
|
||||
// The OpenAI tool-calling contract requires every tool message to
|
||||
// carry the matching call's id (unlike Ollama's native /api/chat,
|
||||
// which correlated tool replies positionally with no id at all) —
|
||||
// vLLM's tool-call parsers (hermes, llama3-json, qwen, …) reject a
|
||||
// tool message with a missing/mismatched tool_call_id outright, so
|
||||
// this must be populated whenever Role == RoleTool. Callers get it
|
||||
// for free: ToolCallRequest.ID below is populated straight off the
|
||||
// id vLLM assigned the call, and ToolResult carries it forward
|
||||
// unchanged — see agentrun.Agent.Run, which copies
|
||||
// ToolCallRequest.ID into the ToolResult it builds and then into
|
||||
// this field when it appends the reply message.
|
||||
ToolCallID string
|
||||
}
|
||||
|
||||
// ToolCallRequest is a single tool invocation the model asked for. ID
|
||||
// is the id vLLM's response assigned the call (OpenAI wire:
|
||||
// tool_calls[].id) — callers must echo it back as ToolResult's
|
||||
// ToolCallID so the follow-up tool message can carry Message.ToolCallID.
|
||||
type ToolCallRequest struct {
|
||||
ID string
|
||||
Name string
|
||||
Arguments map[string]any
|
||||
}
|
||||
|
||||
// ToolResult is what gets fed back to the model after executing a
|
||||
// ToolCallRequest.
|
||||
type ToolResult struct {
|
||||
ToolCallID string
|
||||
Name string
|
||||
Content string
|
||||
IsError bool
|
||||
}
|
||||
|
||||
// ToolDef describes a single callable tool in the JSON-schema shape
|
||||
// the OpenAI-compatible function-calling API (and therefore vLLM)
|
||||
// expects.
|
||||
type ToolDef struct {
|
||||
Name string
|
||||
Description string
|
||||
Parameters map[string]any // JSON Schema object
|
||||
}
|
||||
Reference in New Issue
Block a user