Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ To keep knowing and doing unified, every agent follows this cycle:
- **Requests are multi-action.** One request may run several intents — e.g. reverse a wrong entry then re-post the correction. The model marks its last action `final: true`; the loop (`FinalIntent`/`IsFinal`) executes it and stops. All entry points are multi-action because `bookkeeping.Intent` implements `IsFinal`, so a lone post is one final action (one turn). `MaxTurns` (default 8) bounds turns; `maxPostsPerRequest` caps postings against a runaway loop. The request commits **all-or-nothing**: the agent runs the loop against a `bookkeeping.Staging` view that buffers events (and overlays staged entries on its reads so a later action sees them); a clean run flushes to the bus, any failure — including a partial run that hits `MaxTurns` — aborts and leaves the ledger untouched. The only residual gap is the flush itself (JetStream has no atomic multi-publish).
- **Cross-turn recall is retrieval, not accumulation.** A TUI session carries a bounded `RecentEntries` (last N posted entries); the agent self-decides when it needs context — a self-contained request (date + amounts + description) acts directly, a referential one ("redo that entry") calls the `recent_entries`/`get_entry` tools to recover detail from the ledger. The transcript is never replayed into the prompt, so prompt growth is O(N) regardless of conversation length. One-shot `book-run`/`bench` carry no memory and omit the recall tools and guidance. The agent must never invent a missing amount — unresolvable references `reject`.
- **Account search is hybrid.** `find_accounts` takes a natural-language description, not a name substring. A dense channel (cosine over `AccountEmbeddingText` = name + `Description` + `Aliases`, code excluded) fuses with a lexical channel (exact code/name/substring, `LexicalAccountTier`) by reciprocal rank fusion (`FuseAccountsRRF`, k=60); a query naming neither degrades to dense. `Aliases`/`Description` are seed-time hints baked into the embedding (re-embed = re-seed). See [docs/architecture.md](docs/architecture.md).
- **Counterparty lookup is lexical.** `Counterparty` (customer/supplier) is event-sourced master data — projected from `CounterpartyAdded` by `ApplyCounterparty` — but **not** seed reference data: it is operational, not setup, so `Scenario`/`ledger seed` do not carry it (the operator create path lands in a later phase). `find_counterparties` resolves a name, alias, or tax id to its `CP-id`. Unlike `find_accounts` it ranks in the agent over `repo.Counterparties()` (`CounterpartyMatch` tiers: exact id/name/tax-id/alias, then substring) rather than a hybrid index — counterparties are directly-named entities of low cardinality, so semantic ranking adds little over alias coverage. Posting-time reference (a `counterparty_id` line dimension, invoice/receipt `SourceDoc`) is a later phase too.
- **Company policy is event-sourced judgment, kept out of seed.** `ledger policy set/edit/get` publishes `PolicySet` (subject `accounting.company.policy`); `ApplyPolicy` projects it to a `policy` column via `SetPolicy`, and `PromptRenderer` injects it verbatim into the agent prompt. It is operator-authored free-text (markdown convention) — sparse, high-consequence account-disambiguation rules (judgment), distinct from account `Description`/`Aliases` (retrieval-only facts in the embedding). It has its own write path, so `SetCompany`/re-seed never clobbers it (`UpsertCompany` omits the column; `Company.Policy` is `yaml:"-"`).

## Code Style
Expand Down
8 changes: 5 additions & 3 deletions agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,13 @@ func (a Bookkeeper) Book(ctx context.Context, request string) (Result, error) {
}, err
}

// toolsFor exposes find_accounts plus, when a recent-entries buffer is present,
// the recall tools (recent_entries, get_entry). repo is the staging view so
// get_entry also resolves staged-but-uncommitted entries.
// toolsFor exposes find_accounts and find_counterparties plus, when a
// recent-entries buffer is present, the recall tools (recent_entries,
// get_entry). repo is the staging view so get_entry also resolves
// staged-but-uncommitted entries.
func (a Bookkeeper) toolsFor(repo accounting.LedgerRepository) map[string]loop.Tool {
tools := accountTools(repo)
maps.Copy(tools, counterpartyTools(repo))
if a.Recent != nil {
maps.Copy(tools, recallTools(repo, a.Recent))
}
Expand Down
130 changes: 130 additions & 0 deletions agent/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"

"github.com/flarexio/accounting"
Expand Down Expand Up @@ -51,6 +52,44 @@ func accountTools(repo accounting.LedgerRepository) map[string]loop.Tool {
}
}

