lambda-erp 0.8.30__tar.gz → 0.8.31__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/PKG-INFO +10 -10
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/README.md +9 -8
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/chat.py +93 -63
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/demo_limits.py +1 -1
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/providers.py +20 -41
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/routers/admin.py +2 -2
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/routers/analytics.py +138 -6
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/pyproject.toml +1 -2
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/terraform/README.md +11 -14
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/.gitignore +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/LICENSE +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/__init__.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/attachments.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/auth.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/bootstrap.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/deps.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/errors.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/list_values.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/main.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/oauth.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/pdf.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/remarks_md.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/routers/__init__.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/routers/accounting.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/routers/actions.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/routers/availability.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/routers/bank_statements.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/routers/chat_api.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/routers/documents.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/routers/masters.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/routers/mcp.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/routers/proposals.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/routers/reports.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/routers/setup.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/services.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/templates/document.html +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/templates/proposal.html +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/tool_permissions.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/docs/agents/README.md +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/frontend/LICENSE +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/frontend/README.md +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/frontend/src/api/client.ts +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/__init__.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/bank_account.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/bank_reconciliation.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/bank_statement_import.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/camt.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/assets/__init__.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/assets/asset.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/assets/reservation.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/database.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/hooks.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/model.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/simulation.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/utils.py +0 -0
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lambda-erp
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.31
|
|
4
4
|
Summary: Core ERP logic - accounting, sales, purchasing, inventory
|
|
5
5
|
Author: TORUS INVESTMENTS AG
|
|
6
6
|
License-Expression: Apache-2.0
|
|
7
7
|
License-File: LICENSE
|
|
8
8
|
Requires-Python: >=3.10
|
|
9
|
-
Requires-Dist: anthropic>=0.40
|
|
10
9
|
Requires-Dist: bcrypt>=5
|
|
11
10
|
Requires-Dist: defusedxml>=0.7
|
|
12
11
|
Requires-Dist: fastapi>=0.115
|
|
@@ -96,8 +95,8 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
|
|
|
96
95
|
│ - GPT-5.6 drives the reasoning loop │
|
|
97
96
|
│ - Tool-use: document CRUD, search, │
|
|
98
97
|
│ reports, aggregations, analytics │
|
|
99
|
-
│ - Delegates report specs to
|
|
100
|
-
│
|
|
98
|
+
│ - Delegates report specs to a separate │
|
|
99
|
+
│ GPT-5.6 Terra specialist call │
|
|
101
100
|
└──────────────────┬───────────────────────┘
|
|
102
101
|
│
|
|
103
102
|
▼
|
|
@@ -133,7 +132,7 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
|
|
|
133
132
|
| Frontend | React + Vite + TypeScript + Tailwind + Recharts |
|
|
134
133
|
| Chat transport | WebSocket |
|
|
135
134
|
| LLM orchestrator | OpenAI (configurable) |
|
|
136
|
-
|
|
|
135
|
+
| Report specialist | OpenAI GPT-5.6 Terra (configurable) |
|
|
137
136
|
| Auth | JWT httponly cookie, three roles + demo |
|
|
138
137
|
|
|
139
138
|
---
|
|
@@ -217,7 +216,7 @@ You need Docker and Compose v2. The canonical installs:
|
|
|
217
216
|
Then, from the repo root:
|
|
218
217
|
|
|
219
218
|
```bash
|
|
220
|
-
cp .env.example .env # add your OPENAI_API_KEY
|
|
219
|
+
cp .env.example .env # add your OPENAI_API_KEY
|
|
221
220
|
docker compose up --build
|
|
222
221
|
```
|
|
223
222
|
|
|
@@ -262,14 +261,15 @@ Open `http://localhost:5173`. Vite proxies `/api/*` to the backend.
|
|
|
262
261
|
|
|
263
262
|
```
|
|
264
263
|
OPENAI_API_KEY=sk-...
|
|
265
|
-
|
|
266
|
-
ANTHROPIC_CODE_MODEL=claude-opus-4-7 # optional, default shown
|
|
264
|
+
LAMBDA_ERP_REPORT_MODEL=gpt-5.6-terra # optional specialist override; default shown
|
|
267
265
|
LAMBDA_ERP_ADMIN_EMAIL=admin@example.com # optional, seeds the admin at boot
|
|
268
266
|
LAMBDA_ERP_ADMIN_PASSWORD=... # optional, required with the line above
|
|
269
267
|
LAMBDA_ERP_ADMIN_NAME=Administrator # optional, display name for the seeded admin
|
|
270
268
|
```
|
|
271
269
|
|
|
272
|
-
Chat
|
|
270
|
+
Chat and custom-report specification generation share `OPENAI_API_KEY`. Both
|
|
271
|
+
the orchestrator and report specialist default to GPT-5.6 Terra; the specialist
|
|
272
|
+
can be overridden with `LAMBDA_ERP_REPORT_MODEL`.
|
|
273
273
|
|
|
274
274
|
**Seeded admin.** By default the first person to register becomes the admin. Set `LAMBDA_ERP_ADMIN_EMAIL` + `LAMBDA_ERP_ADMIN_PASSWORD` to instead provision that admin automatically at startup — useful when the database is recreated on every deploy, so a redeployed instance can't be claimed by whoever visits first. It's create-if-missing and idempotent (an existing account with that email is promoted to an enabled admin; its password is left untouched), and the password is only ever read from the environment.
|
|
275
275
|
|
|
@@ -487,4 +487,4 @@ Release notes live in [CHANGELOG.md](./CHANGELOG.md). Releases are tagged
|
|
|
487
487
|
|
|
488
488
|
## Trademarks and affiliations
|
|
489
489
|
|
|
490
|
-
Lambda ERP and [lambda.dev](https://lambda.dev/) are product and trade names of **TORUS INVESTMENTS AG**. It is not affiliated with, endorsed by, or sponsored by OpenAI, Anthropic, SAP, Oracle, Microsoft, or any other company named in this repository. SAP, Business One, S/4HANA, Oracle, NetSuite, Microsoft, Dynamics, OpenAI, GPT, Anthropic, and Claude are trademarks of their respective owners and are referenced here only for descriptive and comparative purposes (nominative fair use).
|
|
490
|
+
Lambda ERP and [lambda.dev](https://lambda.dev/) are product and trade names of **TORUS INVESTMENTS AG**. It is not affiliated with, endorsed by, or sponsored by OpenAI, Anthropic, SAP, Oracle, Microsoft, or any other company named in this repository. SAP, Business One, S/4HANA, Oracle, NetSuite, Microsoft, Dynamics, OpenAI, GPT, Anthropic, and Claude are trademarks of their respective owners and are referenced here only for descriptive and comparative purposes (nominative fair use). The ERP uses the OpenAI API as a customer; you supply your own API key.
|
|
@@ -67,8 +67,8 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
|
|
|
67
67
|
│ - GPT-5.6 drives the reasoning loop │
|
|
68
68
|
│ - Tool-use: document CRUD, search, │
|
|
69
69
|
│ reports, aggregations, analytics │
|
|
70
|
-
│ - Delegates report specs to
|
|
71
|
-
│
|
|
70
|
+
│ - Delegates report specs to a separate │
|
|
71
|
+
│ GPT-5.6 Terra specialist call │
|
|
72
72
|
└──────────────────┬───────────────────────┘
|
|
73
73
|
│
|
|
74
74
|
▼
|
|
@@ -104,7 +104,7 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
|
|
|
104
104
|
| Frontend | React + Vite + TypeScript + Tailwind + Recharts |
|
|
105
105
|
| Chat transport | WebSocket |
|
|
106
106
|
| LLM orchestrator | OpenAI (configurable) |
|
|
107
|
-
|
|
|
107
|
+
| Report specialist | OpenAI GPT-5.6 Terra (configurable) |
|
|
108
108
|
| Auth | JWT httponly cookie, three roles + demo |
|
|
109
109
|
|
|
110
110
|
---
|
|
@@ -188,7 +188,7 @@ You need Docker and Compose v2. The canonical installs:
|
|
|
188
188
|
Then, from the repo root:
|
|
189
189
|
|
|
190
190
|
```bash
|
|
191
|
-
cp .env.example .env # add your OPENAI_API_KEY
|
|
191
|
+
cp .env.example .env # add your OPENAI_API_KEY
|
|
192
192
|
docker compose up --build
|
|
193
193
|
```
|
|
194
194
|
|
|
@@ -233,14 +233,15 @@ Open `http://localhost:5173`. Vite proxies `/api/*` to the backend.
|
|
|
233
233
|
|
|
234
234
|
```
|
|
235
235
|
OPENAI_API_KEY=sk-...
|
|
236
|
-
|
|
237
|
-
ANTHROPIC_CODE_MODEL=claude-opus-4-7 # optional, default shown
|
|
236
|
+
LAMBDA_ERP_REPORT_MODEL=gpt-5.6-terra # optional specialist override; default shown
|
|
238
237
|
LAMBDA_ERP_ADMIN_EMAIL=admin@example.com # optional, seeds the admin at boot
|
|
239
238
|
LAMBDA_ERP_ADMIN_PASSWORD=... # optional, required with the line above
|
|
240
239
|
LAMBDA_ERP_ADMIN_NAME=Administrator # optional, display name for the seeded admin
|
|
241
240
|
```
|
|
242
241
|
|
|
243
|
-
Chat
|
|
242
|
+
Chat and custom-report specification generation share `OPENAI_API_KEY`. Both
|
|
243
|
+
the orchestrator and report specialist default to GPT-5.6 Terra; the specialist
|
|
244
|
+
can be overridden with `LAMBDA_ERP_REPORT_MODEL`.
|
|
244
245
|
|
|
245
246
|
**Seeded admin.** By default the first person to register becomes the admin. Set `LAMBDA_ERP_ADMIN_EMAIL` + `LAMBDA_ERP_ADMIN_PASSWORD` to instead provision that admin automatically at startup — useful when the database is recreated on every deploy, so a redeployed instance can't be claimed by whoever visits first. It's create-if-missing and idempotent (an existing account with that email is promoted to an enabled admin; its password is left untouched), and the password is only ever read from the environment.
|
|
246
247
|
|
|
@@ -458,4 +459,4 @@ Release notes live in [CHANGELOG.md](./CHANGELOG.md). Releases are tagged
|
|
|
458
459
|
|
|
459
460
|
## Trademarks and affiliations
|
|
460
461
|
|
|
461
|
-
Lambda ERP and [lambda.dev](https://lambda.dev/) are product and trade names of **TORUS INVESTMENTS AG**. It is not affiliated with, endorsed by, or sponsored by OpenAI, Anthropic, SAP, Oracle, Microsoft, or any other company named in this repository. SAP, Business One, S/4HANA, Oracle, NetSuite, Microsoft, Dynamics, OpenAI, GPT, Anthropic, and Claude are trademarks of their respective owners and are referenced here only for descriptive and comparative purposes (nominative fair use).
|
|
462
|
+
Lambda ERP and [lambda.dev](https://lambda.dev/) are product and trade names of **TORUS INVESTMENTS AG**. It is not affiliated with, endorsed by, or sponsored by OpenAI, Anthropic, SAP, Oracle, Microsoft, or any other company named in this repository. SAP, Business One, S/4HANA, Oracle, NetSuite, Microsoft, Dynamics, OpenAI, GPT, Anthropic, and Claude are trademarks of their respective owners and are referenced here only for descriptive and comparative purposes (nominative fair use). The ERP uses the OpenAI API as a customer; you supply your own API key.
|
|
@@ -35,7 +35,7 @@ from api.demo_limits import (
|
|
|
35
35
|
is_demo_role,
|
|
36
36
|
limiter as demo_limiter,
|
|
37
37
|
)
|
|
38
|
-
from api.providers import
|
|
38
|
+
from api.providers import cost_of_openai_call, cost_of_transcription
|
|
39
39
|
from api.routers.masters import create_master_record, update_master_record, delete_master_record, MASTER_IDENTITY_ALIAS
|
|
40
40
|
from lambda_erp.database import get_db
|
|
41
41
|
from lambda_erp.utils import flt, now, nowdate
|
|
@@ -985,7 +985,11 @@ TOOLS = [
|
|
|
985
985
|
"- `ap_open_items` — outstanding purchase invoice amounts\n"
|
|
986
986
|
"- `stock_balances` — current stock on hand by item × warehouse\n"
|
|
987
987
|
"- `stock_movements` — stock ledger entries (movement history); "
|
|
988
|
-
"`abs_qty` = units moved (use `sum`), `actual_qty` = net flow\n
|
|
988
|
+
"`abs_qty` = units moved (use `sum`), `actual_qty` = net flow\n"
|
|
989
|
+
"- `gl_entries` — posted ledger rows; use `income_amount` and "
|
|
990
|
+
"`expense_amount` for P&L analysis\n"
|
|
991
|
+
"- `bank_transactions` — imported cash movements; use `deposit` "
|
|
992
|
+
"and `withdrawal` for bank inflow/outflow analysis\n\n"
|
|
989
993
|
"Shape your call like: `{dataset, group_by, measures, filters?, "
|
|
990
994
|
"order_by?, limit?}`. `group_by` is a list of field names. "
|
|
991
995
|
"`measures` is an object of `{alias: [op, field]}` where `op` "
|
|
@@ -1025,6 +1029,9 @@ TOOLS = [
|
|
|
1025
1029
|
"ar_open_items",
|
|
1026
1030
|
"ap_open_items",
|
|
1027
1031
|
"stock_balances",
|
|
1032
|
+
"stock_movements",
|
|
1033
|
+
"gl_entries",
|
|
1034
|
+
"bank_transactions",
|
|
1028
1035
|
],
|
|
1029
1036
|
},
|
|
1030
1037
|
"group_by": {
|
|
@@ -1067,10 +1074,11 @@ TOOLS = [
|
|
|
1067
1074
|
"Available semantic datasets include: sales_invoices, "
|
|
1068
1075
|
"sales_invoice_lines, purchase_invoices, purchase_invoice_lines, "
|
|
1069
1076
|
"payments, ar_open_items, ap_open_items, stock_balances, "
|
|
1070
|
-
"stock_movements.\n\n"
|
|
1077
|
+
"stock_movements, gl_entries, bank_transactions.\n\n"
|
|
1071
1078
|
"The report language supports grouped dimensions, month/quarter/year "
|
|
1072
1079
|
"date buckets, sum/count/avg/min/max measures and KPIs, sorting, "
|
|
1073
|
-
"limits, tables, and bar/line
|
|
1080
|
+
"limits, tables, and single- or multi-series bar/line charts plus "
|
|
1081
|
+
"pie charts. Request only fields "
|
|
1074
1082
|
"exposed by dataset metadata. Always include the returned URL."
|
|
1075
1083
|
),
|
|
1076
1084
|
"parameters": {
|
|
@@ -1105,6 +1113,9 @@ TOOLS = [
|
|
|
1105
1113
|
"ar_open_items",
|
|
1106
1114
|
"ap_open_items",
|
|
1107
1115
|
"stock_balances",
|
|
1116
|
+
"stock_movements",
|
|
1117
|
+
"gl_entries",
|
|
1118
|
+
"bank_transactions",
|
|
1108
1119
|
],
|
|
1109
1120
|
},
|
|
1110
1121
|
"fields": {"type": "array", "items": {"type": "string"}},
|
|
@@ -1153,7 +1164,7 @@ TOOLS = [
|
|
|
1153
1164
|
"sales_invoice_lines; use amount instead\", or \"add a line "
|
|
1154
1165
|
"chart showing monthly trend\"). The backend will hand both "
|
|
1155
1166
|
"the current draft and your feedback to the report specialist "
|
|
1156
|
-
"model
|
|
1167
|
+
"model to rewrite the declarative spec.\n\n"
|
|
1157
1168
|
"Only pass `data_requests` or `report` directly if you already "
|
|
1158
1169
|
"have a complete version-1 declarative definition."
|
|
1159
1170
|
),
|
|
@@ -1188,6 +1199,9 @@ TOOLS = [
|
|
|
1188
1199
|
"ar_open_items",
|
|
1189
1200
|
"ap_open_items",
|
|
1190
1201
|
"stock_balances",
|
|
1202
|
+
"stock_movements",
|
|
1203
|
+
"gl_entries",
|
|
1204
|
+
"bank_transactions",
|
|
1191
1205
|
],
|
|
1192
1206
|
},
|
|
1193
1207
|
"fields": {"type": "array", "items": {"type": "string"}},
|
|
@@ -2100,17 +2114,18 @@ def _handle_query_dataset(args):
|
|
|
2100
2114
|
def _handle_create_custom_analytics_report(args, user_info: dict | None = None, session_id: str | None = None, client_ip: str | None = None):
|
|
2101
2115
|
from api.routers.analytics import create_report_draft_record
|
|
2102
2116
|
|
|
2103
|
-
# GPT may pass intent + (optionally) a sketch. If the
|
|
2104
|
-
# missing, delegate to the
|
|
2117
|
+
# GPT may pass intent + (optionally) a sketch. If the declarative spec is
|
|
2118
|
+
# missing, delegate to the report-spec specialist.
|
|
2105
2119
|
if not args.get("report") or not args.get("data_requests"):
|
|
2106
2120
|
intent = args.get("intent") or args.get("description") or args.get("title")
|
|
2107
2121
|
if not intent:
|
|
2108
2122
|
return {"error": "Provide either `intent` or a complete spec (data_requests + report)."}
|
|
2109
2123
|
try:
|
|
2110
|
-
spec =
|
|
2124
|
+
spec = _generate_report_spec_via_openai(
|
|
2111
2125
|
intent,
|
|
2112
2126
|
client_ip=client_ip,
|
|
2113
2127
|
user_role=(user_info or {}).get("role"),
|
|
2128
|
+
session_id=session_id,
|
|
2114
2129
|
)
|
|
2115
2130
|
except Exception as e:
|
|
2116
2131
|
return {"error": f"Report specialist failed: {e}"}
|
|
@@ -2138,7 +2153,12 @@ def _handle_get_custom_analytics_report(args, user_info: dict | None = None):
|
|
|
2138
2153
|
return row
|
|
2139
2154
|
|
|
2140
2155
|
|
|
2141
|
-
def _handle_update_custom_analytics_report(
|
|
2156
|
+
def _handle_update_custom_analytics_report(
|
|
2157
|
+
args,
|
|
2158
|
+
user_info: dict | None = None,
|
|
2159
|
+
client_ip: str | None = None,
|
|
2160
|
+
session_id: str | None = None,
|
|
2161
|
+
):
|
|
2142
2162
|
from api.routers.analytics import get_report_draft_record, update_report_draft_record
|
|
2143
2163
|
|
|
2144
2164
|
report_id = args.get("report_id")
|
|
@@ -2154,12 +2174,13 @@ def _handle_update_custom_analytics_report(args, user_info: dict | None = None,
|
|
|
2154
2174
|
if not existing:
|
|
2155
2175
|
return {"error": f"Report draft '{report_id}' not found"}
|
|
2156
2176
|
try:
|
|
2157
|
-
spec =
|
|
2177
|
+
spec = _generate_report_spec_via_openai(
|
|
2158
2178
|
intent=existing.get("description") or existing.get("title") or "",
|
|
2159
2179
|
existing_spec=existing,
|
|
2160
2180
|
feedback=feedback,
|
|
2161
2181
|
client_ip=client_ip,
|
|
2162
2182
|
user_role=(user_info or {}).get("role"),
|
|
2183
|
+
session_id=session_id,
|
|
2163
2184
|
)
|
|
2164
2185
|
except Exception as e:
|
|
2165
2186
|
return {"error": f"Report specialist failed: {e}"}
|
|
@@ -2441,11 +2462,11 @@ Only state that something was done — created, changed, enabled/disabled, booke
|
|
|
2441
2462
|
|
|
2442
2463
|
**Path 2: aggregated facts → `query_dataset`.** For "who is our top customer by revenue", "total sales this month", "outstanding AR by customer", "average invoice size", "count of POs per supplier" — anything that requires summing, counting, ranking, or grouping across many rows — call `query_dataset`. It runs a deterministic SQL aggregation server-side and returns the actual aggregated numbers you can cite in chat. NEVER try to compute a top-N or sum by eyeballing a `list_documents` sample — it defaults to 20 rows and will give a wrong answer on any meaningful dataset.
|
|
2443
2464
|
|
|
2444
|
-
**Path 3: charts / complex reports → `create_custom_analytics_report`.** Only call this when the user **explicitly asks for** a chart, graph, visualization, dashboard, trend, pivot, breakdown, or saved report — or when the analysis genuinely
|
|
2465
|
+
**Path 3: charts / complex reports → `create_custom_analytics_report`.** Only call this when the user **explicitly asks for** a chart, graph, visualization, dashboard, trend, pivot, breakdown, or saved report — or when the analysis genuinely needs a reusable report. Do **not** invoke it for a factual question `query_dataset` could answer in one call.
|
|
2445
2466
|
|
|
2446
|
-
Important constraint on the analytics report tool:
|
|
2467
|
+
Important constraint on the analytics report tool: its bounded declarative specification runs **client-side only** — you never see its output. So you cannot "open the report to read the numbers." If the user asks you to summarise or interpret the result after the fact, re-answer via `query_dataset` or a standard financial report; do not infer data coverage from an empty invoice dataset when GL or bank data may exist.
|
|
2447
2468
|
|
|
2448
|
-
When you do build a custom report, pass a plain-language `intent` to `create_custom_analytics_report` (a specialist model writes the
|
|
2469
|
+
When you do build a custom report, pass a plain-language `intent` to `create_custom_analytics_report` (a specialist model writes the bounded declarative spec for you) and reply with the returned `/reports/analytics?report_id=…` link as a markdown link. The draft appears under **Custom Analytics** in the sidebar so the user can reopen or share it.
|
|
2449
2470
|
|
|
2450
2471
|
{markdown_links_section}
|
|
2451
2472
|
|
|
@@ -2675,9 +2696,9 @@ Master records CAN be deleted — use the `delete_master` tool (ADMIN role only)
|
|
|
2675
2696
|
|
|
2676
2697
|
When you do call `create_custom_analytics_report`, pass `title` and
|
|
2677
2698
|
`intent` — a clear plain-language description (filters, groupings, sort
|
|
2678
|
-
orders, chart type). A specialist model writes the
|
|
2699
|
+
orders, chart type). A specialist model writes the declarative report spec for you.
|
|
2679
2700
|
Relay the returned `/reports/analytics?report_id=…` link as a markdown
|
|
2680
|
-
link. You will NOT see the
|
|
2701
|
+
link. You will NOT see the rendered data — the declarative spec runs in the
|
|
2681
2702
|
user's browser. So do not promise to "read" or "interpret" the report
|
|
2682
2703
|
yourself; if the user wants a narrative answer, use `list_documents`
|
|
2683
2704
|
instead.
|
|
@@ -2762,33 +2783,20 @@ Users can attach PDFs and images (receipts, bills, contracts, screenshots) to th
|
|
|
2762
2783
|
|
|
2763
2784
|
|
|
2764
2785
|
# ---------------------------------------------------------------------------
|
|
2765
|
-
#
|
|
2786
|
+
# OpenAI report-spec specialist
|
|
2766
2787
|
#
|
|
2767
|
-
# GPT-5.
|
|
2788
|
+
# GPT-5.6 Terra stays as the planner/orchestrator. When it decides a report needs
|
|
2768
2789
|
# a custom analytics view it calls the create/update custom analytics tools.
|
|
2769
|
-
# Those handlers
|
|
2770
|
-
#
|
|
2771
|
-
# compatibility even though executable report code is no longer accepted.
|
|
2790
|
+
# Those handlers make a separate, narrowly prompted Terra call for the bounded
|
|
2791
|
+
# declarative report spec. Both roles use the existing OPENAI_API_KEY.
|
|
2772
2792
|
# ---------------------------------------------------------------------------
|
|
2773
2793
|
|
|
2774
2794
|
|
|
2775
|
-
|
|
2776
|
-
return (
|
|
2777
|
-
os.environ.get("ANTHROPIC_CODE_MODEL")
|
|
2778
|
-
or os.environ.get("ANTHROPIC_REPORT_REPAIR_MODEL") # legacy fallback
|
|
2779
|
-
or "claude-sonnet-4-20250514"
|
|
2780
|
-
)
|
|
2795
|
+
REPORT_SPECIALIST_MODEL = "gpt-5.6-terra"
|
|
2781
2796
|
|
|
2782
2797
|
|
|
2783
|
-
def
|
|
2784
|
-
|
|
2785
|
-
if not api_key or api_key == "your-anthropic-key-here":
|
|
2786
|
-
return False, "ANTHROPIC_API_KEY is not configured"
|
|
2787
|
-
try:
|
|
2788
|
-
import anthropic # noqa: F401 # type: ignore
|
|
2789
|
-
except Exception as e:
|
|
2790
|
-
return False, f"Anthropic SDK not available: {e}"
|
|
2791
|
-
return True, ""
|
|
2798
|
+
def _report_specialist_model() -> str:
|
|
2799
|
+
return os.environ.get("LAMBDA_ERP_REPORT_MODEL") or REPORT_SPECIALIST_MODEL
|
|
2792
2800
|
|
|
2793
2801
|
|
|
2794
2802
|
_REPORT_CODE_SYSTEM_PROMPT = """You are a report-spec specialist for Lambda ERP.
|
|
@@ -2824,6 +2832,17 @@ all except count require a field. KPI operations use the same set. Formats are
|
|
|
2824
2832
|
currency, percent, number, date, or string as appropriate. Charts are bar, line,
|
|
2825
2833
|
or pie and must reference a table id via data_table.
|
|
2826
2834
|
|
|
2835
|
+
For a chart with multiple bars or lines per x value, omit `y` and use
|
|
2836
|
+
`series`: [{"key":"income","label":"Income"},{"key":"expenses","label":"Expenses"}].
|
|
2837
|
+
Every series key must be an output column of the chart's data_table. Pie charts
|
|
2838
|
+
allow only one series. A single-series chart should keep using `y`.
|
|
2839
|
+
|
|
2840
|
+
Use `gl_entries` with `income_amount` and `expense_amount` for accounting
|
|
2841
|
+
income/expense or profit-and-loss analysis. Use `bank_transactions` with
|
|
2842
|
+
`deposit` and `withdrawal` only when the user asks for cash/bank inflows and
|
|
2843
|
+
outflows. Do not infer that there is no financial activity merely because the
|
|
2844
|
+
sales_invoices or purchase_invoices datasets are empty.
|
|
2845
|
+
|
|
2827
2846
|
Filters are objects keyed by an exposed filter field: equality values, arrays
|
|
2828
2847
|
for IN, or {"from":...,"to":...} for ranges. Never use SQL or operator strings.
|
|
2829
2848
|
Use only dataset fields provided in the user message. Identifier fields (name,
|
|
@@ -2856,25 +2875,24 @@ def _extract_json_object(text: str) -> dict:
|
|
|
2856
2875
|
start = text.find("{")
|
|
2857
2876
|
end = text.rfind("}")
|
|
2858
2877
|
if start == -1 or end == -1 or end <= start:
|
|
2859
|
-
raise ValueError("
|
|
2878
|
+
raise ValueError("Report specialist did not return a JSON object.")
|
|
2860
2879
|
candidate = text[start : end + 1]
|
|
2861
2880
|
return json.loads(candidate)
|
|
2862
2881
|
|
|
2863
2882
|
|
|
2864
|
-
def
|
|
2883
|
+
def _generate_report_spec_via_openai(
|
|
2865
2884
|
intent: str,
|
|
2866
2885
|
existing_spec: dict | None = None,
|
|
2867
2886
|
feedback: str | None = None,
|
|
2868
2887
|
client_ip: str | None = None,
|
|
2869
2888
|
user_role: str | None = None,
|
|
2889
|
+
session_id: str | None = None,
|
|
2870
2890
|
) -> dict:
|
|
2871
|
-
|
|
2872
|
-
if not
|
|
2873
|
-
raise RuntimeError(
|
|
2874
|
-
import anthropic # type: ignore
|
|
2891
|
+
api_key = os.environ.get("OPENAI_API_KEY", "")
|
|
2892
|
+
if not api_key or api_key == "sk-your-key-here":
|
|
2893
|
+
raise RuntimeError("OPENAI_API_KEY is not configured")
|
|
2875
2894
|
|
|
2876
|
-
model =
|
|
2877
|
-
api_key = os.environ["ANTHROPIC_API_KEY"]
|
|
2895
|
+
model = _report_specialist_model()
|
|
2878
2896
|
|
|
2879
2897
|
user_parts: list[str] = []
|
|
2880
2898
|
user_parts.append(f"## Today's date\n{date.today().isoformat()}")
|
|
@@ -2903,10 +2921,13 @@ def _generate_report_spec_via_anthropic(
|
|
|
2903
2921
|
user_msg = "\n\n".join(user_parts)
|
|
2904
2922
|
|
|
2905
2923
|
print(
|
|
2906
|
-
f"[chat_llm] provider=
|
|
2924
|
+
f"[chat_llm] provider=openai role=report_specialist model={model}",
|
|
2907
2925
|
flush=True,
|
|
2908
2926
|
)
|
|
2909
|
-
client =
|
|
2927
|
+
client = OpenAI(
|
|
2928
|
+
api_key=api_key,
|
|
2929
|
+
timeout=httpx.Timeout(120.0, connect=10.0),
|
|
2930
|
+
)
|
|
2910
2931
|
reservation_id = None
|
|
2911
2932
|
if is_demo_role(user_role):
|
|
2912
2933
|
_blocked, reservation_id = demo_limiter.reserve(
|
|
@@ -2923,11 +2944,12 @@ def _generate_report_spec_via_anthropic(
|
|
|
2923
2944
|
# reservation for the process lifetime (or until TTL sweep).
|
|
2924
2945
|
settled = False
|
|
2925
2946
|
try:
|
|
2926
|
-
response = client.
|
|
2947
|
+
response = client.responses.create(
|
|
2927
2948
|
model=model,
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2949
|
+
instructions=_REPORT_CODE_SYSTEM_PROMPT,
|
|
2950
|
+
input=user_msg,
|
|
2951
|
+
max_output_tokens=4096,
|
|
2952
|
+
reasoning={"effort": "low"},
|
|
2931
2953
|
)
|
|
2932
2954
|
# Log every call for the admin dashboard. Only public_manager rows
|
|
2933
2955
|
# count against the demo cap — other roles are logged for
|
|
@@ -2935,22 +2957,28 @@ def _generate_report_spec_via_anthropic(
|
|
|
2935
2957
|
usage = getattr(response, "usage", None)
|
|
2936
2958
|
demo_limiter.settle(
|
|
2937
2959
|
reservation_id,
|
|
2938
|
-
actual_cost_usd=
|
|
2960
|
+
actual_cost_usd=cost_of_openai_call(model, usage),
|
|
2939
2961
|
ip=client_ip or "unknown",
|
|
2940
2962
|
role=user_role,
|
|
2941
|
-
provider="
|
|
2963
|
+
provider="openai",
|
|
2942
2964
|
model=model,
|
|
2943
2965
|
prompt_tokens=int(getattr(usage, "input_tokens", 0) or 0) if usage else 0,
|
|
2944
2966
|
completion_tokens=int(getattr(usage, "output_tokens", 0) or 0) if usage else 0,
|
|
2967
|
+
session_id=session_id,
|
|
2945
2968
|
)
|
|
2946
2969
|
settled = True
|
|
2947
2970
|
finally:
|
|
2948
2971
|
if not settled:
|
|
2949
2972
|
demo_limiter.release(reservation_id)
|
|
2950
2973
|
|
|
2951
|
-
text = ""
|
|
2952
|
-
|
|
2953
|
-
|
|
2974
|
+
text = getattr(response, "output_text", "") or ""
|
|
2975
|
+
if not text:
|
|
2976
|
+
text = "".join(
|
|
2977
|
+
getattr(block, "text", "") or ""
|
|
2978
|
+
for item in (getattr(response, "output", None) or [])
|
|
2979
|
+
for block in (getattr(item, "content", None) or [])
|
|
2980
|
+
if getattr(block, "type", "") in ("output_text", "text")
|
|
2981
|
+
)
|
|
2954
2982
|
spec = _extract_json_object(text)
|
|
2955
2983
|
if not spec.get("report") or not spec.get("data_requests"):
|
|
2956
2984
|
raise RuntimeError("Report specialist returned an incomplete spec.")
|
|
@@ -3251,10 +3279,10 @@ async def run_thinking_loop(
|
|
|
3251
3279
|
):
|
|
3252
3280
|
"""Run the agentic reasoning loop.
|
|
3253
3281
|
|
|
3254
|
-
The orchestrator
|
|
3282
|
+
The orchestrator and report specialist both use OpenAI gpt-5.6-terra. When GPT decides to call
|
|
3255
3283
|
`create_custom_analytics_report` or `update_custom_analytics_report`
|
|
3256
3284
|
with an intent/feedback hint, the tool handler itself delegates the
|
|
3257
|
-
declarative-spec step to
|
|
3285
|
+
declarative-spec step to a separate narrowly prompted Terra call. We emit an
|
|
3258
3286
|
`llm_provider` event around that delegation so the UI can surface it.
|
|
3259
3287
|
"""
|
|
3260
3288
|
openai_api_key = os.environ.get("OPENAI_API_KEY", "")
|
|
@@ -3337,7 +3365,9 @@ async def run_thinking_loop(
|
|
|
3337
3365
|
lambda args: _handle_get_custom_analytics_report(args, principal_ref[0])
|
|
3338
3366
|
)
|
|
3339
3367
|
tool_handlers["update_custom_analytics_report"] = (
|
|
3340
|
-
lambda args: _handle_update_custom_analytics_report(
|
|
3368
|
+
lambda args: _handle_update_custom_analytics_report(
|
|
3369
|
+
args, principal_ref[0], client_ip=client_ip, session_id=session_id
|
|
3370
|
+
)
|
|
3341
3371
|
)
|
|
3342
3372
|
|
|
3343
3373
|
# Demo sessions get a per-turn cap on attachment retrieval: each
|
|
@@ -3458,20 +3488,20 @@ async def run_thinking_loop(
|
|
|
3458
3488
|
principal_ref[0] = refresh_auth_principal(principal_ref[0])
|
|
3459
3489
|
live_role = principal_ref[0].get("role") if principal_ref[0] else None
|
|
3460
3490
|
|
|
3461
|
-
# If
|
|
3491
|
+
# If the orchestrator is delegating report-spec generation to Terra,
|
|
3462
3492
|
# surface the handoff in the UI.
|
|
3463
|
-
|
|
3493
|
+
will_delegate_to_report_specialist = (
|
|
3464
3494
|
tool_allowed(fn_name, live_role)
|
|
3465
3495
|
and fn_name in ("create_custom_analytics_report", "update_custom_analytics_report")
|
|
3466
3496
|
and not fn_args.get("report")
|
|
3467
3497
|
and not fn_args.get("data_requests")
|
|
3468
3498
|
)
|
|
3469
|
-
if
|
|
3499
|
+
if will_delegate_to_report_specialist:
|
|
3470
3500
|
await on_event({
|
|
3471
3501
|
"type": "llm_provider",
|
|
3472
|
-
"provider": "
|
|
3473
|
-
"model":
|
|
3474
|
-
"role": "
|
|
3502
|
+
"provider": "openai",
|
|
3503
|
+
"model": _report_specialist_model(),
|
|
3504
|
+
"role": "report_specialist",
|
|
3475
3505
|
})
|
|
3476
3506
|
|
|
3477
3507
|
handler = tool_handlers.get(fn_name)
|
|
@@ -37,7 +37,7 @@ _WINDOW_SECONDS = 3600 # 1 hour
|
|
|
37
37
|
|
|
38
38
|
# Upper bound on how long a reservation may hold budget before it's
|
|
39
39
|
# considered stale and ignored. Must exceed the longest provider call
|
|
40
|
-
# timeout (OpenAI
|
|
40
|
+
# timeout (OpenAI clients use 120s) plus a fudge for the
|
|
41
41
|
# event-loop latency between the SDK call returning and settle() firing.
|
|
42
42
|
# Defence-in-depth against cancellation leaks that slip past try/finally.
|
|
43
43
|
_RESERVATION_TTL_SECONDS = 180
|
|
@@ -4,8 +4,9 @@ Mirrors the shape of `lambda-web/backend/providers.py`. Only the models
|
|
|
4
4
|
this app actually calls are listed — refresh this dict when model
|
|
5
5
|
choices change. Prices are USD per 1M tokens.
|
|
6
6
|
|
|
7
|
-
Consumers import `cost_of_openai_call`
|
|
8
|
-
|
|
7
|
+
Consumers import `cost_of_openai_call` to turn an SDK `response.usage` object
|
|
8
|
+
into a USD amount. Both Chat Completions and Responses usage shapes are
|
|
9
|
+
accepted because the orchestrator and report specialist use different calls.
|
|
9
10
|
"""
|
|
10
11
|
|
|
11
12
|
from __future__ import annotations
|
|
@@ -44,14 +45,6 @@ OPENAI_PRICING: dict[str, dict[str, Any]] = {
|
|
|
44
45
|
"gpt-4o-mini": {"input": 0.15, "cached_input": 0.075, "output": 0.60},
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
ANTHROPIC_PRICING: dict[str, dict[str, Any]] = {
|
|
48
|
-
"claude-opus-4-7": {"input": 5.00, "output": 25.00, "cache_write": 6.25, "cache_read": 0.50},
|
|
49
|
-
"claude-opus-4-6": {"input": 5.00, "output": 25.00, "cache_write": 6.25, "cache_read": 0.50},
|
|
50
|
-
"claude-sonnet-4-6": {"input": 3.00, "output": 15.00, "cache_write": 3.75, "cache_read": 0.30},
|
|
51
|
-
"claude-sonnet-4-5": {"input": 3.00, "output": 15.00, "cache_write": 3.75, "cache_read": 0.30},
|
|
52
|
-
"claude-haiku-4-5": {"input": 1.00, "output": 5.00, "cache_write": 1.25, "cache_read": 0.10},
|
|
53
|
-
}
|
|
54
|
-
|
|
55
48
|
# Speech-to-text, priced per minute of audio (not per token).
|
|
56
49
|
TRANSCRIBE_PRICING: dict[str, dict[str, float]] = {
|
|
57
50
|
"gpt-4o-transcribe": {"per_minute": 0.006},
|
|
@@ -78,27 +71,34 @@ def get_openai_rates(model: str, input_tokens: int = 0) -> dict[str, float]:
|
|
|
78
71
|
return entry
|
|
79
72
|
|
|
80
73
|
|
|
81
|
-
def get_anthropic_rates(model: str) -> dict[str, float]:
|
|
82
|
-
return ANTHROPIC_PRICING.get(model) or ANTHROPIC_PRICING["claude-opus-4-7"]
|
|
83
|
-
|
|
84
|
-
|
|
85
74
|
# ---------------------------------------------------------------------------
|
|
86
75
|
# Cost calculators. `usage` is the SDK's raw usage object; we accept None
|
|
87
76
|
# and degrade to 0 so callers don't need to pre-check.
|
|
88
77
|
# ---------------------------------------------------------------------------
|
|
89
78
|
|
|
90
79
|
def cost_of_openai_call(model: str, usage: Optional[Any]) -> float:
|
|
91
|
-
"""USD cost of one OpenAI
|
|
80
|
+
"""USD cost of one OpenAI Chat Completions or Responses API call.
|
|
92
81
|
|
|
93
|
-
Understands
|
|
94
|
-
|
|
82
|
+
Understands either endpoint's cached-token breakdown so cache hits are
|
|
83
|
+
billed at the discounted rate."""
|
|
95
84
|
if usage is None:
|
|
96
85
|
return 0.0
|
|
97
|
-
prompt_tokens = int(
|
|
98
|
-
|
|
86
|
+
prompt_tokens = int(
|
|
87
|
+
getattr(usage, "prompt_tokens", 0)
|
|
88
|
+
or getattr(usage, "input_tokens", 0)
|
|
89
|
+
or 0
|
|
90
|
+
)
|
|
91
|
+
completion_tokens = int(
|
|
92
|
+
getattr(usage, "completion_tokens", 0)
|
|
93
|
+
or getattr(usage, "output_tokens", 0)
|
|
94
|
+
or 0
|
|
95
|
+
)
|
|
99
96
|
|
|
100
97
|
cached = 0
|
|
101
|
-
details =
|
|
98
|
+
details = (
|
|
99
|
+
getattr(usage, "prompt_tokens_details", None)
|
|
100
|
+
or getattr(usage, "input_tokens_details", None)
|
|
101
|
+
)
|
|
102
102
|
if details is not None:
|
|
103
103
|
cached = int(getattr(details, "cached_tokens", 0) or 0)
|
|
104
104
|
|
|
@@ -111,27 +111,6 @@ def cost_of_openai_call(model: str, usage: Optional[Any]) -> float:
|
|
|
111
111
|
+ completion_tokens * rates["output"]
|
|
112
112
|
)
|
|
113
113
|
return total / 1_000_000
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def cost_of_anthropic_call(model: str, usage: Optional[Any]) -> float:
|
|
117
|
-
"""USD cost of one Anthropic messages.create call from `response.usage`."""
|
|
118
|
-
if usage is None:
|
|
119
|
-
return 0.0
|
|
120
|
-
input_tokens = int(getattr(usage, "input_tokens", 0) or 0)
|
|
121
|
-
output_tokens = int(getattr(usage, "output_tokens", 0) or 0)
|
|
122
|
-
cache_create = int(getattr(usage, "cache_creation_input_tokens", 0) or 0)
|
|
123
|
-
cache_read = int(getattr(usage, "cache_read_input_tokens", 0) or 0)
|
|
124
|
-
|
|
125
|
-
rates = get_anthropic_rates(model)
|
|
126
|
-
total = (
|
|
127
|
-
input_tokens * rates["input"]
|
|
128
|
-
+ cache_create * rates.get("cache_write", rates["input"])
|
|
129
|
-
+ cache_read * rates.get("cache_read", rates["input"])
|
|
130
|
-
+ output_tokens * rates["output"]
|
|
131
|
-
)
|
|
132
|
-
return total / 1_000_000
|
|
133
|
-
|
|
134
|
-
|
|
135
114
|
def cost_of_transcription(model: str, audio_seconds: float) -> float:
|
|
136
115
|
"""USD cost of one speech-to-text call, billed per minute of audio.
|
|
137
116
|
|
|
@@ -83,8 +83,8 @@ def demo_spend_overview(_user: dict = Depends(require_admin)):
|
|
|
83
83
|
Only `public_manager` spend counts against the demo cap — `demo_usd`
|
|
84
84
|
in each window is the number the global cap compares against. Non-
|
|
85
85
|
demo calls (your own admin/manager sessions) are included in
|
|
86
|
-
`total_usd` so you can see the full
|
|
87
|
-
|
|
86
|
+
`total_usd` so you can see the full model bill from this deployment,
|
|
87
|
+
including historical provider rows recorded before a provider change.
|
|
88
88
|
"""
|
|
89
89
|
init_schema()
|
|
90
90
|
now = time.time()
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
This module now exposes two layers:
|
|
4
4
|
|
|
5
5
|
1. The original preset analytics API used by the current /reports/analytics page.
|
|
6
|
-
2. A semantic dataset registry + bounded data-fetch endpoint for
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
2. A semantic dataset registry + bounded data-fetch endpoint for declarative
|
|
7
|
+
reports. The browser can request approved datasets, then group, aggregate,
|
|
8
|
+
and chart them without executing report-provided code.
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
11
|
import json
|
|
@@ -381,13 +381,31 @@ class ReportKpiDefinition(StrictReportModel):
|
|
|
381
381
|
return self
|
|
382
382
|
|
|
383
383
|
|
|
384
|
+
class ReportChartSeries(StrictReportModel):
|
|
385
|
+
key: str = Field(pattern=r"^[A-Za-z_][A-Za-z0-9_]{0,63}$")
|
|
386
|
+
label: str | None = Field(default=None, max_length=200)
|
|
387
|
+
|
|
388
|
+
|
|
384
389
|
class ReportChartDefinition(StrictReportModel):
|
|
385
390
|
id: str | None = Field(default=None, pattern=r"^[A-Za-z_][A-Za-z0-9_]{0,63}$")
|
|
386
391
|
title: str = Field(max_length=200)
|
|
387
392
|
type: Literal["bar", "line", "pie"]
|
|
388
393
|
data_table: str = Field(pattern=r"^[A-Za-z_][A-Za-z0-9_]{0,63}$")
|
|
389
394
|
x: str = Field(pattern=r"^[A-Za-z_][A-Za-z0-9_]{0,63}$")
|
|
390
|
-
y
|
|
395
|
+
# `y` keeps existing single-series v1 drafts working. New reports may use
|
|
396
|
+
# `series` for grouped bars / multiple lines over the same table.
|
|
397
|
+
y: str | None = Field(default=None, pattern=r"^[A-Za-z_][A-Za-z0-9_]{0,63}$")
|
|
398
|
+
series: list[ReportChartSeries] = Field(default_factory=list, max_length=8)
|
|
399
|
+
|
|
400
|
+
@model_validator(mode="after")
|
|
401
|
+
def require_series(self):
|
|
402
|
+
if bool(self.y) == bool(self.series):
|
|
403
|
+
raise ValueError("Chart must define exactly one of 'y' or 'series'")
|
|
404
|
+
if self.type == "pie" and len(self.series) > 1:
|
|
405
|
+
raise ValueError("Pie charts support only one series")
|
|
406
|
+
if len({series.key for series in self.series}) != len(self.series):
|
|
407
|
+
raise ValueError("Chart series keys must be unique")
|
|
408
|
+
return self
|
|
391
409
|
|
|
392
410
|
|
|
393
411
|
class DeclarativeReportDefinition(StrictReportModel):
|
|
@@ -477,9 +495,10 @@ class ReportDraftPayload(StrictReportModel):
|
|
|
477
495
|
raise ValueError(
|
|
478
496
|
f"Chart '{chart.title}' references unknown table '{chart.data_table}'"
|
|
479
497
|
)
|
|
480
|
-
|
|
498
|
+
series_keys = [chart.y] if chart.y else [series.key for series in chart.series]
|
|
499
|
+
if chart.x not in available or any(key not in available for key in series_keys):
|
|
481
500
|
raise ValueError(
|
|
482
|
-
f"Chart '{chart.title}' references unknown x/
|
|
501
|
+
f"Chart '{chart.title}' references unknown x/series table output"
|
|
483
502
|
)
|
|
484
503
|
return self
|
|
485
504
|
|
|
@@ -672,6 +691,119 @@ SEMANTIC_DATASETS: dict[str, dict[str, Any]] = {
|
|
|
672
691
|
"max_limit": 10000,
|
|
673
692
|
"default_order_by": "pe.posting_date DESC, pe.name DESC",
|
|
674
693
|
},
|
|
694
|
+
"gl_entries": {
|
|
695
|
+
"label": "General Ledger Entries",
|
|
696
|
+
"description": (
|
|
697
|
+
"Posted double-entry accounting rows joined to the chart of accounts. "
|
|
698
|
+
"Use income_amount and expense_amount for profit-and-loss charts; they "
|
|
699
|
+
"apply the correct normal-balance sign and are zero for other account "
|
|
700
|
+
"roots. Use debit/credit for raw ledger movement."
|
|
701
|
+
),
|
|
702
|
+
"sql_from": 'FROM "GL Entry" gle LEFT JOIN "Account" a ON a.name = gle.account',
|
|
703
|
+
"fields": {
|
|
704
|
+
"name": "string",
|
|
705
|
+
"posting_date": "date",
|
|
706
|
+
"company": "string",
|
|
707
|
+
"account": "string",
|
|
708
|
+
"account_name": "string",
|
|
709
|
+
"root_type": "string",
|
|
710
|
+
"account_type": "string",
|
|
711
|
+
"party_type": "string",
|
|
712
|
+
"party": "string",
|
|
713
|
+
"cost_center": "string",
|
|
714
|
+
"voucher_type": "string",
|
|
715
|
+
"voucher_no": "string",
|
|
716
|
+
"debit": "number",
|
|
717
|
+
"credit": "number",
|
|
718
|
+
"normal_balance_amount": "number",
|
|
719
|
+
"income_amount": "number",
|
|
720
|
+
"expense_amount": "number",
|
|
721
|
+
"is_opening": "string",
|
|
722
|
+
},
|
|
723
|
+
"field_sql": {
|
|
724
|
+
"name": "gle.name",
|
|
725
|
+
"posting_date": "gle.posting_date",
|
|
726
|
+
"company": "gle.company",
|
|
727
|
+
"account": "gle.account",
|
|
728
|
+
"account_name": "a.account_name",
|
|
729
|
+
"root_type": "a.root_type",
|
|
730
|
+
"account_type": "a.account_type",
|
|
731
|
+
"party_type": "gle.party_type",
|
|
732
|
+
"party": "gle.party",
|
|
733
|
+
"cost_center": "gle.cost_center",
|
|
734
|
+
"voucher_type": "gle.voucher_type",
|
|
735
|
+
"voucher_no": "gle.voucher_no",
|
|
736
|
+
"debit": "gle.debit",
|
|
737
|
+
"credit": "gle.credit",
|
|
738
|
+
"normal_balance_amount": (
|
|
739
|
+
"CASE WHEN a.root_type IN ('Liability', 'Equity', 'Income') "
|
|
740
|
+
"THEN gle.credit - gle.debit ELSE gle.debit - gle.credit END"
|
|
741
|
+
),
|
|
742
|
+
"income_amount": (
|
|
743
|
+
"CASE WHEN a.root_type = 'Income' THEN gle.credit - gle.debit ELSE 0 END"
|
|
744
|
+
),
|
|
745
|
+
"expense_amount": (
|
|
746
|
+
"CASE WHEN a.root_type = 'Expense' THEN gle.debit - gle.credit ELSE 0 END"
|
|
747
|
+
),
|
|
748
|
+
"is_opening": "gle.is_opening",
|
|
749
|
+
},
|
|
750
|
+
"default_where": ["COALESCE(gle.is_cancelled, 0) = 0"],
|
|
751
|
+
"filter_fields": {
|
|
752
|
+
"posting_date", "company", "account", "root_type", "account_type",
|
|
753
|
+
"party_type", "party", "cost_center", "voucher_type", "voucher_no",
|
|
754
|
+
"is_opening",
|
|
755
|
+
},
|
|
756
|
+
"default_limit": 1000,
|
|
757
|
+
"max_limit": 10000,
|
|
758
|
+
"default_order_by": "gle.posting_date DESC, gle.name DESC",
|
|
759
|
+
},
|
|
760
|
+
"bank_transactions": {
|
|
761
|
+
"label": "Bank Transactions",
|
|
762
|
+
"description": (
|
|
763
|
+
"Imported bank-statement movements. Use deposit for cash inflows and "
|
|
764
|
+
"withdrawal for cash outflows. These are cash movements, not accounting "
|
|
765
|
+
"income/expenses; use gl_entries for a profit-and-loss view."
|
|
766
|
+
),
|
|
767
|
+
"sql_from": 'FROM "Bank Transaction" bt',
|
|
768
|
+
"fields": {
|
|
769
|
+
"name": "string",
|
|
770
|
+
"posting_date": "date",
|
|
771
|
+
"value_date": "date",
|
|
772
|
+
"bank_account": "string",
|
|
773
|
+
"currency": "string",
|
|
774
|
+
"deposit": "number",
|
|
775
|
+
"withdrawal": "number",
|
|
776
|
+
"counterparty_name": "string",
|
|
777
|
+
"description": "string",
|
|
778
|
+
"reference_number": "string",
|
|
779
|
+
"status": "string",
|
|
780
|
+
"reference_doctype": "string",
|
|
781
|
+
"reference_name": "string",
|
|
782
|
+
},
|
|
783
|
+
"field_sql": {
|
|
784
|
+
"name": "bt.name",
|
|
785
|
+
"posting_date": "bt.posting_date",
|
|
786
|
+
"value_date": "bt.value_date",
|
|
787
|
+
"bank_account": "bt.bank_account",
|
|
788
|
+
"currency": "bt.currency",
|
|
789
|
+
"deposit": "bt.deposit",
|
|
790
|
+
"withdrawal": "bt.withdrawal",
|
|
791
|
+
"counterparty_name": "bt.counterparty_name",
|
|
792
|
+
"description": "bt.description",
|
|
793
|
+
"reference_number": "bt.reference_number",
|
|
794
|
+
"status": "bt.status",
|
|
795
|
+
"reference_doctype": "bt.reference_doctype",
|
|
796
|
+
"reference_name": "bt.reference_name",
|
|
797
|
+
},
|
|
798
|
+
"default_where": ["COALESCE(bt.reversal_indicator, 0) = 0"],
|
|
799
|
+
"filter_fields": {
|
|
800
|
+
"posting_date", "value_date", "bank_account", "currency", "status",
|
|
801
|
+
"reference_doctype", "reference_name",
|
|
802
|
+
},
|
|
803
|
+
"default_limit": 1000,
|
|
804
|
+
"max_limit": 10000,
|
|
805
|
+
"default_order_by": "bt.posting_date DESC, bt.name DESC",
|
|
806
|
+
},
|
|
675
807
|
"ar_open_items": {
|
|
676
808
|
"label": "AR Open Items",
|
|
677
809
|
"description": "Open submitted non-return sales invoices with positive outstanding amounts.",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "lambda-erp"
|
|
3
|
-
version = "0.8.
|
|
3
|
+
version = "0.8.31"
|
|
4
4
|
description = "Core ERP logic - accounting, sales, purchasing, inventory"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "Apache-2.0"
|
|
@@ -11,7 +11,6 @@ dependencies = [
|
|
|
11
11
|
"uvicorn[standard]>=0.30",
|
|
12
12
|
"python-multipart>=0.0.9",
|
|
13
13
|
"pydantic>=2.0",
|
|
14
|
-
"anthropic>=0.40",
|
|
15
14
|
"openai>=1.0",
|
|
16
15
|
"httpx>=0.27",
|
|
17
16
|
"python-dotenv>=1.0",
|
|
@@ -27,10 +27,8 @@ rate/cost, not the VM.
|
|
|
27
27
|
### 1. Gather inputs
|
|
28
28
|
|
|
29
29
|
- **Azure subscription ID** — `az account show --query id -o tsv`
|
|
30
|
-
- **OpenAI API key** — required; chat orchestration
|
|
31
|
-
|
|
32
|
-
generates custom-analytics JS runs on Claude. The demo still boots
|
|
33
|
-
without it but custom-report code generation will fail.
|
|
30
|
+
- **OpenAI API key** — required; chat orchestration and the report specialist
|
|
31
|
+
both run on GPT-5.6 Terra.
|
|
34
32
|
- **JWT secret** — a stable 64-char hex string used to sign login cookies:
|
|
35
33
|
```bash
|
|
36
34
|
python -c "import secrets; print(secrets.token_hex(32))"
|
|
@@ -38,19 +36,19 @@ rate/cost, not the VM.
|
|
|
38
36
|
Generate once, keep stable across revisions (users' cookies survive
|
|
39
37
|
rollouts that way).
|
|
40
38
|
|
|
41
|
-
You collect these
|
|
39
|
+
You collect these three items once, then paste them into GitHub — they
|
|
42
40
|
never need to sit on a laptop during any terraform run:
|
|
43
41
|
|
|
44
42
|
- **Subscription ID** goes into `terraform.tfvars` in step 3 (also into
|
|
45
43
|
GitHub Actions Variables for CI).
|
|
46
|
-
- **OpenAI key
|
|
44
|
+
- **OpenAI key and JWT secret** go directly into **GitHub
|
|
47
45
|
Actions Secrets** in step 6. From that point on, the
|
|
48
46
|
`terraform-apply` workflow reads them as `TF_VAR_*` env vars on
|
|
49
47
|
every CI-driven apply and pushes them into the Container App's
|
|
50
48
|
secret store.
|
|
51
49
|
|
|
52
50
|
The bootstrap `terraform apply` in step 4 runs with **no real secrets
|
|
53
|
-
at all** — the
|
|
51
|
+
at all** — the two secret-backed variables both default to a
|
|
54
52
|
placeholder string, which terraform writes into the Container App
|
|
55
53
|
alongside the Microsoft quickstart image. The first CI-driven
|
|
56
54
|
`terraform-apply` run (step 7) is where real secrets first reach
|
|
@@ -117,8 +115,8 @@ run has to happen from a machine where you can sign in as an owner of
|
|
|
117
115
|
the subscription + Entra tenant. No further applies will run from a
|
|
118
116
|
laptop after this.
|
|
119
117
|
|
|
120
|
-
**No secrets go in on this run.** The
|
|
121
|
-
(`openai_api_key`, `
|
|
118
|
+
**No secrets go in on this run.** The two secret-backed variables
|
|
119
|
+
(`openai_api_key`, `jwt_secret_key`) both default
|
|
122
120
|
to the placeholder string `"placeholder-will-be-set-by-github-actions"`;
|
|
123
121
|
terraform writes that string into the Container App's secret store,
|
|
124
122
|
which is fine because the app image isn't real yet either (the
|
|
@@ -175,7 +173,6 @@ Under **Secrets** tab (same page), add:
|
|
|
175
173
|
| Secret | Value |
|
|
176
174
|
|---------------------|-----------------------------------------------|
|
|
177
175
|
| `OPENAI_API_KEY` | your OpenAI key |
|
|
178
|
-
| `ANTHROPIC_API_KEY` | your Anthropic key |
|
|
179
176
|
| `JWT_SECRET_KEY` | the 64-hex-char string from step 1 |
|
|
180
177
|
|
|
181
178
|
These are what the `terraform-apply` workflow reads as `TF_VAR_*` env
|
|
@@ -208,7 +205,7 @@ From this point on, **no secret ever sits on a laptop**:
|
|
|
208
205
|
|----------------------------------------------|---------------------------------------------------------------------------|
|
|
209
206
|
| New app code (Python/React) | Push to `master`. `deploy.yml` builds, pushes, rolls the revision. |
|
|
210
207
|
| New infra, env var, or demo cap | Edit `terraform/app/*.tf`, push, then run `terraform-apply` workflow. |
|
|
211
|
-
| Rotate `OPENAI_API_KEY`
|
|
208
|
+
| Rotate `OPENAI_API_KEY` | Update the GitHub Secret, then run `terraform-apply` with `action=apply`. |
|
|
212
209
|
| Rotate `JWT_SECRET_KEY` | Same as above. All existing login cookies will become invalid. |
|
|
213
210
|
|
|
214
211
|
The `terraform-apply` workflow has a `plan | apply` dropdown on manual
|
|
@@ -217,8 +214,8 @@ before applying.
|
|
|
217
214
|
|
|
218
215
|
## Secrets: who owns what
|
|
219
216
|
|
|
220
|
-
- **Container App secrets** (`openai-api-key`, `
|
|
221
|
-
|
|
217
|
+
- **Container App secrets** (`openai-api-key`, `jwt-secret-key`) live in
|
|
218
|
+
**GitHub Secrets** and are applied to
|
|
222
219
|
Azure by the `terraform-apply` workflow, which passes them as
|
|
223
220
|
`TF_VAR_*` env vars. Terraform then writes them into the Container
|
|
224
221
|
App's own secret store. No copy on disk anywhere else.
|
|
@@ -269,7 +266,7 @@ Set `custom_domain = "erp-demo.example.com"` in `terraform.tfvars`. After
|
|
|
269
266
|
| Log Analytics (minimal) | ~$5 |
|
|
270
267
|
| **Total infra** | **~$40** |
|
|
271
268
|
|
|
272
|
-
LLM usage is billed separately by OpenAI
|
|
269
|
+
LLM usage is billed separately by OpenAI. The demo cap above
|
|
273
270
|
targets ~$50/day ceiling for visitor traffic, so the worst-case combined
|
|
274
271
|
monthly bill is roughly $40 infra + $1500 LLM if the demo is slammed
|
|
275
272
|
24/7.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|