const toolFindCounterparties = "find_counterparties"

type findCounterpartiesArgs struct {
Query string `json:"query"`
Kind string `json:"kind"`
}

// findCounterpartiesArgsSchema is the strict-mode schema for find_counterparties.
// Both args are required; kind may be the empty string to skip the filter.
const findCounterpartiesArgsSchema = `{
"type": "object",
"additionalProperties": false,
"required": ["query", "kind"],
"properties": {
"query": { "type": "string", "description": "Name, alias, or tax id of the customer or supplier, e.g. \"TSMC\" or \"22099131\"." },
"kind": {
"type": "string",
"description": "Restrict to customers or suppliers; empty string means either.",
"enum": ["", "customer", "supplier"]
}
}
}`

// counterpartyTools returns the find_counterparties tool: it resolves a
// customer/supplier the user named to its CP-id so a posting can reference it.
func counterpartyTools(repo accounting.LedgerRepository) map[string]loop.Tool {
return map[string]loop.Tool{
toolFindCounterparties: {
Spec: llm.ToolSpec{
Name: toolFindCounterparties,
Description: "Look up a customer or supplier by name, alias, or tax id. Active matches are listed best match first; inactive ones are listed separately and must not be referenced by a new posting.",
ArgsSchema: json.RawMessage(findCounterpartiesArgsSchema),
},
Handler: findCounterpartiesHandler(repo),
},
}
}

const (
toolRecentEntries = "recent_entries"
toolGetEntry = "get_entry"
Expand Down Expand Up @@ -179,6 +218,97 @@ func findAccountsHandler(repo accounting.LedgerRepository) loop.ToolHandler {
}
}

// findCounterpartiesHandler answers a find_counterparties call by lexically
// ranking the chart of counterparties. The list is small, so it loads all and
// ranks in memory rather than pushing the search to the adapter.
func findCounterpartiesHandler(repo accounting.LedgerRepository) loop.ToolHandler {
return func(ctx context.Context, args json.RawMessage) (string, error) {
var p findCounterpartiesArgs
if len(args) > 0 {
if err := json.Unmarshal(args, &p); err != nil {
return "", fmt.Errorf("invalid find_counterparties args: %w", err)
}
}
all, err := repo.Counterparties(ctx)
if err != nil {
return "", err
}
kind := accounting.CounterpartyKind(strings.TrimSpace(p.Kind))
type scored struct {
cp accounting.Counterparty
tier int
}
var matched []scored
for _, c := range all {
if !counterpartyKindMatches(kind, c.Kind) {
continue
}
if tier, ok := accounting.CounterpartyMatch(p.Query, c); ok {
matched = append(matched, scored{c, tier})
}
}
sort.SliceStable(matched, func(i, j int) bool {
if matched[i].tier != matched[j].tier {
return matched[i].tier < matched[j].tier
}
return matched[i].cp.ID < matched[j].cp.ID
})
out := make([]accounting.Counterparty, len(matched))
for i, m := range matched {
out[i] = m.cp
}
return formatCounterpartyMatches(out), nil
}
}

// counterpartyKindMatches reports whether a counterparty of kind cp satisfies a
// query filter; an empty filter matches anything and "both" matches either side.
func counterpartyKindMatches(filter, cp accounting.CounterpartyKind) bool {
if filter == "" || cp == accounting.CounterpartyBoth {
return true
}
return filter == cp
}

// formatCounterpartyMatches renders active matches as the referenceable
// candidates and lists any inactive ones separately as disabled.
func formatCounterpartyMatches(cps []accounting.Counterparty) string {
var active, inactive []accounting.Counterparty
for _, c := range cps {
if c.Active {
active = append(active, c)
} else {
inactive = append(inactive, c)
}
}
if len(active) == 0 && len(inactive) == 0 {
return "No counterparties match. Try the name, an alias, or the tax id."
}
var b strings.Builder
if len(active) > 0 {
fmt.Fprintf(&b, "%d matching counterparty(ies):", len(active))
for _, c := range active {
writeCounterparty(&b, c)
}
} else {
b.WriteString("No active counterparties match.")
}
if len(inactive) > 0 {
fmt.Fprintf(&b, "\n\n%d inactive counterparty(ies) matched -- disabled, must not be referenced in a posting:", len(inactive))
for _, c := range inactive {
writeCounterparty(&b, c)
}
}
return b.String()
}

func writeCounterparty(b *strings.Builder, c accounting.Counterparty) {
fmt.Fprintf(b, "\n - %s %s (%s)", c.ID, c.Name, c.Kind)
if c.TaxID != "" {
fmt.Fprintf(b, " TaxID %s", c.TaxID)
}
}

// formatAccountMatches renders the active matches as the postable candidates
// and lists any inactive matches separately as disabled, so the model can tell
// a disabled account apart from a missing one.
Expand Down
48 changes: 48 additions & 0 deletions agent/tools_internal_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package agent

import (
"context"
"encoding/json"
"strings"
"testing"

"github.com/flarexio/accounting"
"github.com/flarexio/accounting/persistence/memory"
)

func TestFormatAccountMatches(t *testing.T) {
Expand Down Expand Up @@ -40,3 +43,48 @@ func TestFormatAccountMatches(t *testing.T) {
}
})
}

func TestFindCounterpartiesHandler(t *testing.T) {
ctx := context.Background()
repo := memory.NewAccountingRepository()
for _, c := range []accounting.Counterparty{
{ID: "CP-0001", Name: "台積電", Kind: accounting.CounterpartyCustomer, TaxID: "22099131", Active: true, Aliases: []string{"TSMC"}},
{ID: "CP-0002", Name: "中華電信", Kind: accounting.CounterpartySupplier, Active: true},
{ID: "CP-0099", Name: "舊廠商", Kind: accounting.CounterpartySupplier, Active: false},
} {
if err := repo.PutCounterparty(ctx, c); err != nil {
t.Fatalf("seed counterparty: %v", err)
}
}
handle := findCounterpartiesHandler(repo)

t.Run("resolves an alias and shows the tax id", func(t *testing.T) {
out, err := handle(ctx, json.RawMessage(`{"query":"TSMC","kind":""}`))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, "CP-0001 台積電") || !strings.Contains(out, "22099131") {
t.Errorf("expected the TSMC match with its tax id:\n%s", out)
}
})

t.Run("kind filter excludes the other side", func(t *testing.T) {
out, err := handle(ctx, json.RawMessage(`{"query":"中華電信","kind":"customer"}`))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, "No counterparties match") {
t.Errorf("a supplier should not match a customer filter:\n%s", out)
}
})

t.Run("inactive match is flagged disabled, not referenceable", func(t *testing.T) {
out, err := handle(ctx, json.RawMessage(`{"query":"舊廠商","kind":""}`))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, "disabled") || !strings.Contains(out, "CP-0099") {
t.Errorf("inactive counterparty should be flagged disabled:\n%s", out)
}
})
}
14 changes: 14 additions & 0 deletions bookkeeping/applyrefdata.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,20 @@ func (h *ApplyBranch) Handle(ctx context.Context, evt Event) error {
return h.Repo.PutBranch(ctx, e.Branch)
}

// ApplyCounterparty projects accounting.CounterpartyAdded by upserting the customer/supplier.
type ApplyCounterparty struct {
Repo accounting.LedgerRepository
}

// Handle implements bookkeeping.EventHandler.
func (h *ApplyCounterparty) Handle(ctx context.Context, evt Event) error {
e, ok := evt.(accounting.CounterpartyAdded)
if !ok {
return fmt.Errorf("bookkeeping: ApplyCounterparty received %T on subject %q, want CounterpartyAdded", evt, evt.EventSubject())
}
return h.Repo.PutCounterparty(ctx, e.Counterparty)
}

// ApplyPeriod projects accounting.PeriodAdded by upserting the accounting period.
type ApplyPeriod struct {
Repo accounting.LedgerRepository
Expand Down
23 changes: 23 additions & 0 deletions bookkeeping/seedscenario_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,26 @@ func TestSeedScenario_ProjectsEveryEntityViaEvents(t *testing.T) {
t.Fatalf("period not projected: %+v", periods)
}
}

// Counterparties are not seeded reference data; they are created by publishing
// CounterpartyAdded, which ApplyCounterparty projects.
func TestApplyCounterparty_Projects(t *testing.T) {
ctx := context.Background()
repo := memory.NewAccountingRepository()
bus := inproc.NewAccountingBus()
router := bookkeeping.NewRouter().
On(accounting.SubjectCounterpartyAdded, &bookkeeping.ApplyCounterparty{Repo: repo})
if err := bus.Subscribe(router); err != nil {
t.Fatalf("subscribe: %v", err)
}

cp := accounting.Counterparty{ID: "CP-0001", Name: "TSMC", Kind: accounting.CounterpartyCustomer, Active: true}
if err := bus.Publish(ctx, accounting.CounterpartyAdded{Counterparty: cp}, accounting.ExpectedSequence{}); err != nil {
t.Fatalf("publish: %v", err)
}

got, ok, err := repo.Counterparty(ctx, "CP-0001")
if err != nil || !ok || got.Name != "TSMC" {
t.Fatalf("counterparty not projected: ok=%v err=%v %+v", ok, err, got)
}
}
3 changes: 2 additions & 1 deletion cmd/ledger/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ func buildMessaging(ctx context.Context, cfg config.Messaging, repo accounting.L
On(accounting.SubjectPolicySet, &bookkeeping.ApplyPolicy{Repo: repo}).
On(accounting.SubjectAccountAdded, &bookkeeping.ApplyAccount{Repo: repo}).
On(accounting.SubjectBranchAdded, &bookkeeping.ApplyBranch{Repo: repo}).
On(accounting.SubjectPeriodAdded, &bookkeeping.ApplyPeriod{Repo: repo})
On(accounting.SubjectPeriodAdded, &bookkeeping.ApplyPeriod{Repo: repo}).
On(accounting.SubjectCounterpartyAdded, &bookkeeping.ApplyCounterparty{Repo: repo})
if err := bus.Subscribe(router); err != nil {
_ = bus.Close()
return nil, fmt.Errorf("book-run: subscribe: %w", err)
Expand Down
67 changes: 67 additions & 0 deletions counterparty.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package accounting

import (
"fmt"
"strings"
)

// CounterpartyKind classifies a Counterparty as a customer, a supplier, or both.
type CounterpartyKind string

const (
CounterpartyCustomer CounterpartyKind = "customer"
CounterpartySupplier CounterpartyKind = "supplier"
CounterpartyBoth CounterpartyKind = "both"
)

// Counterparty is a customer or supplier the ledger transacts with. ID is
// producer-assigned (CP-0001); TaxID is the Taiwan 統一編號. Inactive
// counterparties cannot be referenced by new postings. Aliases enrich lexical
// lookup and carry no posting invariant.
type Counterparty struct {
ID string `json:"id" yaml:"id"`
Name string `json:"name" yaml:"name"`
Kind CounterpartyKind `json:"kind" yaml:"kind"`
TaxID string `json:"tax_id,omitempty" yaml:"tax_id,omitempty"`
Active bool `json:"active" yaml:"active"`
Aliases []string `json:"aliases,omitempty" yaml:"aliases,omitempty"`
Description string `json:"description,omitempty" yaml:"description,omitempty"`
}

// FormatCounterpartyID formats a per-subject counter into the canonical Counterparty.ID.
func FormatCounterpartyID(seq uint64) string {
return fmt.Sprintf("CP-%04d", seq)
}

// CounterpartyMatch scores how exactly query identifies c for lexical lookup; a
// lower tier is a stronger match and ok is false when nothing relates. Matching
// is case-insensitive.
func CounterpartyMatch(query string, c Counterparty) (tier int, ok bool) {
q := strings.ToLower(strings.TrimSpace(query))
if q == "" {
return 0, false
}
switch {
case q == strings.ToLower(c.ID):
return 0, true
case q == strings.ToLower(c.Name):
return 1, true
case q == strings.ToLower(c.TaxID):
return 2, true
}
for _, a := range c.Aliases {
if q == strings.ToLower(a) {
return 3, true
}
}
n := strings.ToLower(c.Name)
if strings.Contains(n, q) || strings.Contains(q, n) {
return 4, true
}
for _, a := range c.Aliases {
if al := strings.ToLower(a); strings.Contains(al, q) || strings.Contains(q, al) {
return 5, true
}
}
return 0, false
}
Loading