lambda-erp 0.1.0__py3-none-any.whl

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.
Files changed (60) hide show
  1. api/__init__.py +0 -0
  2. api/attachments.py +229 -0
  3. api/auth.py +511 -0
  4. api/bootstrap.py +498 -0
  5. api/chat.py +2764 -0
  6. api/demo_limits.py +400 -0
  7. api/deps.py +7 -0
  8. api/errors.py +56 -0
  9. api/main.py +182 -0
  10. api/pdf.py +151 -0
  11. api/providers.py +116 -0
  12. api/routers/__init__.py +0 -0
  13. api/routers/accounting.py +63 -0
  14. api/routers/admin.py +122 -0
  15. api/routers/analytics.py +1009 -0
  16. api/routers/bank_reconciliation.py +31 -0
  17. api/routers/documents.py +100 -0
  18. api/routers/masters.py +396 -0
  19. api/routers/reports.py +735 -0
  20. api/routers/setup.py +387 -0
  21. api/services.py +372 -0
  22. api/templates/document.html +197 -0
  23. lambda_erp/__init__.py +3 -0
  24. lambda_erp/accounting/__init__.py +0 -0
  25. lambda_erp/accounting/bank_transaction.py +76 -0
  26. lambda_erp/accounting/budget.py +117 -0
  27. lambda_erp/accounting/chart_of_accounts.py +183 -0
  28. lambda_erp/accounting/general_ledger.py +362 -0
  29. lambda_erp/accounting/journal_entry.py +235 -0
  30. lambda_erp/accounting/payment_entry.py +515 -0
  31. lambda_erp/accounting/pos_invoice.py +342 -0
  32. lambda_erp/accounting/purchase_invoice.py +504 -0
  33. lambda_erp/accounting/revaluation.py +172 -0
  34. lambda_erp/accounting/sales_invoice.py +523 -0
  35. lambda_erp/accounting/subscription.py +132 -0
  36. lambda_erp/buying/__init__.py +0 -0
  37. lambda_erp/buying/purchase_order.py +165 -0
  38. lambda_erp/controllers/__init__.py +0 -0
  39. lambda_erp/controllers/currency.py +52 -0
  40. lambda_erp/controllers/defaults.py +51 -0
  41. lambda_erp/controllers/pricing_rule.py +103 -0
  42. lambda_erp/controllers/taxes_and_totals.py +369 -0
  43. lambda_erp/database.py +1543 -0
  44. lambda_erp/exceptions.py +37 -0
  45. lambda_erp/hooks.py +37 -0
  46. lambda_erp/model.py +462 -0
  47. lambda_erp/selling/__init__.py +0 -0
  48. lambda_erp/selling/quotation.py +263 -0
  49. lambda_erp/selling/sales_order.py +214 -0
  50. lambda_erp/simulation.py +704 -0
  51. lambda_erp/stock/__init__.py +0 -0
  52. lambda_erp/stock/delivery_note.py +254 -0
  53. lambda_erp/stock/purchase_receipt.py +356 -0
  54. lambda_erp/stock/stock_entry.py +330 -0
  55. lambda_erp/stock/stock_ledger.py +337 -0
  56. lambda_erp/utils.py +167 -0
  57. lambda_erp-0.1.0.dist-info/METADATA +454 -0
  58. lambda_erp-0.1.0.dist-info/RECORD +60 -0
  59. lambda_erp-0.1.0.dist-info/WHEEL +4 -0
  60. lambda_erp-0.1.0.dist-info/licenses/LICENSE +21 -0
api/chat.py ADDED
@@ -0,0 +1,2764 @@
1
+ """
2
+ WebSocket chat interface with agentic reasoning loop.
3
+
4
+ The LLM receives tool definitions that map to existing ERP service functions.
5
+ It reasons about what to do, calls tools, gets results, and iterates until
6
+ it has a final answer — all streamed back to the browser via WebSocket.
7
+
8
+ Supports multiple chat sessions, each with their own history and auto-generated title.
9
+ """
10
+
11
+ import asyncio
12
+ import json
13
+ import logging
14
+ import os
15
+ import traceback
16
+ import uuid
17
+ from datetime import date
18
+
19
+ import httpx
20
+ from dotenv import load_dotenv
21
+ from fastapi import APIRouter, Depends as _Depends, HTTPException, WebSocket, WebSocketDisconnect
22
+ from openai import OpenAI
23
+
24
+ from api import services
25
+ from api.demo_limits import (
26
+ demo_call_reserve_usd,
27
+ demo_max_completion_tokens,
28
+ demo_max_message_chars,
29
+ is_demo_role,
30
+ limiter as demo_limiter,
31
+ )
32
+ from api.providers import cost_of_anthropic_call, cost_of_openai_call
33
+ from api.routers.masters import create_master_record, update_master_record, MASTER_IDENTITY_ALIAS
34
+ from lambda_erp.database import get_db
35
+ from lambda_erp.utils import flt, now, nowdate
36
+
37
+ load_dotenv()
38
+
39
+ from api.auth import require_role, get_current_user
40
+
41
+ router = APIRouter(prefix="/chat", tags=["chat"], dependencies=[_Depends(require_role("viewer"))])
42
+ logger = logging.getLogger("chat")
43
+ def _resolve_demo_script_path() -> str:
44
+ """Locate live-demo-script.json in both dev and prod layouts.
45
+
46
+ In local dev the source of truth is `frontend/public/` (Vite serves
47
+ it from there). The Docker build only copies `frontend/dist/` into
48
+ the image, and Vite inlines everything from `public/` into `dist/`
49
+ at build time — so in the container the file lives under `dist/`.
50
+ Prefer whichever exists so the same code runs in both places.
51
+ """
52
+ here = os.path.dirname(__file__)
53
+ for subdir in ("public", "dist"):
54
+ candidate = os.path.join(here, "..", "frontend", subdir, "live-demo-script.json")
55
+ if os.path.isfile(candidate):
56
+ return candidate
57
+ # Fall back to the dev path — makes the FileNotFoundError message
58
+ # useful when neither exists (e.g. frontend wasn't built).
59
+ return os.path.join(here, "..", "frontend", "public", "live-demo-script.json")
60
+
61
+
62
+ DEMO_SCRIPT_PATH = _resolve_demo_script_path()
63
+ DEMO_TYPE_MS_PER_CHAR = 14
64
+ DEMO_TYPE_INITIAL_MS = 90
65
+ DEMO_AFTER_TYPED_USER_MS = 180
66
+
67
+ SESSION_SELECT = """
68
+ SELECT
69
+ cs.id,
70
+ cs.title,
71
+ cs.user_id,
72
+ cs.created_at,
73
+ cs.updated_at,
74
+ (
75
+ SELECT MAX(cm.created_at)
76
+ FROM "Chat Message" cm
77
+ WHERE cm.session_id = cs.id
78
+ AND cm.role IN ('user', 'assistant')
79
+ ) AS last_message_at
80
+ FROM "Chat Session" cs
81
+ """
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # Chat session & message persistence
85
+ # ---------------------------------------------------------------------------
86
+
87
+
88
+ def create_session(user_id: str | None = None) -> dict:
89
+ """Create a new chat session."""
90
+ db = get_db()
91
+ session_id = str(uuid.uuid4())[:8]
92
+ # Use now() (server-local ISO) to match the message `created_at` format;
93
+ # SQLite's CURRENT_TIMESTAMP default is UTC with a space separator, which
94
+ # would cause the sidebar to sort a brand-new session below older ones
95
+ # whose last_message_at came from now().
96
+ created_at = now()
97
+ db.sql(
98
+ 'INSERT INTO "Chat Session" (id, title, user_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)',
99
+ [session_id, "New Chat", user_id, created_at, created_at],
100
+ )
101
+ db.conn.commit()
102
+ return get_session(session_id) or {"id": session_id, "title": "New Chat"}
103
+
104
+
105
+ def list_sessions(user_id: str | None = None, role: str | None = None) -> list[dict]:
106
+ """List chat sessions for a user."""
107
+ db = get_db()
108
+ clauses: list[str] = []
109
+ params: list = []
110
+ if user_id:
111
+ clauses.append("cs.user_id = ?")
112
+ params.append(user_id)
113
+ if role == "public_manager":
114
+ # Hide demo-only and empty sessions — they pile up from drive-by visitors.
115
+ # Only surface sessions with at least one real (non-demo) message.
116
+ clauses.append(
117
+ 'EXISTS (SELECT 1 FROM "Chat Message" cm WHERE cm.session_id = cs.id AND cm.message_type != ?)'
118
+ )
119
+ params.append("demo")
120
+ where = f" WHERE {' AND '.join(f'({c})' for c in clauses)}" if clauses else ""
121
+ rows = db.sql(f"{SESSION_SELECT}{where} ORDER BY created_at DESC", params)
122
+ return [dict(r) for r in rows]
123
+
124
+
125
+ def get_session(session_id: str) -> dict | None:
126
+ db = get_db()
127
+ rows = db.sql(f"{SESSION_SELECT} WHERE cs.id = ?", [session_id])
128
+ return dict(rows[0]) if rows else None
129
+
130
+
131
+ def can_access_session(session: dict | None, user: dict | None) -> bool:
132
+ if not session or not user:
133
+ return False
134
+ if user.get("role") == "admin":
135
+ return True
136
+ return session.get("user_id") == user.get("name")
137
+
138
+
139
+ def delete_session(session_id: str):
140
+ """Delete a chat session, its messages, and its attachments."""
141
+ try:
142
+ from api.attachments import delete_session_attachments
143
+ delete_session_attachments(session_id)
144
+ except Exception:
145
+ pass
146
+ db = get_db()
147
+ db.sql('DELETE FROM "Chat Message" WHERE session_id = ?', [session_id])
148
+ db.sql('DELETE FROM "Chat Session" WHERE id = ?', [session_id])
149
+ db.conn.commit()
150
+
151
+
152
+ def update_session_title(session_id: str, title: str):
153
+ db = get_db()
154
+ db.sql('UPDATE "Chat Session" SET title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [title, session_id])
155
+ db.conn.commit()
156
+
157
+
158
+ def touch_session(session_id: str):
159
+ db = get_db()
160
+ db.sql('UPDATE "Chat Session" SET updated_at = CURRENT_TIMESTAMP WHERE id = ?', [session_id])
161
+ db.conn.commit()
162
+
163
+
164
+ def save_chat_message(session_id: str, role: str, content: str, message_type: str = "chat", metadata: dict = None):
165
+ """Save a chat message to the database."""
166
+ db = get_db()
167
+ metadata_json = json.dumps(metadata, default=str) if metadata else None
168
+ db.sql(
169
+ 'INSERT INTO "Chat Message" (session_id, role, message_type, content, metadata_json) VALUES (?, ?, ?, ?, ?)',
170
+ [session_id, role, message_type, content, metadata_json],
171
+ )
172
+ db.conn.commit()
173
+ touch_session(session_id)
174
+
175
+
176
+ def load_chat_history(session_id: str, limit: int = 50, before_id: int | None = None) -> list[dict]:
177
+ """Load recent chat messages for a session.
178
+
179
+ When `before_id` is provided, load messages strictly older than that id
180
+ (used for "Load older messages" pagination).
181
+ """
182
+ db = get_db()
183
+ if before_id:
184
+ rows = db.sql(
185
+ 'SELECT id, role, message_type, content, metadata_json, created_at '
186
+ 'FROM "Chat Message" WHERE session_id = ? AND id < ? ORDER BY id DESC LIMIT ?',
187
+ [session_id, int(before_id), limit],
188
+ )
189
+ else:
190
+ rows = db.sql(
191
+ 'SELECT id, role, message_type, content, metadata_json, created_at '
192
+ 'FROM "Chat Message" WHERE session_id = ? ORDER BY id DESC LIMIT ?',
193
+ [session_id, limit],
194
+ )
195
+ rows.reverse()
196
+ return [dict(r) for r in rows]
197
+
198
+
199
+ def build_conversation(session_id: str, limit: int = 20) -> list[dict]:
200
+ """Build the user/assistant conversation context for a session.
201
+
202
+ Demo-replay messages are included: the docs they narrate actually exist
203
+ (bootstrap's ensure_demo_chat_records creates them), so the history is an
204
+ accurate summary the LLM can build on when the user's first real message
205
+ refers back to "that quotation" or similar.
206
+ """
207
+ conversation = []
208
+ for row in load_chat_history(session_id, limit=max(limit * 2, 50)):
209
+ role = row["role"]
210
+ content = row.get("content", "")
211
+ if role in ("user", "assistant") and content:
212
+ conversation.append({"role": role, "content": content})
213
+ return conversation[-limit:]
214
+
215
+
216
+ def serialize_chat_message(
217
+ role: str,
218
+ content: str,
219
+ created_at: str | None = None,
220
+ attachments: list | None = None,
221
+ message_id: int | None = None,
222
+ ) -> dict:
223
+ msg = {
224
+ "role": role,
225
+ "content": content,
226
+ "created_at": created_at or now(),
227
+ }
228
+ if attachments:
229
+ msg["attachments"] = attachments
230
+ if message_id is not None:
231
+ msg["id"] = message_id
232
+ return msg
233
+
234
+
235
+ def load_serialized_chat_history(
236
+ session_id: str,
237
+ limit: int = 20,
238
+ before_id: int | None = None,
239
+ ) -> dict:
240
+ """Return a page of chat history plus pagination metadata.
241
+
242
+ Returns {messages, has_more, oldest_id}. `has_more` is True when older
243
+ messages exist beyond this page.
244
+ """
245
+ rows = load_chat_history(session_id, limit=limit, before_id=before_id)
246
+ history_messages = []
247
+ for row in rows:
248
+ role = row["role"]
249
+ content = row.get("content", "") or ""
250
+ if role not in ("user", "assistant"):
251
+ continue
252
+ attachments = None
253
+ meta_json = row.get("metadata_json")
254
+ if meta_json:
255
+ try:
256
+ meta = json.loads(meta_json)
257
+ attachments = meta.get("attachments")
258
+ except (json.JSONDecodeError, AttributeError):
259
+ pass
260
+ if content or attachments:
261
+ history_messages.append(
262
+ serialize_chat_message(
263
+ role, content, row.get("created_at"), attachments, row.get("id"),
264
+ )
265
+ )
266
+
267
+ oldest_id = history_messages[0].get("id") if history_messages else None
268
+ has_more = False
269
+ if oldest_id is not None:
270
+ db = get_db()
271
+ older = db.sql(
272
+ 'SELECT 1 FROM "Chat Message" WHERE session_id = ? AND id < ? LIMIT 1',
273
+ [session_id, int(oldest_id)],
274
+ )
275
+ has_more = bool(older)
276
+
277
+ return {
278
+ "messages": history_messages,
279
+ "has_more": has_more,
280
+ "oldest_id": oldest_id,
281
+ }
282
+
283
+
284
+ def clear_chat_history(session_id: str):
285
+ """Delete all messages (and attachments) in a session."""
286
+ db = get_db()
287
+ db.sql('DELETE FROM "Chat Message" WHERE session_id = ?', [session_id])
288
+ db.conn.commit()
289
+ try:
290
+ from api.attachments import delete_session_attachments
291
+ delete_session_attachments(session_id)
292
+ except Exception:
293
+ pass
294
+
295
+
296
+ def count_assistant_messages(session_id: str) -> int:
297
+ """Count assistant messages in a session (used to decide when to generate title)."""
298
+ db = get_db()
299
+ rows = db.sql(
300
+ 'SELECT COUNT(*) as cnt FROM "Chat Message" WHERE session_id = ? AND role = "assistant" AND message_type = "chat"',
301
+ [session_id],
302
+ )
303
+ return rows[0]["cnt"] if rows else 0
304
+
305
+
306
+ def load_demo_script() -> list[dict]:
307
+ """Load the scripted-chat replay and substitute runtime placeholders.
308
+
309
+ The JSON template references docs/values that only exist after the demo
310
+ bootstrap has run (quotation name, PO name, top-customer analytics…).
311
+ Those are written to the Settings table by api/bootstrap.py, so we pull
312
+ them here and do a simple {{KEY}} replacement on the string contents.
313
+ """
314
+ with open(DEMO_SCRIPT_PATH, "r", encoding="utf-8") as f:
315
+ data = json.load(f)
316
+
317
+ db = get_db()
318
+ settings_rows = db.sql(
319
+ 'SELECT key, value FROM "Settings" WHERE key LIKE "demo_chat_%"'
320
+ )
321
+ key_map = {
322
+ "demo_chat_company": "COMPANY",
323
+ "demo_chat_quotation": "DEMO_QUOTATION",
324
+ "demo_chat_purchase_order": "DEMO_PO",
325
+ # Top 3 customer ranking
326
+ "demo_chat_top1_id": "DEMO_TOP1_ID",
327
+ "demo_chat_top1_name": "DEMO_TOP1_NAME",
328
+ "demo_chat_top1_revenue": "DEMO_TOP1_REVENUE",
329
+ "demo_chat_top1_invoices": "DEMO_TOP1_INVOICES",
330
+ "demo_chat_top2_id": "DEMO_TOP2_ID",
331
+ "demo_chat_top2_name": "DEMO_TOP2_NAME",
332
+ "demo_chat_top2_revenue": "DEMO_TOP2_REVENUE",
333
+ "demo_chat_top3_id": "DEMO_TOP3_ID",
334
+ "demo_chat_top3_name": "DEMO_TOP3_NAME",
335
+ "demo_chat_top3_revenue": "DEMO_TOP3_REVENUE",
336
+ # Per-customer last invoice snapshots
337
+ "demo_chat_top1_last_inv": "DEMO_TOP1_LAST_INV",
338
+ "demo_chat_top1_last_inv_date": "DEMO_TOP1_LAST_INV_DATE",
339
+ "demo_chat_top1_last_inv_items": "DEMO_TOP1_LAST_INV_ITEMS",
340
+ "demo_chat_top2_last_inv": "DEMO_TOP2_LAST_INV",
341
+ "demo_chat_top2_last_inv_date": "DEMO_TOP2_LAST_INV_DATE",
342
+ "demo_chat_top2_last_inv_items": "DEMO_TOP2_LAST_INV_ITEMS",
343
+ "demo_chat_top3_last_inv": "DEMO_TOP3_LAST_INV",
344
+ "demo_chat_top3_last_inv_date": "DEMO_TOP3_LAST_INV_DATE",
345
+ "demo_chat_top3_last_inv_items": "DEMO_TOP3_LAST_INV_ITEMS",
346
+ # Analytics draft + follow-up sales invoice
347
+ "demo_chat_top7_report_id": "DEMO_TOP7_REPORT_ID",
348
+ "demo_chat_redstone_sinv": "DEMO_REDSTONE_SINV",
349
+ "demo_chat_redstone_sinv_date": "DEMO_REDSTONE_SINV_DATE",
350
+ "demo_chat_redstone_due_date": "DEMO_REDSTONE_DUE_DATE",
351
+ }
352
+ substitutions = {placeholder: "" for placeholder in key_map.values()}
353
+ for row in settings_rows:
354
+ placeholder = key_map.get(row["key"])
355
+ if placeholder:
356
+ substitutions[placeholder] = row["value"] or ""
357
+
358
+ def _sub(text: str) -> str:
359
+ for placeholder, value in substitutions.items():
360
+ text = text.replace("{{" + placeholder + "}}", value)
361
+ return text
362
+
363
+ result = []
364
+ for m in data:
365
+ if m.get("role") not in ("user", "assistant"):
366
+ continue
367
+ content = str(m.get("content", "")).strip()
368
+ if not content:
369
+ continue
370
+ entry = dict(m)
371
+ entry["content"] = _sub(content)
372
+ # Also substitute inside flash.item so placeholders like the custom
373
+ # analytics report id resolve to real identifiers the UI can match.
374
+ flash = entry.get("flash")
375
+ if isinstance(flash, dict):
376
+ entry["flash"] = {
377
+ k: _sub(str(v)) if isinstance(v, str) else v
378
+ for k, v in flash.items()
379
+ }
380
+ result.append(entry)
381
+ return result
382
+
383
+
384
+ def load_demo_history(session_id: str) -> list[dict]:
385
+ db = get_db()
386
+ rows = db.sql(
387
+ 'SELECT role, content, created_at FROM "Chat Message" '
388
+ 'WHERE session_id = ? AND message_type = "demo" ORDER BY id',
389
+ [session_id],
390
+ )
391
+ return [dict(r) for r in rows]
392
+
393
+
394
+ # ---------------------------------------------------------------------------
395
+ # REST endpoints for session management
396
+ # ---------------------------------------------------------------------------
397
+
398
+
399
+ @router.get("/sessions")
400
+ def api_list_sessions(user: dict = _Depends(get_current_user)):
401
+ return list_sessions(user_id=user["name"], role=user.get("role"))
402
+
403
+
404
+ @router.post("/sessions")
405
+ def api_create_session(user: dict = _Depends(get_current_user)):
406
+ return create_session(user_id=user["name"])
407
+
408
+
409
+ @router.get("/sessions/{session_id}")
410
+ def api_get_session(session_id: str, user: dict = _Depends(get_current_user)):
411
+ session = get_session(session_id)
412
+ if not can_access_session(session, user):
413
+ return {"detail": "Session not found"}
414
+ return session
415
+
416
+
417
+ @router.delete("/sessions/{session_id}")
418
+ def api_delete_session(session_id: str, user: dict = _Depends(get_current_user)):
419
+ session = get_session(session_id)
420
+ if not can_access_session(session, user):
421
+ return {"detail": "Session not found"}
422
+ delete_session(session_id)
423
+ return {"ok": True}
424
+
425
+
426
+ @router.put("/sessions/{session_id}/title")
427
+ def api_rename_session(session_id: str, data: dict, user: dict = _Depends(get_current_user)):
428
+ session = get_session(session_id)
429
+ if not can_access_session(session, user):
430
+ return {"detail": "Session not found"}
431
+ title = data.get("title", "").strip()
432
+ if title:
433
+ update_session_title(session_id, title)
434
+ return {"ok": True}
435
+
436
+
437
+ # ---------------------------------------------------------------------------
438
+ # Tool definitions (OpenAI function-calling format)
439
+ # ---------------------------------------------------------------------------
440
+
441
+ DOCUMENT_SLUGS = [
442
+ "quotation", "sales-order", "sales-invoice",
443
+ "purchase-order", "purchase-invoice",
444
+ "payment-entry", "journal-entry", "stock-entry",
445
+ "delivery-note", "purchase-receipt", "pos-invoice",
446
+ "pricing-rule", "budget", "subscription", "bank-transaction",
447
+ ]
448
+
449
+ MASTER_TYPES = ["customer", "supplier", "item", "warehouse", "account", "company", "cost-center"]
450
+
451
+ TOOLS = [
452
+ {
453
+ "type": "function",
454
+ "function": {
455
+ "name": "list_documents",
456
+ "description": "List or search documents of a given type. Returns an array of document summaries (header fields only — child tables like 'items' and 'taxes' are NOT included to keep results compact). Use get_document to drill into a single document's line items. Results are ordered by creation DESC (newest first).",
457
+ "parameters": {
458
+ "type": "object",
459
+ "properties": {
460
+ "doctype": {"type": "string", "enum": DOCUMENT_SLUGS, "description": "Document type slug"},
461
+ "filters": {"type": "object", "description": "Optional filters like {\"status\": \"Draft\", \"customer\": \"CUST-001\"}", "default": {}},
462
+ "limit": {"type": "integer", "description": "Max results (default 20, max 500)", "default": 20},
463
+ },
464
+ "required": ["doctype"],
465
+ },
466
+ },
467
+ },
468
+ {
469
+ "type": "function",
470
+ "function": {
471
+ "name": "get_document",
472
+ "description": "Load a specific document by its name/ID. Returns the full document with all fields and child tables.",
473
+ "parameters": {
474
+ "type": "object",
475
+ "properties": {
476
+ "doctype": {"type": "string", "enum": DOCUMENT_SLUGS},
477
+ "name": {"type": "string", "description": "Document name/ID, e.g. 'QTN-0001'"},
478
+ },
479
+ "required": ["doctype", "name"],
480
+ },
481
+ },
482
+ },
483
+ {
484
+ "type": "function",
485
+ "function": {
486
+ "name": "create_document",
487
+ "description": (
488
+ "Create a new draft document (docstatus=0). The document is saved but NOT submitted. IMPORTANT: you MUST pass the 'data' object with ALL document fields — doctype alone is not enough. Example: {\"doctype\": \"purchase-order\", \"data\": {\"supplier\": \"SUPP-001\", \"company\": \"My Co\", \"transaction_date\": \"2026-04-14\", \"items\": [{\"item_code\": \"ITEM-001\", \"qty\": 10, \"rate\": 100}]}}\n"
489
+ "Currency: `currency` is optional — it defaults to the customer/supplier's currency, else the company's base currency. To bill in a foreign currency, also pass `conversion_rate` = how many units of the company's base currency equal 1 unit of the document currency (no automatic FX lookup yet, so you MUST supply it for a foreign currency). Item rates stay in the document currency. Example (base USD, invoicing in EUR at 1 EUR = 1.10 USD): {\"doctype\": \"sales-invoice\", \"data\": {\"customer\": \"CUST-001\", \"company\": \"My Co\", \"posting_date\": \"2026-05-22\", \"currency\": \"EUR\", \"conversion_rate\": 1.10, \"items\": [{\"item_code\": \"ITEM-001\", \"qty\": 1, \"rate\": 100}]}}"
490
+ ),
491
+ "parameters": {
492
+ "type": "object",
493
+ "properties": {
494
+ "doctype": {"type": "string", "enum": DOCUMENT_SLUGS},
495
+ "data": {"type": "object", "description": "REQUIRED. All document fields. Must include: supplier/customer, company, date, and items array. Optional: currency + conversion_rate (see description) for foreign-currency documents. Without this the call will fail."},
496
+ },
497
+ "required": ["doctype", "data"],
498
+ },
499
+ },
500
+ },
501
+ {
502
+ "type": "function",
503
+ "function": {
504
+ "name": "update_document",
505
+ "description": "Update fields on an existing draft document (docstatus=0). Only drafts can be edited. You MUST include the `data` object with the fields to change. For parent fields: {\"data\": {\"company\": \"X\"}}. For child table fields (like setting warehouse on items): first call get_document to see the current items, then pass the full items array back with your changes: {\"data\": {\"items\": [{\"item_code\": \"ITEM-001\", \"qty\": 10, \"rate\": 100, \"warehouse\": \"WH-001\"}]}}. To change the document's currency, pass `currency` (and `conversion_rate` for a foreign currency, as in create_document).",
506
+ "parameters": {
507
+ "type": "object",
508
+ "properties": {
509
+ "doctype": {"type": "string", "enum": DOCUMENT_SLUGS},
510
+ "name": {"type": "string", "description": "Document name/ID to update"},
511
+ "data": {"type": "object", "description": "Fields to change. MUST be provided. For child tables like items, pass the complete array."},
512
+ },
513
+ "required": ["doctype", "name", "data"],
514
+ },
515
+ },
516
+ },
517
+ {
518
+ "type": "function",
519
+ "function": {
520
+ "name": "submit_document",
521
+ "description": "Submit a draft document (changes docstatus from 0 to 1). This posts GL entries, stock entries, etc. Only works on drafts (docstatus=0). Once submitted, the document is locked and can only be cancelled, not edited.",
522
+ "parameters": {
523
+ "type": "object",
524
+ "properties": {
525
+ "doctype": {"type": "string", "enum": DOCUMENT_SLUGS},
526
+ "name": {"type": "string"},
527
+ },
528
+ "required": ["doctype", "name"],
529
+ },
530
+ },
531
+ },
532
+ {
533
+ "type": "function",
534
+ "function": {
535
+ "name": "cancel_document",
536
+ "description": "Cancel a submitted document (reverses GL/stock entries). Only works on submitted documents (docstatus=1). To void a draft, submit it first then cancel it. There is no delete — cancel is the only way to void a document.",
537
+ "parameters": {
538
+ "type": "object",
539
+ "properties": {
540
+ "doctype": {"type": "string", "enum": DOCUMENT_SLUGS},
541
+ "name": {"type": "string"},
542
+ },
543
+ "required": ["doctype", "name"],
544
+ },
545
+ },
546
+ },
547
+ {
548
+ "type": "function",
549
+ "function": {
550
+ "name": "convert_document",
551
+ "description": "Convert a submitted document to the next type in the workflow, OR create a return. Forward: Quotation -> Sales Order or Sales Invoice or Delivery Note, Sales Order -> Sales Invoice or Delivery Note, Purchase Order -> Purchase Invoice or Purchase Receipt. Returns: convert to the SAME type (e.g. Sales Invoice -> Sales Invoice creates a Credit Note, Purchase Invoice -> Purchase Invoice creates a Debit Note, Delivery Note -> Delivery Note or Purchase Receipt -> Purchase Receipt for stock returns).",
552
+ "parameters": {
553
+ "type": "object",
554
+ "properties": {
555
+ "doctype": {"type": "string", "enum": DOCUMENT_SLUGS, "description": "Source document type slug"},
556
+ "name": {"type": "string", "description": "Source document name"},
557
+ "target_doctype": {"type": "string", "description": "Target document type DISPLAY NAME, e.g. 'Sales Order', 'Delivery Note'"},
558
+ },
559
+ "required": ["doctype", "name", "target_doctype"],
560
+ },
561
+ },
562
+ },
563
+ {
564
+ "type": "function",
565
+ "function": {
566
+ "name": "search_masters",
567
+ "description": "Search master data (customers, suppliers, items, warehouses, accounts, companies, cost centers). Returns matching records.",
568
+ "parameters": {
569
+ "type": "object",
570
+ "properties": {
571
+ "master_type": {"type": "string", "enum": MASTER_TYPES},
572
+ "query": {"type": "string", "description": "Search term (empty string returns all)", "default": ""},
573
+ },
574
+ "required": ["master_type"],
575
+ },
576
+ },
577
+ },
578
+ {
579
+ "type": "function",
580
+ "function": {
581
+ "name": "create_master",
582
+ "description": (
583
+ "Create a new master record. You MUST include the full `data` object using the EXACT field names listed below — unknown fields are silently dropped.\n\n"
584
+ "Every record's id is `name`. Customer/Supplier/Item/Warehouse auto-generate it (CUST-/SUPP-/ITEM-/WH-NNN) when omitted; Company/Account/Cost Center have NO auto-id, so `name` is required for them.\n\n"
585
+ "**Customer fields:** name (optional custom id; auto CUST-NNN), customer_name (required), customer_group, territory, default_currency, credit_limit, email, phone, address, city, zip_code, country, tax_id.\n"
586
+ "**Supplier fields:** name (optional custom id; auto SUPP-NNN), supplier_name (required), supplier_group, default_currency, email, phone, address, city, zip_code, country, tax_id.\n"
587
+ "**Item fields:** item_code (the unique item code/id, e.g. \"SVC-SPARK\" — optional, may use ANY prefix, not just ITEM; auto-generated as ITEM-NNN if omitted), item_name (required, the human-readable name), item_group, stock_uom, standard_rate, is_stock_item, default_warehouse, description.\n"
588
+ "**Warehouse fields:** name (optional custom id; auto WH-NNN), warehouse_name (required), company, parent_warehouse (omit or null when not needed), address, city, zip_code, country.\n"
589
+ "**Company fields:** name (the company id — optional, defaults to company_name), company_name (required), default_currency, email, phone, address, city, zip_code, country, tax_id.\n"
590
+ "**Account fields:** name (REQUIRED — full account id, conventionally \"<account_name> - <company abbr>\", e.g. \"Marketing Expenses - LAMB\"), account_name (required), company (required), root_type (Asset/Liability/Equity/Income/Expense), report_type (\"Balance Sheet\" or \"Profit and Loss\"), account_type (e.g. Receivable, Payable, Bank, Cash, Stock, Tax), parent_account, account_currency, is_group (0/1).\n"
591
+ "**Cost Center fields:** name (REQUIRED — e.g. \"Marketing - LAMB\"), cost_center_name (required), company (required), parent_cost_center, is_group (0/1).\n\n"
592
+ "zip_code is free text (e.g. \"8400\", \"ZH 8400\", \"59123\"), never numeric.\n"
593
+ "Supplier example: {\"master_type\":\"supplier\",\"data\":{\"supplier_name\":\"Schlafteq\",\"email\":\"jacob@schlafteq.ch\",\"phone\":\"+1 555-0104\",\"address\":\"145 Harbor Rd\",\"city\":\"Seattle\",\"zip_code\":\"98101\",\"country\":\"US\",\"tax_id\":\"98-7654321\"}}\n"
594
+ "Item example (custom code): {\"master_type\":\"item\",\"data\":{\"item_code\":\"SVC-SPARK\",\"item_name\":\"Spark\",\"item_group\":\"Services\",\"is_stock_item\":0,\"standard_rate\":310}}"
595
+ ),
596
+ "parameters": {
597
+ "type": "object",
598
+ "properties": {
599
+ "master_type": {"type": "string", "enum": MASTER_TYPES},
600
+ "data": {"type": "object", "description": "REQUIRED. Full master data payload using the field names listed in the tool description."},
601
+ },
602
+ "required": ["master_type", "data"],
603
+ },
604
+ },
605
+ },
606
+ {
607
+ "type": "function",
608
+ "function": {
609
+ "name": "update_master",
610
+ "description": (
611
+ "Update an existing master record. You MUST include the `name` of the existing record and a `data` object with the fields to change. Use the same field names listed in create_master (customer: customer_name, email, phone, address, city, zip_code, country, tax_id, etc.). Example: {\"master_type\":\"customer\",\"name\":\"CUST-001\",\"data\":{\"address\":\"123 New Street\",\"city\":\"Boston\",\"zip_code\":\"02110\"}}"
612
+ ),
613
+ "parameters": {
614
+ "type": "object",
615
+ "properties": {
616
+ "master_type": {"type": "string", "enum": MASTER_TYPES},
617
+ "name": {"type": "string", "description": "Existing master record ID/name to update."},
618
+ "data": {"type": "object", "description": "REQUIRED. Fields to change on the existing master record."},
619
+ },
620
+ "required": ["master_type", "name", "data"],
621
+ },
622
+ },
623
+ },
624
+ {
625
+ "type": "function",
626
+ "function": {
627
+ "name": "get_report",
628
+ "description": "Run a financial or stock report.",
629
+ "parameters": {
630
+ "type": "object",
631
+ "properties": {
632
+ "report_type": {"type": "string", "enum": ["trial-balance", "general-ledger", "stock-balance", "dashboard-summary", "profit-and-loss", "balance-sheet", "ar-aging", "ap-aging"]},
633
+ "filters": {"type": "object", "description": "Optional filters: company, account, from_date, to_date, item_code, warehouse. For trial-balance / profit-and-loss / balance-sheet you may also pass `presentation_currency` (e.g. \"EUR\") to view the report translated from the company's base currency at the period's closing rate — display only, the ledger stays in base currency.", "default": {}},
634
+ },
635
+ "required": ["report_type"],
636
+ },
637
+ },
638
+ },
639
+ {
640
+ "type": "function",
641
+ "function": {
642
+ "name": "revalue_currencies",
643
+ "description": "Preview or post period-end FX revaluation of open foreign-currency balances (foreign receivables, payables, and bank/cash accounts). Restates them to the closing rate and books the unrealized gain/loss, plus a next-day auto-reversal. Use post=false (default) to preview the exposure without posting; post=true to commit. Returns a per-balance breakdown and the net unrealized P&L impact.",
644
+ "parameters": {
645
+ "type": "object",
646
+ "properties": {
647
+ "company": {"type": "string", "description": "Company name. Defaults to the only/first company."},
648
+ "date": {"type": "string", "description": "Period-end date (YYYY-MM-DD). Defaults to today. The closing rate is looked up for this date."},
649
+ "post": {"type": "boolean", "description": "false = preview only (no GL); true = post the revaluation + next-day reversal.", "default": False},
650
+ },
651
+ },
652
+ },
653
+ },
654
+ {
655
+ "type": "function",
656
+ "function": {
657
+ "name": "get_current_time",
658
+ "description": "Get the current date and time.",
659
+ "parameters": {"type": "object", "properties": {}},
660
+ },
661
+ },
662
+ {
663
+ "type": "function",
664
+ "function": {
665
+ "name": "retrieve_chat_history",
666
+ "description": "Retrieve earlier messages from the conversation history. Use this when the user references something said earlier, asks a follow-up to a previous topic, or you need context from past messages. The most recent messages are already in context — this tool fetches older ones.",
667
+ "parameters": {
668
+ "type": "object",
669
+ "properties": {
670
+ "num_messages": {"type": "integer", "description": "Number of recent messages to retrieve (default 20, max 50)."},
671
+ "date_from": {"type": "string", "description": "Start of date range (ISO 8601)."},
672
+ "date_to": {"type": "string", "description": "End of date range (ISO 8601)."},
673
+ },
674
+ },
675
+ },
676
+ },
677
+ {
678
+ "type": "function",
679
+ "function": {
680
+ "name": "list_chat_attachments",
681
+ "description": "List all files (PDFs, images) the user has uploaded in this chat session. Returns metadata including id, filename, mime type, size, and upload date. Use this when the user references a previously uploaded file or you need to find an attachment to retrieve.",
682
+ "parameters": {"type": "object", "properties": {}},
683
+ },
684
+ },
685
+ {
686
+ "type": "function",
687
+ "function": {
688
+ "name": "retrieve_chat_attachment",
689
+ "description": "Retrieve a previously uploaded file (PDF or image) by its id and inject it back into the conversation so you can read/analyze it. Use this when the user asks a follow-up about a file they uploaded earlier in the chat but it's no longer in the immediate context. Call list_chat_attachments first if you don't know the id.",
690
+ "parameters": {
691
+ "type": "object",
692
+ "properties": {
693
+ "attachment_id": {"type": "string", "description": "The id of the attachment (from list_chat_attachments)."},
694
+ },
695
+ "required": ["attachment_id"],
696
+ },
697
+ },
698
+ },
699
+ {
700
+ "type": "function",
701
+ "function": {
702
+ "name": "query_dataset",
703
+ "description": (
704
+ "Run a deterministic SQL GROUP BY aggregation over a semantic "
705
+ "dataset and return the aggregated rows. Use this for factual "
706
+ "answers that require aggregating many rows (top customer by "
707
+ "revenue, total sales this month, outstanding AR by customer, "
708
+ "count of invoices per supplier, etc.) — instead of calling "
709
+ "`list_documents` and adding things up in your head.\n\n"
710
+ "Available datasets (same as for custom analytics):\n"
711
+ "- `sales_invoices` — submitted sales invoices (includes returns)\n"
712
+ "- `sales_invoice_lines` — submitted sales invoice line items\n"
713
+ "- `purchase_invoices` — submitted purchase invoices (includes returns)\n"
714
+ "- `purchase_invoice_lines` — submitted purchase invoice line items\n"
715
+ "- `payments` — submitted payment entries\n"
716
+ "- `ar_open_items` — outstanding sales invoice amounts\n"
717
+ "- `ap_open_items` — outstanding purchase invoice amounts\n"
718
+ "- `stock_balances` — current stock on hand by item × warehouse\n"
719
+ "- `stock_movements` — stock ledger entries (movement history); "
720
+ "`abs_qty` = units moved (use `sum`), `actual_qty` = net flow\n\n"
721
+ "Shape your call like: `{dataset, group_by, measures, filters?, "
722
+ "order_by?, limit?}`. `group_by` is a list of field names. "
723
+ "`measures` is an object of `{alias: [op, field]}` where `op` "
724
+ "is one of sum, count, avg, min, max (count may omit field). "
725
+ "`filters` is a dict keyed by field name — same shape as for "
726
+ "`create_custom_analytics_report` (equality, list for IN, "
727
+ "`{from, to}` for ranges). `order_by` is a list of "
728
+ "`{field, direction}` where `field` is a group_by field or a "
729
+ "measure alias and `direction` is asc/desc.\n\n"
730
+ "Example — top 5 customers by revenue:\n"
731
+ "```json\n"
732
+ "{\"dataset\": \"sales_invoices\", "
733
+ "\"group_by\": [\"customer\", \"customer_name\"], "
734
+ "\"measures\": {\"revenue\": [\"sum\", \"net_total\"], "
735
+ "\"invoices\": [\"count\"]}, "
736
+ "\"filters\": {\"is_return\": 0}, "
737
+ "\"order_by\": [{\"field\": \"revenue\", \"direction\": \"desc\"}], "
738
+ "\"limit\": 5}\n"
739
+ "```\n\n"
740
+ "Only reference fields that are exposed by the chosen dataset. "
741
+ "Only filter on the dataset's `filter_fields`. Do not invent "
742
+ "fields. The tool returns `{rows: [{…group fields, …measure "
743
+ "aliases}], row_count, truncated}` — you can cite these numbers "
744
+ "directly in your reply."
745
+ ),
746
+ "parameters": {
747
+ "type": "object",
748
+ "properties": {
749
+ "dataset": {
750
+ "type": "string",
751
+ "enum": [
752
+ "sales_invoices",
753
+ "sales_invoice_lines",
754
+ "purchase_invoices",
755
+ "purchase_invoice_lines",
756
+ "payments",
757
+ "ar_open_items",
758
+ "ap_open_items",
759
+ "stock_balances",
760
+ ],
761
+ },
762
+ "group_by": {
763
+ "type": "array",
764
+ "items": {"type": "string"},
765
+ },
766
+ "measures": {
767
+ "type": "object",
768
+ "description": "Map of alias -> [op, field]. op ∈ sum|count|avg|min|max.",
769
+ },
770
+ "filters": {"type": "object"},
771
+ "order_by": {
772
+ "type": "array",
773
+ "items": {
774
+ "type": "object",
775
+ "properties": {
776
+ "field": {"type": "string"},
777
+ "direction": {"type": "string", "enum": ["asc", "desc"]},
778
+ },
779
+ "required": ["field"],
780
+ },
781
+ },
782
+ "limit": {"type": "integer"},
783
+ },
784
+ "required": ["dataset", "measures"],
785
+ },
786
+ },
787
+ },
788
+ {
789
+ "type": "function",
790
+ "function": {
791
+ "name": "create_custom_analytics_report",
792
+ "description": (
793
+ "Create a draft custom analytics report that opens in "
794
+ "/reports/analytics?report_id=... . Use this when the user wants "
795
+ "a bespoke chart/table that goes beyond the preset metric × "
796
+ "group_by analytics.\n\n"
797
+ "**Preferred usage:** provide `intent` as a plain-language "
798
+ "description of what the user wants. The backend will hand "
799
+ "`intent` to the code specialist model (Anthropic) to generate "
800
+ "`data_requests` + `transform_js` for you, then persist the "
801
+ "draft. You do NOT need to write the code yourself — your job "
802
+ "is to understand the user's request and pass a clear `intent`. "
803
+ "Only pass `data_requests` + `transform_js` yourself if you "
804
+ "explicitly want to bypass the code specialist.\n\n"
805
+ "Available semantic datasets include: sales_invoices, "
806
+ "sales_invoice_lines, purchase_invoices, purchase_invoice_lines, "
807
+ "payments, ar_open_items, ap_open_items, stock_balances, "
808
+ "stock_movements.\n\n"
809
+ "Each data_request may include: name, dataset, fields, filters, "
810
+ "limit. The JS runs client-side over the fetched datasets only; "
811
+ "it does NOT have SQL, network, or DOM access. The transform "
812
+ "should end with `return { ... }`. These datasets already focus "
813
+ "on the accounting-relevant submitted/open records described in "
814
+ "their names, so avoid redundant filters like `docstatus = 1` "
815
+ "unless you truly need to surface that field in the output. "
816
+ "Only request fields that are explicitly exposed by the chosen "
817
+ "semantic dataset; do not invent fields like `base_grand_total` "
818
+ "or other ERP-style variants unless the dataset metadata showed "
819
+ "that exact field name.\n\n"
820
+ "Choose chart types deliberately. Use `bar` for ranked lists, "
821
+ "category comparisons, month-by-month business totals, and most "
822
+ "discrete bucketed ERP reporting. Use `line` when the main goal "
823
+ "is to show a continuous trend over time across many periods, "
824
+ "especially when the user explicitly asks for a trend line. Use "
825
+ "`pie` only for simple part-of-whole breakdowns with a small "
826
+ "number of categories. If unsure between `bar` and `line`, "
827
+ "prefer `bar`. If the user asks for a graph, chart, visual, "
828
+ "breakdown, or comparison, include at least one chart in "
829
+ "`charts[]` rather than returning only a table. Table-only "
830
+ "output is appropriate only when the user explicitly asked for "
831
+ "just a table or list.\n\n"
832
+ "Use only the supported runtime helper patterns: "
833
+ "`helpers.sum(rows, 'field')` or `helpers.sum(rows, row => ...)`; "
834
+ "`helpers.sortBy(rows, 'field', 'asc'|'desc'|true)`; "
835
+ "`helpers.topN(rows, 'field', n)` or `helpers.topN(rows, n)` if "
836
+ "already sorted; `helpers.group(rows, ['field1', ...], "
837
+ "{ alias: ['sum'|'count', 'field'] })` or "
838
+ "`helpers.group(rows, row => key)` which returns "
839
+ "`[{ key, rows }]`; plus `helpers.monthKey(...)`, "
840
+ "`helpers.quarterKey(...)`, `helpers.yearKey(...)`, "
841
+ "`helpers.leftJoin(...)`, and `helpers.pivot(...)`. Do not use "
842
+ "unsupported shapes like `helpers.sortBy(rows, row => ...)` or "
843
+ "`helpers.group(rows, keyFn, reducerFn)`.\n\n"
844
+ "Return charts and tables in the supported shape. A chart should "
845
+ "use `{ title, type, x, y, dataTable? , data? }` where `type` is "
846
+ "`bar`, `line`, or `pie`. Prefer `dataTable` when the chart is "
847
+ "based on one of your returned tables, otherwise use inline "
848
+ "`data`. The `y` field MUST be a single string field name such "
849
+ "as `revenue` or `net_sales` — never an array, never `['revenue']`, "
850
+ "and never multi-series keys. Do not use unsupported keys like "
851
+ "`x_key` or `y_keys`. "
852
+ "A table should use `{ title, columns, rows }` where each column "
853
+ "uses `{ key, label, type? }` and `type` is things like "
854
+ "`currency`, `number`, `string`, or `date`.\n\n"
855
+ "Canonical example: top customers by revenue. Use "
856
+ "`sales_invoices` with fields like `posting_date`, `customer`, "
857
+ "`grand_total`; map rows into `{ customer, revenue }`; group with "
858
+ "`helpers.group(rows, ['customer'], { revenue: ['sum', 'revenue'] })`; "
859
+ "sort descending by `revenue`; take `helpers.topN(..., 'revenue', 10)`; "
860
+ "and return a bar chart with `x: 'customer'`, `y: 'revenue'`, "
861
+ "and inline `data: top10`.\n\n"
862
+ "Canonical example: best selling items by quantity. Use "
863
+ "`sales_invoice_lines` with fields `posting_date`, `item_code`, `qty`; "
864
+ "clean into `{ item_code, qty }`; group with "
865
+ "`helpers.group(clean, ['item_code'], { qty_sold: ['sum', 'qty'], "
866
+ "line_count: ['count', 'qty'] })`; sort descending by `qty_sold`; "
867
+ "take `helpers.topN(sorted, 10)`; and return a bar chart with "
868
+ "`x: 'item_code'`, `y: 'qty_sold'`, and inline `data: top10`. "
869
+ "Do not request unsupported line fields like `item_name`, `amount`, "
870
+ "or `grand_total` from `sales_invoice_lines`.\n\n"
871
+ "For the first version, prefer simple transforms using helpers "
872
+ "like helpers.group(...), helpers.sortBy(...), helpers.topN(...), "
873
+ "helpers.monthKey(...), helpers.sum(...). Always include the URL "
874
+ "returned by this tool verbatim in your response."
875
+ ),
876
+ "parameters": {
877
+ "type": "object",
878
+ "properties": {
879
+ "title": {"type": "string"},
880
+ "description": {"type": "string"},
881
+ "intent": {
882
+ "type": "string",
883
+ "description": (
884
+ "Plain-language description of what the user wants "
885
+ "in this report — the code specialist will use this "
886
+ "to generate data_requests + transform_js. Include "
887
+ "any specific filters, groupings, sort orders, or "
888
+ "chart preferences the user mentioned."
889
+ ),
890
+ },
891
+ "data_requests": {
892
+ "type": "array",
893
+ "items": {
894
+ "type": "object",
895
+ "properties": {
896
+ "name": {"type": "string"},
897
+ "dataset": {
898
+ "type": "string",
899
+ "enum": [
900
+ "sales_invoices",
901
+ "sales_invoice_lines",
902
+ "purchase_invoices",
903
+ "purchase_invoice_lines",
904
+ "payments",
905
+ "ar_open_items",
906
+ "ap_open_items",
907
+ "stock_balances",
908
+ ],
909
+ },
910
+ "fields": {"type": "array", "items": {"type": "string"}},
911
+ "filters": {"type": "object"},
912
+ "limit": {"type": "integer"},
913
+ },
914
+ "required": ["dataset"],
915
+ },
916
+ },
917
+ "transform_js": {"type": "string"},
918
+ },
919
+ "required": ["title"],
920
+ },
921
+ },
922
+ },
923
+ {
924
+ "type": "function",
925
+ "function": {
926
+ "name": "get_custom_analytics_report",
927
+ "description": (
928
+ "Load an existing custom analytics draft by report_id. Use this "
929
+ "when the user says a draft report is broken, wants to refine an "
930
+ "existing report, or references a /reports/analytics?report_id=... link."
931
+ ),
932
+ "parameters": {
933
+ "type": "object",
934
+ "properties": {
935
+ "report_id": {"type": "string"},
936
+ },
937
+ "required": ["report_id"],
938
+ },
939
+ },
940
+ },
941
+ {
942
+ "type": "function",
943
+ "function": {
944
+ "name": "update_custom_analytics_report",
945
+ "description": (
946
+ "Update an existing custom analytics draft in place while "
947
+ "keeping the same report_id and URL. Load the current draft "
948
+ "with `get_custom_analytics_report` first so you understand "
949
+ "what it does.\n\n"
950
+ "**Preferred usage:** provide `report_id` and `feedback` — a "
951
+ "plain-language description of what needs to change (e.g. "
952
+ "\"chart is empty because grand_total is not in "
953
+ "sales_invoice_lines; use amount instead\", or \"add a line "
954
+ "chart showing monthly trend\"). The backend will hand both "
955
+ "the current draft and your feedback to the code specialist "
956
+ "model (Anthropic) to rewrite the spec. You do NOT need to "
957
+ "write code yourself.\n\n"
958
+ "Only pass `data_requests` or `transform_js` directly if you "
959
+ "want to bypass the code specialist for a trivial change."
960
+ ),
961
+ "parameters": {
962
+ "type": "object",
963
+ "properties": {
964
+ "report_id": {"type": "string"},
965
+ "feedback": {
966
+ "type": "string",
967
+ "description": (
968
+ "Description of what should change — the code "
969
+ "specialist receives this alongside the existing "
970
+ "draft and rewrites the spec."
971
+ ),
972
+ },
973
+ "title": {"type": "string"},
974
+ "description": {"type": "string"},
975
+ "data_requests": {
976
+ "type": "array",
977
+ "items": {
978
+ "type": "object",
979
+ "properties": {
980
+ "name": {"type": "string"},
981
+ "dataset": {
982
+ "type": "string",
983
+ "enum": [
984
+ "sales_invoices",
985
+ "sales_invoice_lines",
986
+ "purchase_invoices",
987
+ "purchase_invoice_lines",
988
+ "payments",
989
+ "ar_open_items",
990
+ "ap_open_items",
991
+ "stock_balances",
992
+ ],
993
+ },
994
+ "fields": {"type": "array", "items": {"type": "string"}},
995
+ "filters": {"type": "object"},
996
+ "limit": {"type": "integer"},
997
+ },
998
+ "required": ["dataset"],
999
+ },
1000
+ },
1001
+ "transform_js": {"type": "string"},
1002
+ },
1003
+ "required": ["report_id"],
1004
+ },
1005
+ },
1006
+ },
1007
+ ]
1008
+
1009
+ # ---------------------------------------------------------------------------
1010
+ # Tool handlers — dispatch to existing service functions
1011
+ # ---------------------------------------------------------------------------
1012
+
1013
+
1014
+ def _handle_list_documents(args):
1015
+ # List views don't need child tables — strip them so the LLM can see more rows
1016
+ # within the tool-result budget. Use get_document to drill into one doc.
1017
+ cls_entry = services.DOCUMENT_CLASSES.get(services.SLUG_TO_DOCTYPE.get(args["doctype"], ""))
1018
+ child_keys = list(cls_entry.CHILD_TABLES.keys()) if cls_entry and cls_entry.CHILD_TABLES else []
1019
+
1020
+ rows = services.list_documents(
1021
+ args["doctype"],
1022
+ filters=args.get("filters"),
1023
+ limit=args.get("limit", 20),
1024
+ )
1025
+ for row in rows:
1026
+ for key in child_keys:
1027
+ row.pop(key, None)
1028
+ return rows
1029
+
1030
+
1031
+ def _handle_get_document(args):
1032
+ return services.load_document(args["doctype"], args["name"])
1033
+
1034
+
1035
+ def _handle_create_document(args):
1036
+ data = args.get("data")
1037
+ if not data:
1038
+ return {"error": "You must pass a 'data' object with the document fields (supplier/customer, company, items, etc.). You only passed the doctype."}
1039
+ return services.create_document(args["doctype"], data)
1040
+
1041
+
1042
+ def _handle_update_document(args):
1043
+ return services.update_document(args["doctype"], args["name"], args.get("data", {}))
1044
+
1045
+
1046
+ def _handle_submit_document(args):
1047
+ return services.submit_document(args["doctype"], args["name"])
1048
+
1049
+
1050
+ def _handle_cancel_document(args):
1051
+ return services.cancel_document(args["doctype"], args["name"])
1052
+
1053
+
1054
+ def _handle_convert_document(args):
1055
+ return services.convert_document(args["doctype"], args["name"], args["target_doctype"])
1056
+
1057
+
1058
+ def _handle_search_masters(args):
1059
+ db = get_db()
1060
+ master_type = args["master_type"]
1061
+ query = args.get("query", "")
1062
+
1063
+ entry = services.MASTER_TABLES.get(master_type)
1064
+ if not entry:
1065
+ return {"error": f"Unknown master type: {master_type}"}
1066
+
1067
+ doctype, name_field = entry
1068
+ active_prefix = 'disabled = 0 AND ' if "disabled" in db._get_table_columns(doctype) else ""
1069
+ if not query:
1070
+ filters = {"disabled": 0} if "disabled" in db._get_table_columns(doctype) else None
1071
+ return db.get_all(doctype, filters=filters, fields=["*"], limit=20)
1072
+
1073
+ rows = db.sql(
1074
+ f'SELECT * FROM "{doctype}" WHERE {active_prefix}(name LIKE ? OR "{name_field}" LIKE ?) LIMIT 20',
1075
+ [f"%{query}%", f"%{query}%"],
1076
+ )
1077
+ return [dict(r) for r in rows]
1078
+
1079
+
1080
+ def _ignored_master_fields(master_type: str, data: dict) -> list[str]:
1081
+ """Return field names in `data` that aren't valid columns on the master's table."""
1082
+ entry = services.MASTER_TABLES.get(master_type)
1083
+ if not entry:
1084
+ return []
1085
+ doctype, _ = entry
1086
+ from lambda_erp.database import get_db
1087
+ valid = set(get_db()._get_table_columns(doctype))
1088
+ # item_code is a recognized alias for the Item's `name` PK, not an unknown
1089
+ # field — don't warn that it was ignored when it was actually honored.
1090
+ alias = MASTER_IDENTITY_ALIAS.get(master_type)
1091
+ if alias:
1092
+ valid.add(alias)
1093
+ return [k for k in data.keys() if k not in valid]
1094
+
1095
+
1096
+ def _handle_create_master(args):
1097
+ master_type = args["master_type"]
1098
+ data = args.get("data")
1099
+ if not data:
1100
+ return {"error": "You must pass a 'data' object with the master fields. For a warehouse, include at least 'warehouse_name' and 'company'. 'parent_warehouse' is optional."}
1101
+
1102
+ if master_type == "warehouse" and data.get("parent_warehouse") in ("", "-", "none", "None", None):
1103
+ data = {k: v for k, v in data.items() if k != "parent_warehouse"}
1104
+
1105
+ ignored = _ignored_master_fields(master_type, data)
1106
+
1107
+ try:
1108
+ result = dict(create_master_record(master_type, data))
1109
+ except Exception as exc:
1110
+ detail = getattr(exc, "detail", None)
1111
+ return {"error": detail or str(exc)}
1112
+
1113
+ if ignored:
1114
+ result["_warning"] = (
1115
+ f"These fields were IGNORED because they are not valid columns on the {master_type}: "
1116
+ f"{ignored}. Retry the call using the correct field names from the create_master tool description."
1117
+ )
1118
+ return result
1119
+
1120
+
1121
+ def _handle_update_master(args):
1122
+ master_type = args["master_type"]
1123
+ name = args.get("name")
1124
+ data = args.get("data")
1125
+ if not name:
1126
+ return {"error": "You must pass the existing master record 'name' to update. Example: {\"master_type\":\"supplier\",\"name\":\"SUPP-001\",\"data\":{\"email\":\"new@example.com\"}}"}
1127
+ if not data:
1128
+ return {"error": f"You must pass a 'data' object with the fields to change. Example: {{\"master_type\":\"{master_type}\",\"name\":\"{name}\",\"data\":{{\"email\":\"foo@bar.com\",\"phone\":\"+1 555-0100\"}}}}. Valid fields for {master_type} are listed in the create_master tool description."}
1129
+
1130
+ if master_type == "warehouse" and data.get("parent_warehouse") in ("", "-", "none", "None", None):
1131
+ data = {k: v for k, v in data.items() if k != "parent_warehouse"}
1132
+
1133
+ ignored = _ignored_master_fields(master_type, data)
1134
+
1135
+ try:
1136
+ result = dict(update_master_record(master_type, name, data))
1137
+ except Exception as exc:
1138
+ detail = getattr(exc, "detail", None)
1139
+ return {"error": detail or str(exc)}
1140
+
1141
+ if ignored:
1142
+ result["_warning"] = (
1143
+ f"These fields were IGNORED because they are not valid columns on the {master_type}: "
1144
+ f"{ignored}. Retry the call using the correct field names."
1145
+ )
1146
+ return result
1147
+
1148
+
1149
+ def _handle_get_current_time(args):
1150
+ from datetime import datetime, timezone
1151
+ now = datetime.now(timezone.utc)
1152
+ return {
1153
+ "utc_time": now.strftime("%Y-%m-%d %H:%M:%S UTC"),
1154
+ "date": now.strftime("%Y-%m-%d"),
1155
+ "weekday": now.strftime("%A"),
1156
+ }
1157
+
1158
+
1159
+ def _handle_retrieve_chat_history(args, session_id=None):
1160
+ db = get_db()
1161
+ date_from = args.get("date_from")
1162
+ date_to = args.get("date_to")
1163
+
1164
+ session_clause = 'AND session_id = ?' if session_id else ''
1165
+ session_params = [session_id] if session_id else []
1166
+
1167
+ if date_from:
1168
+ params = session_params + [date_from]
1169
+ date_clause = 'AND created_at >= ?'
1170
+ if date_to:
1171
+ date_clause += ' AND created_at <= ?'
1172
+ params.append(date_to)
1173
+ rows = db.sql(
1174
+ f'SELECT role, content, created_at FROM "Chat Message" '
1175
+ f'WHERE role IN ("user", "assistant") {session_clause} {date_clause} '
1176
+ f'ORDER BY id ASC LIMIT 50',
1177
+ params,
1178
+ )
1179
+ else:
1180
+ num = min(int(args.get("num_messages", 20)), 50)
1181
+ rows = db.sql(
1182
+ f'SELECT role, content, created_at FROM "Chat Message" '
1183
+ f'WHERE role IN ("user", "assistant") {session_clause} '
1184
+ f'ORDER BY id DESC LIMIT ?',
1185
+ session_params + [num],
1186
+ )
1187
+ rows.reverse()
1188
+
1189
+ return [
1190
+ {"role": m["role"], "content": (m.get("content") or "")[:2000], "created_at": m.get("created_at")}
1191
+ for m in rows
1192
+ ]
1193
+
1194
+
1195
+ def _handle_get_report(args):
1196
+ report_type = args["report_type"]
1197
+ filters = args.get("filters", {})
1198
+ db = get_db()
1199
+
1200
+ if report_type == "trial-balance":
1201
+ from api.routers.reports import _trial_balance, _present
1202
+ rep = _trial_balance(db, filters.get("company"), filters.get("from_date"), filters.get("to_date"))
1203
+ return _present(db, rep, filters.get("company"), filters.get("presentation_currency"), filters.get("to_date"))
1204
+ elif report_type == "general-ledger":
1205
+ from api.routers.reports import _general_ledger
1206
+ return _general_ledger(db, filters)
1207
+ elif report_type == "stock-balance":
1208
+ from api.routers.reports import _stock_balance
1209
+ return _stock_balance(db, filters.get("item_code"), filters.get("warehouse"))
1210
+ elif report_type == "dashboard-summary":
1211
+ from api.routers.reports import _dashboard_summary
1212
+ return _dashboard_summary(db, filters.get("company"))
1213
+ elif report_type == "profit-and-loss":
1214
+ from api.routers.reports import _profit_and_loss, _present
1215
+ rep = _profit_and_loss(db, filters.get("company"), filters.get("from_date"), filters.get("to_date"))
1216
+ return _present(db, rep, filters.get("company"), filters.get("presentation_currency"), filters.get("to_date"))
1217
+ elif report_type == "balance-sheet":
1218
+ from api.routers.reports import _balance_sheet, _present
1219
+ rep = _balance_sheet(db, filters.get("company"), filters.get("as_of_date"))
1220
+ return _present(db, rep, filters.get("company"), filters.get("presentation_currency"), filters.get("as_of_date"))
1221
+ elif report_type == "ar-aging":
1222
+ from api.routers.reports import _ar_aging
1223
+ return _ar_aging(db, filters.get("company"), filters.get("as_of_date"))
1224
+ elif report_type == "ap-aging":
1225
+ from api.routers.reports import _ap_aging
1226
+ return _ap_aging(db, filters.get("company"), filters.get("as_of_date"))
1227
+ return {"error": f"Unknown report type: {report_type}"}
1228
+
1229
+
1230
+ def _handle_revalue_currencies(args):
1231
+ db = get_db()
1232
+ company = args.get("company")
1233
+ if not company:
1234
+ companies = db.get_all("Company", fields=["name"], limit=1)
1235
+ company = companies[0]["name"] if companies else None
1236
+ if not company:
1237
+ return {"error": "No company found; create one first."}
1238
+ from lambda_erp.accounting.revaluation import run_period_revaluation
1239
+ try:
1240
+ return run_period_revaluation(company, args.get("date"), post=bool(args.get("post", False)))
1241
+ except Exception as exc:
1242
+ return {"error": getattr(exc, "detail", None) or str(exc)}
1243
+
1244
+
1245
+ def _handle_list_chat_attachments(_args, session_id: str | None = None, user_id: str | None = None):
1246
+ if not session_id or not user_id:
1247
+ return {"error": "No active chat session for attachments."}
1248
+ from api.attachments import list_session_attachments
1249
+ items = list_session_attachments(session_id, user_id)
1250
+ return {"attachments": items, "count": len(items)}
1251
+
1252
+
1253
+ def _handle_retrieve_chat_attachment(args, session_id: str | None = None, user_id: str | None = None):
1254
+ if not session_id or not user_id:
1255
+ return {"error": "No active chat session for attachments."}
1256
+ attachment_id = args.get("attachment_id")
1257
+ if not attachment_id:
1258
+ return {"error": "attachment_id is required."}
1259
+ from api.attachments import get_attachments_by_ids, build_multimodal_content
1260
+ atts = get_attachments_by_ids([attachment_id], user_id)
1261
+ if not atts:
1262
+ return {"error": "Attachment not found or access denied."}
1263
+ att = atts[0]
1264
+ return {
1265
+ "id": att["id"],
1266
+ "filename": att["filename"],
1267
+ "mime_type": att["mime_type"],
1268
+ "size_bytes": att["size_bytes"],
1269
+ "_multimodal_content": build_multimodal_content(att),
1270
+ }
1271
+
1272
+
1273
+ def _handle_query_dataset(args):
1274
+ from api.routers.analytics import aggregate_semantic_dataset
1275
+
1276
+ dataset = args.get("dataset")
1277
+ if not dataset:
1278
+ return {"error": "dataset is required"}
1279
+ measures = args.get("measures") or {}
1280
+ if not measures:
1281
+ return {"error": "measures is required — e.g. {\"revenue\": [\"sum\", \"net_total\"]}"}
1282
+ try:
1283
+ result = aggregate_semantic_dataset(
1284
+ dataset=dataset,
1285
+ group_by=args.get("group_by") or [],
1286
+ measures=measures,
1287
+ filters=args.get("filters") or {},
1288
+ order_by=args.get("order_by") or [],
1289
+ limit=args.get("limit"),
1290
+ )
1291
+ except HTTPException as e:
1292
+ return {"error": str(e.detail)}
1293
+ except Exception as e:
1294
+ return {"error": f"Aggregation failed: {e}"}
1295
+ return result
1296
+
1297
+
1298
+ def _handle_create_custom_analytics_report(args, user_info: dict | None = None, session_id: str | None = None, client_ip: str | None = None):
1299
+ from api.routers.analytics import create_report_draft_record
1300
+
1301
+ # GPT may pass intent + (optionally) a sketch. If the code spec is
1302
+ # missing, delegate to the Anthropic code specialist.
1303
+ if not args.get("transform_js") or not args.get("data_requests"):
1304
+ intent = args.get("intent") or args.get("description") or args.get("title")
1305
+ if not intent:
1306
+ return {"error": "Provide either `intent` or a complete spec (data_requests + transform_js)."}
1307
+ try:
1308
+ spec = _generate_report_spec_via_anthropic(
1309
+ intent,
1310
+ client_ip=client_ip,
1311
+ user_role=(user_info or {}).get("role"),
1312
+ )
1313
+ except Exception as e:
1314
+ return {"error": f"Code specialist failed: {e}"}
1315
+ args["data_requests"] = spec["data_requests"]
1316
+ args["transform_js"] = spec["transform_js"]
1317
+ if not args.get("title"):
1318
+ args["title"] = spec.get("title") or (intent[:60] if intent else "Custom Report")
1319
+ if not args.get("description") and spec.get("description"):
1320
+ args["description"] = spec["description"]
1321
+
1322
+ if not args.get("title"):
1323
+ return {"error": "title is required"}
1324
+ return create_report_draft_record(args, user_info, source_chat_session_id=session_id)
1325
+
1326
+
1327
+ def _handle_get_custom_analytics_report(args, user_info: dict | None = None):
1328
+ from api.routers.analytics import get_report_draft_record
1329
+
1330
+ report_id = args.get("report_id")
1331
+ if not report_id:
1332
+ return {"error": "report_id is required"}
1333
+ row = get_report_draft_record(report_id, user_info)
1334
+ if not row:
1335
+ return {"error": f"Report draft '{report_id}' not found"}
1336
+ return row
1337
+
1338
+
1339
+ def _handle_update_custom_analytics_report(args, user_info: dict | None = None, client_ip: str | None = None):
1340
+ from api.routers.analytics import get_report_draft_record, update_report_draft_record
1341
+
1342
+ report_id = args.get("report_id")
1343
+ if not report_id:
1344
+ return {"error": "report_id is required"}
1345
+
1346
+ feedback = args.get("feedback") or args.get("intent")
1347
+ # If the caller provided a feedback/intent hint and didn't already hand-roll
1348
+ # a transform_js or data_requests change, let the code specialist rewrite
1349
+ # the spec based on the existing draft.
1350
+ if feedback and "transform_js" not in args and "data_requests" not in args:
1351
+ existing = get_report_draft_record(report_id, user_info)
1352
+ if not existing:
1353
+ return {"error": f"Report draft '{report_id}' not found"}
1354
+ try:
1355
+ spec = _generate_report_spec_via_anthropic(
1356
+ intent=existing.get("description") or existing.get("title") or "",
1357
+ existing_spec=existing,
1358
+ feedback=feedback,
1359
+ client_ip=client_ip,
1360
+ user_role=(user_info or {}).get("role"),
1361
+ )
1362
+ except Exception as e:
1363
+ return {"error": f"Code specialist failed: {e}"}
1364
+ args["data_requests"] = spec["data_requests"]
1365
+ args["transform_js"] = spec["transform_js"]
1366
+ if spec.get("title") and "title" not in args:
1367
+ args["title"] = spec["title"]
1368
+ if spec.get("description") and "description" not in args:
1369
+ args["description"] = spec["description"]
1370
+
1371
+ payload = {key: value for key, value in args.items() if key not in ("report_id", "feedback", "intent")}
1372
+ if not payload:
1373
+ return {"error": "At least one field to update (or a `feedback` hint) is required"}
1374
+ row = update_report_draft_record(report_id, payload, user_info)
1375
+ if not row:
1376
+ return {"error": f"Report draft '{report_id}' not found"}
1377
+ return row
1378
+
1379
+
1380
+ TOOL_HANDLERS = {
1381
+ "list_documents": _handle_list_documents,
1382
+ "get_document": _handle_get_document,
1383
+ "create_document": _handle_create_document,
1384
+ "update_document": _handle_update_document,
1385
+ "submit_document": _handle_submit_document,
1386
+ "cancel_document": _handle_cancel_document,
1387
+ "convert_document": _handle_convert_document,
1388
+ "search_masters": _handle_search_masters,
1389
+ "create_master": _handle_create_master,
1390
+ "update_master": _handle_update_master,
1391
+ "get_report": _handle_get_report,
1392
+ "revalue_currencies": _handle_revalue_currencies,
1393
+ "get_current_time": _handle_get_current_time,
1394
+ "retrieve_chat_history": lambda args: _handle_retrieve_chat_history(args),
1395
+ "list_chat_attachments": _handle_list_chat_attachments,
1396
+ "retrieve_chat_attachment": _handle_retrieve_chat_attachment,
1397
+ "query_dataset": _handle_query_dataset,
1398
+ "create_custom_analytics_report": lambda args: _handle_create_custom_analytics_report(args),
1399
+ "get_custom_analytics_report": lambda args: _handle_get_custom_analytics_report(args),
1400
+ "update_custom_analytics_report": lambda args: _handle_update_custom_analytics_report(args),
1401
+ }
1402
+
1403
+ # ---------------------------------------------------------------------------
1404
+ # System prompt
1405
+ # ---------------------------------------------------------------------------
1406
+
1407
+
1408
+ def _prompt_company_context() -> str:
1409
+ try:
1410
+ companies = get_db().get_all("Company", fields=["name"], order_by="name asc", limit=20)
1411
+ except Exception:
1412
+ return "## Companies In This ERP\n- Company names could not be loaded for this prompt.\n"
1413
+ if not companies:
1414
+ return "## Companies In This ERP\n- No companies found.\n"
1415
+ names = [row.get("name") for row in companies if row.get("name")]
1416
+ if not names:
1417
+ return "## Companies In This ERP\n- No companies found.\n"
1418
+ bullets = "\n".join(f"- {name}" for name in names)
1419
+ return (
1420
+ "## Companies In This ERP\n"
1421
+ "Use these exact company names when you need a company filter or document company value. "
1422
+ "Do not invent company names.\n"
1423
+ f"{bullets}\n"
1424
+ )
1425
+
1426
+
1427
+ def _prompt_analytics_context() -> str:
1428
+ db = get_db()
1429
+ datasets = [
1430
+ ("sales invoices", "Sales Invoice", "posting_date", "docstatus = 1"),
1431
+ ("purchase invoices", "Purchase Invoice", "posting_date", "docstatus = 1"),
1432
+ ("payments", "Payment Entry", "posting_date", "docstatus = 1"),
1433
+ ]
1434
+ lines: list[str] = []
1435
+ for label, table, date_field, where in datasets:
1436
+ try:
1437
+ row = db.sql(
1438
+ f'''
1439
+ SELECT
1440
+ MIN({date_field}) AS min_date,
1441
+ MAX({date_field}) AS max_date,
1442
+ COUNT(*) AS row_count
1443
+ FROM "{table}"
1444
+ WHERE {where}
1445
+ '''
1446
+ )[0]
1447
+ except Exception:
1448
+ continue
1449
+ row_count = int(row["row_count"] or 0)
1450
+ if row_count <= 0:
1451
+ lines.append(f"- {label}: no submitted records")
1452
+ continue
1453
+ lines.append(
1454
+ f"- {label}: {row_count} submitted records from {row['min_date']} to {row['max_date']}"
1455
+ )
1456
+ if not lines:
1457
+ return "## Analytics Data Coverage\n- Coverage could not be loaded for this prompt.\n"
1458
+ return (
1459
+ "## Analytics Data Coverage\n"
1460
+ "Use these date ranges when drafting analytics. Do not invent narrow date filters outside the "
1461
+ "known coverage unless the user explicitly asks for them.\n"
1462
+ f"{chr(10).join(lines)}\n"
1463
+ )
1464
+
1465
+
1466
+ def build_system_prompt(user_info: dict | None = None):
1467
+ user_name = user_info.get("full_name", "User") if user_info else "User"
1468
+ user_role = user_info.get("role", "viewer") if user_info else "viewer"
1469
+ company_context = _prompt_company_context()
1470
+ analytics_context = _prompt_analytics_context()
1471
+
1472
+ if user_role == "admin":
1473
+ role_desc = "You have **admin** access — full permissions to create, edit, submit, cancel documents, manage master data, run reports, and manage users."
1474
+ elif user_role == "manager":
1475
+ role_desc = "You have **manager** access — you can create, edit, submit, and cancel documents, create and edit master data, and run reports. You cannot manage users or company setup."
1476
+ elif user_role == "public_manager":
1477
+ role_desc = "You are in **public demo mode** — you can create, edit, submit, and cancel documents and run reports, but you cannot create, edit, or delete master data, and you cannot manage users or company setup."
1478
+ else:
1479
+ role_desc = "You have **viewer** access — you can view documents, master data, and reports, but you cannot create or modify data. If the user asks you to create or change something, let them know they need a manager or admin to do that."
1480
+
1481
+ return f"""You are an ERP assistant for Lambda ERP. Today's date is {date.today().isoformat()}.
1482
+
1483
+ You help users manage their business by creating documents, looking up data, and running reports — all through natural conversation.
1484
+
1485
+ ## Answering data questions — three paths
1486
+
1487
+ **Path 1: single-record lookup → `list_documents` / `get_document`.** For "is SINV-0042 paid", "what did customer X order last", "show me the latest 5 purchase orders" — fetch the rows directly. Don't try to aggregate in your head unless there are only a handful of rows in front of you.
1488
+
1489
+ **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.
1490
+
1491
+ **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 combines multiple datasets (e.g. purchases vs sales joined by month). Do **not** invoke it for a factual question `query_dataset` could answer in one call.
1492
+
1493
+ Important constraint on the analytics report tool: the JS transform 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, tell them you don't have access to the executed data and either (a) re-answer via `query_dataset`, or (b) ask them what they see on the page. Never claim you'll look at the report yourself.
1494
+
1495
+ When you do build a custom report, pass a plain-language `intent` to `create_custom_analytics_report` (a specialist model writes the code 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.
1496
+
1497
+ ## Always use markdown links
1498
+ Every URL you mention in chat MUST be written as a markdown link `[label](url)` — never a bare URL on its own. The chat UI only turns `[label](url)` into a proper clickable link. A bare `/reports/analytics?report_id=...` still works (a fallback linkifier catches it), but markdown form is the expected shape.
1499
+
1500
+ Examples:
1501
+ - Correct: `[Open report](/reports/analytics?report_id=RPT-AB12CD34)`
1502
+ - Correct: `[SINV-0012](/app/sales-invoice/SINV-0012)`
1503
+ - Avoid: `/reports/analytics?report_id=RPT-AB12CD34` (bare URL)
1504
+
1505
+ ## Current User
1506
+ You are speaking with **{user_name}** (role: **{user_role}**).
1507
+ {role_desc}
1508
+
1509
+ {company_context}
1510
+ {analytics_context}
1511
+
1512
+ ## User Roles
1513
+ Lambda ERP has four roles:
1514
+ - **admin**: Full access to everything — documents, masters, reports, company setup, and user management (inviting team members, changing roles).
1515
+ - **manager**: Can create, edit, submit, and cancel documents. Can create and edit master data. Can run all reports and use the chat. Cannot manage users or company setup.
1516
+ - **public_manager**: Demo-mode access. Can create, edit, submit, and cancel documents and use reports/chat, but cannot create, edit, or delete master data. Cannot manage users or company setup.
1517
+ - **viewer**: Read-only access to documents, masters, and reports. Can use the chat but cannot create or modify data.
1518
+
1519
+ When a user asks you to do something they don't have permission for, explain what role is needed instead of attempting the action (the API will reject it anyway).
1520
+
1521
+ ## Available Document Types (use the slug when calling tools)
1522
+ - **Selling:** quotation, sales-order, sales-invoice, pos-invoice
1523
+ - **Buying:** purchase-order, purchase-invoice
1524
+ - **Accounting:** payment-entry, journal-entry, budget, subscription, bank-transaction
1525
+ - **Stock:** stock-entry, delivery-note, purchase-receipt
1526
+ - **Settings:** pricing-rule
1527
+
1528
+ ## Document Workflow & What Each Document Does
1529
+
1530
+ ### Sales Cycle
1531
+ Quotation → Sales Order → Delivery Note (shipping) / Sales Invoice (billing) → Payment Entry
1532
+ Shortcuts: Quotation can also convert directly to Sales Invoice or Delivery Note (skipping Sales Order) for quick deals.
1533
+
1534
+ - **Quotation:** Non-binding offer. No financial or stock impact.
1535
+ - **Sales Order:** Confirmed customer commitment. No financial impact, but reserves stock for planning.
1536
+ - **Delivery Note:** Ships goods to customer. **Posts stock entries** (inventory decreases). Requires warehouse on each item. No GL impact on its own.
1537
+ - **Sales Invoice:** Bills the customer. **Posts GL entries:** Debit Accounts Receivable, Credit Sales Revenue (+ Credit Tax Payable if taxes). Creates outstanding amount.
1538
+ - **Payment Entry (Receive):** Records customer payment. **Posts GL entries:** Debit Bank, Credit Accounts Receivable. Reduces invoice outstanding.
1539
+
1540
+ ### Purchase Cycle
1541
+ Purchase Order → Purchase Receipt (receiving) / Purchase Invoice (billing) → Payment Entry
1542
+
1543
+ - **Purchase Order:** Commitment to buy from supplier. No financial or stock impact.
1544
+ - **Purchase Receipt:** Receives goods into warehouse. **Posts stock entries** (inventory increases). Requires warehouse on each item.
1545
+ - **Purchase Invoice:** Records supplier bill. Always **posts GL entries** and creates outstanding amount.
1546
+ For non-stock/services: Debit Expense, Credit Accounts Payable.
1547
+ For stock after a prior Purchase Receipt: Debit Stock Received But Not Billed, Credit Accounts Payable.
1548
+ If `update_stock=1`: the Purchase Invoice also receives the goods into stock, so stock items require a warehouse and the posting is Debit Stock In Hand, Credit Accounts Payable.
1549
+ - **Payment Entry (Pay):** Pays the supplier. **Posts GL entries:** Debit Accounts Payable, Credit Bank. Reduces invoice outstanding.
1550
+
1551
+ ### Items vs. charges on an invoice
1552
+
1553
+ A supplier (or sales) invoice has **two different kinds of lines**, and the LLM MUST keep them separate:
1554
+
1555
+ - **Items** (`items[]`): physical goods or distinct services that have an Item master record and a unit × rate shape. Example lines: "10 × Bolt Pack M8", "1 × Engineering Consultation (4 hours)".
1556
+ - **Charges** (`taxes[]`): non-item monetary lines like **freight, shipping, postage, handling, customs, import duties, insurance**, plus actual taxes (VAT, GST, sales tax). These do NOT need an Item master. They're rows on the invoice that point at a GL account directly.
1557
+
1558
+ Do NOT try to create a new Item master for a line like "Shipping" or "Customs duty". Use the charges table instead.
1559
+
1560
+ Each charge row has:
1561
+ - `charge_type`: `"Actual"` for a fixed amount (freight, shipping, handling, customs), `"On Net Total"` for a percentage (most VAT/GST), `"On Previous Row Total"` for a percentage applied on top of earlier charges (EU VAT that includes freight in its base).
1562
+ - `account_head`: the GL account to debit. Use the company's defaults:
1563
+ - Freight / shipping / postage → `default_freight_in_account` on the Company record
1564
+ - Customs / duty / tariff → `default_customs_account`
1565
+ - VAT / GST / sales tax → the company's Tax Payable account (or similar)
1566
+ - `description`: the freeform label from the supplier invoice ("Shipping", "Customs duty", "VAT 19%").
1567
+ - `rate` (for percentage charges) or `tax_amount` (for `Actual` charges).
1568
+ - `add_deduct_tax`: `"Add"` for most charges; `"Deduct"` for discounts/rebates on the bill.
1569
+
1570
+ When you parse a supplier invoice PDF that has both products and a shipping line, create ONE Purchase Invoice with the items in `items[]` and the shipping as a row in `taxes[]` with `charge_type="Actual"`, the freight amount, and the freight account. Same structure for customs duties.
1571
+
1572
+ If multiple charges stack (freight + VAT on total incl. freight), put freight first as `Actual`, then VAT second as `On Previous Row Total` referencing the freight row's `idx`.
1573
+
1574
+ ### Returns (Credit Notes / Debit Notes)
1575
+ Returns use the SAME document type with negative quantities and `is_return=1`. Create them by "converting" a document to a return of the same type.
1576
+
1577
+ - **Credit Note (Sales Return):** Convert a submitted Sales Invoice to return it. Creates a new Sales Invoice with negative quantities. On submit, reverses the original GL entries (credits Receivable, debits Income) and reduces the original invoice's outstanding_amount.
1578
+ - **Debit Note (Purchase Return):** Convert a submitted Purchase Invoice. Reverses AP/Expense entries and reduces original outstanding.
1579
+ - **Delivery Note Return:** Convert a submitted Delivery Note. Stock comes back into the warehouse, reverses COGS entries.
1580
+ - **Purchase Receipt Return:** Convert a submitted Purchase Receipt. Stock goes back out, reverses stock-in-hand entries.
1581
+
1582
+ To create a return: use convert_document with the SAME doctype as both source and target.
1583
+ Example: convert_document(doctype="sales-invoice", name="SINV-0001", target_doctype="Sales Invoice")
1584
+
1585
+ A return is a draft when created — submit it to take effect. It can be cancelled like any other document.
1586
+
1587
+ For a complete sales return (financial + stock): create and submit both a Credit Note and a Delivery Note return.
1588
+
1589
+ ### Stock Entry (manual inventory, NOT for purchases/sales)
1590
+ - **Opening Stock:** One-time seed of initial inventory at company setup. Posts Dr Stock In Hand / Cr Opening Balance Equity so day-one stock doesn't hit the P&L.
1591
+ - **Material Receipt:** Manual adjustment that adds stock (found stock, inventory count corrections). Posts Dr Stock In Hand / Cr Stock Adjustment. Not for purchased goods — use Purchase Receipt or Purchase Invoice with update_stock=1 for those. Not for opening balances — use Opening Stock.
1592
+ - **Material Issue:** Removes stock for write-offs or internal consumption. Posts Dr Stock Adjustment / Cr Stock In Hand.
1593
+ - **Material Transfer:** Moves stock between warehouses. No GL impact.
1594
+
1595
+ ### Salary / Payroll (manual flow)
1596
+ There is no dedicated payroll module. Handle salary payments with two steps:
1597
+ 1. **Accrue salary:** Create a Journal Entry — Debit "Salary Expense" (the expense), Credit "Salary Payable" (the liability). Add a remark like "April 2026 salaries".
1598
+ 2. **Pay salary:** Create a Payment Entry — payment_type "Pay", party_type "Supplier" (or use a Journal Entry: Debit "Salary Payable", Credit bank account).
1599
+ The accounts "Salary Expense - {{ABBR}}" and "Salary Payable - {{ABBR}}" exist in the Chart of Accounts for this purpose (where ABBR is the company abbreviation, e.g. LAMB for Lambda Corp).
1600
+
1601
+ ### Key principle
1602
+ For purchased goods, prefer Purchase Receipt when receiving separately from billing because it gives the best audit trail and clears cleanly into Purchase Invoice later. If the user wants one step to both receive goods and record the supplier bill, use Purchase Invoice with `update_stock=1` and a warehouse on each stock item line.
1603
+
1604
+ ## Document Lifecycle Rules
1605
+ - **Draft (docstatus=0):** Document is editable. Has NO financial impact — no GL entries, no stock movement. Can be updated freely.
1606
+ - **Submitted (docstatus=1):** Document is locked. GL entries and stock ledger entries are posted. Cannot be edited — only cancelled.
1607
+ - **Cancelled (docstatus=2):** GL and stock entries are reversed. Document is permanently archived. Cannot be reused or edited.
1608
+ - **There is NO delete operation.** Documents are permanent records. This is by design for audit integrity.
1609
+ - **To void a draft you no longer need:** submit it first (docstatus 0→1), then cancel it (docstatus 1→2). Do NOT tell the user to "delete" anything.
1610
+ - **To correct a submitted document:** cancel it and create a new one with the correct values.
1611
+ - Only submitted documents can be converted (e.g. Sales Order → Sales Invoice). Draft or cancelled documents cannot.
1612
+ - Cancelled documents cannot be re-submitted or modified.
1613
+
1614
+ ## Master Data Types
1615
+ customer, supplier, item, warehouse, account, company, cost-center
1616
+
1617
+ ## Warehouse Master Rules
1618
+ - To create a warehouse via `create_master`, you MUST pass a `data` object.
1619
+ - `warehouse_name` is required.
1620
+ - `company` should be provided when the warehouse belongs to a company.
1621
+ - `parent_warehouse` is optional. Do not ask for it unless the user explicitly wants a parent or warehouse tree placement.
1622
+ - If there is no parent warehouse, omit `parent_warehouse` or set it to `null`. Do not use `"-"` as a value.
1623
+
1624
+ ## Reports
1625
+ - **trial-balance** — All account balances (debit, credit, net). Filters: company, from_date, to_date.
1626
+ - **general-ledger** — Individual GL entries with running balance. Filters: account, party, voucher_type, from_date, to_date, company.
1627
+ - **profit-and-loss** — Income vs Expense summary with net profit. Filters: company, from_date, to_date.
1628
+ - **balance-sheet** — Assets, Liabilities, Equity as of a date (includes retained earnings). Filters: company, as_of_date.
1629
+ - **ar-aging** — Outstanding receivables bucketed by overdue days (Current, 1-30, 31-60, 61-90, 90+). Filters: company, as_of_date.
1630
+ - **ap-aging** — Outstanding payables bucketed by overdue days. Filters: company, as_of_date.
1631
+ - **stock-balance** — Inventory quantities and valuations. Filters: item_code, warehouse.
1632
+ - **dashboard-summary** — KPI overview (revenue, receivables, payables, stock value).
1633
+ - **Custom analytics drafts** — reserved for when the user **explicitly asks
1634
+ for** a chart, graph, trend, pivot, breakdown, or saved report, or when the
1635
+ analysis genuinely requires combining multiple datasets. See the
1636
+ "Answering data questions — two paths" section at the top: simple factual
1637
+ lookups (top customer, outstanding invoices, etc.) should be answered in
1638
+ chat via `list_documents`, not by building a report.
1639
+
1640
+ When you do call `create_custom_analytics_report`, pass `title` and
1641
+ `intent` — a clear plain-language description (filters, groupings, sort
1642
+ orders, chart type). A specialist model writes the JS transform for you.
1643
+ Relay the returned `/reports/analytics?report_id=…` link as a markdown
1644
+ link. You will NOT see the executed data — the transform runs in the
1645
+ user's browser. So do not promise to "read" or "interpret" the report
1646
+ yourself; if the user wants a narrative answer, use `list_documents`
1647
+ instead.
1648
+
1649
+ For refinements or repairs, first load the existing draft with
1650
+ `get_custom_analytics_report` so you can summarize what it does, then call
1651
+ `update_custom_analytics_report` with `report_id` and `feedback` — a plain-
1652
+ language description of what should change. The specialist rewrites the
1653
+ spec while keeping the same `report_id` and URL.
1654
+
1655
+ Do not invent a `company` filter unless the user specified one (or use one
1656
+ of the exact company names listed above). Do not invent restrictive date
1657
+ windows unless the user asked for them.
1658
+
1659
+ ## Stock Entry specifics
1660
+ When creating a stock-entry, the `data` object MUST include:
1661
+ - `stock_entry_type`: "Opening Stock", "Material Receipt", "Material Issue", or "Material Transfer"
1662
+ - `company`, `posting_date`
1663
+ - `items` array where each item uses `basic_rate` (NOT `rate`) and warehouse fields:
1664
+ - Opening Stock: items need `t_warehouse` (target). Set `to_warehouse` on the parent too.
1665
+ - Material Receipt: items need `t_warehouse` (target). Set `to_warehouse` on the parent too.
1666
+ - Material Issue: items need `s_warehouse` (source). Set `from_warehouse` on the parent too.
1667
+ - Material Transfer: items need both `s_warehouse` and `t_warehouse`
1668
+
1669
+ ## Payment Entry specifics
1670
+ When creating a payment-entry, the `data` object MUST include:
1671
+ - `payment_type`: "Receive" (customer pays you) or "Pay" (you pay supplier)
1672
+ - `company`, `posting_date`, `party_type` ("Customer" or "Supplier"), `party` (the party name)
1673
+ - `paid_from`, `paid_to` (account names), `paid_amount`, `received_amount`
1674
+ - **Always include references** when paying against an invoice:
1675
+ `"references": [{{"reference_doctype": "Sales Invoice", "reference_name": "SINV-0001", "allocated_amount": 5000}}]`
1676
+ - For partial payments, set `allocated_amount` to less than the invoice total
1677
+
1678
+ ## Delivery Note & Purchase Receipt specifics
1679
+ - Items MUST have a warehouse set. For Delivery Notes use the source warehouse. For Purchase Receipts use the target warehouse.
1680
+ - Look up the available warehouse first (search_masters with master_type "warehouse") and set it on each item row.
1681
+ - When converting from a Sales Order or Purchase Order, the warehouse is often missing — always check and update it before submitting.
1682
+
1683
+ ## Master keys vs display names — CRITICAL
1684
+
1685
+ Every master record has a primary key (the `name` column) and a human-readable display field:
1686
+ - **Item:** key = `item_code` (e.g. `SVC-005`), display = `item_name` (e.g. "Project Management")
1687
+ - **Customer:** key = `name` (e.g. `CUST-007`), display = `customer_name` (e.g. "Redstone Automotive")
1688
+ - **Supplier:** key = `name` (e.g. `SUPP-003`), display = `supplier_name`
1689
+ - **Warehouse / Company / Account / Cost Center:** key = `name`
1690
+
1691
+ When you fill in `item_code`, `customer`, `supplier`, `warehouse`, `company`, etc. in a document or child-table row, you MUST use the **primary key**, never the display name. `"item_code": "Project Management"` is ALWAYS wrong — it's a name, not a code.
1692
+
1693
+ If the user refers to something by its human name ("bill them 8 hours of project mgmt", "add Redstone to the quote"), resolve the key first:
1694
+ - `search_masters(master_type="item", q="project management")` → returns `[{{name: "SVC-005", item_name: "Project Management"}}]`. Use `name` as `item_code`.
1695
+ - Same for customers, suppliers, warehouses, etc. — `search_masters` matches **both** the key and the display field.
1696
+
1697
+ When you list masters back to the user (items on an invoice, customers on a report), include the key in parentheses so follow-ups are unambiguous. Example: "Project Management (SVC-005) — 16 Hour".
1698
+
1699
+ If a `create_document` / `update_document` call fails with an "item not found" / "master does not exist" kind of error, do NOT blame permissions — first call `search_masters` to find the correct key and retry. Only report a permission error if the API explicitly returns a 403 / "cannot create or update master data" message.
1700
+
1701
+ ## Rules
1702
+ - Always search for existing master data before creating documents (verify customer/item keys exist).
1703
+ - When calling `create_master`, always include the `data` object. Never call it with only `master_type`.
1704
+ - When calling `update_master`, always search the master first if the exact record name is uncertain, then pass the existing `name` plus a `data` object with only the fields to change.
1705
+ - If the current user role is `public_manager`, do not call `create_master` or `update_master`; explain that demo mode cannot modify master data. **But `public_manager` CAN still create, edit, submit, and cancel documents** — don't cite demo mode as the reason a document creation failed unless the error literally says so.
1706
+ - Use today's date ({date.today().isoformat()}) as default for posting_date/transaction_date
1707
+ - When creating documents, you MUST always include the `company` field. Search for companies first if you don't know the name.
1708
+ - When creating documents with items, you MUST include the items array with `item_code` (primary key, NOT `item_name`), qty, and rate.
1709
+ - After creating a document, tell the user its name and key details, and include links (see below)
1710
+ - When the user says "submit it" or "convert it", refer to the most recently discussed document
1711
+ - Use markdown for formatting responses
1712
+ - Be concise but helpful
1713
+ - If a tool call fails, explain the error clearly and suggest how to fix it
1714
+
1715
+ ## Document & Master Links
1716
+ When referencing records, always use clickable markdown links so the user can open them directly.
1717
+
1718
+ **Documents** (quotations, invoices, orders, deliveries, receipts, payments, journal entries, stock entries):
1719
+ - **View/edit link:** `/app/{{doctype-slug}}/{{name}}` — e.g. [SINV-0001](/app/sales-invoice/SINV-0001)
1720
+ - **PDF link:** `/api/documents/{{doctype-slug}}/{{name}}/pdf` — e.g. [Download PDF](/api/documents/sales-invoice/SINV-0001/pdf)
1721
+ The doctype slug is the lowercase, hyphenated form: sales-invoice, purchase-order, delivery-note, etc.
1722
+
1723
+ **Master records** (customer, supplier, item, warehouse, company):
1724
+ - **View/edit link:** `/masters/{{master-type}}/{{name}}` — e.g. [SUPP-001](/masters/supplier/SUPP-001), [CUST-003](/masters/customer/CUST-003), [ITEM-001](/masters/item/ITEM-001)
1725
+ - NEVER use `/app/...` for masters — that path is only for transactional documents.
1726
+
1727
+ Always include the view link after creating, submitting, converting, or updating a record. Include the PDF link when the user asks for a printable version or when sharing an invoice/quotation.
1728
+
1729
+ ## File Attachments
1730
+ Users can attach PDFs and images (receipts, bills, contracts, screenshots) to their messages using the paperclip button in the chat input. When attachments are sent, they appear as multimodal content in the current message and you can directly see their contents.
1731
+
1732
+ - **For the current message** — any attached images/PDFs are already in your context; read them carefully and use the content (e.g., parse a supplier invoice into a Purchase Invoice).
1733
+ - **For files uploaded earlier in the chat** — if the user asks about a previously-uploaded file that is no longer in your immediate context, call `list_chat_attachments` to see what's available, then `retrieve_chat_attachment(attachment_id)` to load it. The retrieved content will appear in the next turn as a user message you can read.
1734
+ - **When a user uploads a bill/invoice and asks you to "add it" or "create a purchase invoice"**, extract supplier, line items, quantities, rates, and totals from the document and call `create_document("purchase-invoice", {...})`. Confirm details back to the user and ask about anything ambiguous (e.g., which existing supplier it matches)."""
1735
+
1736
+
1737
+ # ---------------------------------------------------------------------------
1738
+ # Anthropic code specialist (sub-agent)
1739
+ #
1740
+ # GPT-5.4 stays as the planner/orchestrator. When it decides a report needs
1741
+ # code generated (the user wants a custom analytics view) it calls the
1742
+ # create/update custom analytics tools. Those handlers delegate the actual
1743
+ # JS spec generation to Anthropic `ANTHROPIC_CODE_MODEL` — a specialist that
1744
+ # is stronger at producing the runtime spec than the planner.
1745
+ # ---------------------------------------------------------------------------
1746
+
1747
+
1748
+ def _code_model() -> str:
1749
+ return (
1750
+ os.environ.get("ANTHROPIC_CODE_MODEL")
1751
+ or os.environ.get("ANTHROPIC_REPORT_REPAIR_MODEL") # legacy fallback
1752
+ or "claude-sonnet-4-20250514"
1753
+ )
1754
+
1755
+
1756
+ def _anthropic_available() -> tuple[bool, str]:
1757
+ api_key = os.environ.get("ANTHROPIC_API_KEY", "")
1758
+ if not api_key or api_key == "your-anthropic-key-here":
1759
+ return False, "ANTHROPIC_API_KEY is not configured"
1760
+ try:
1761
+ import anthropic # noqa: F401 # type: ignore
1762
+ except Exception as e:
1763
+ return False, f"Anthropic SDK not available: {e}"
1764
+ return True, ""
1765
+
1766
+
1767
+ _REPORT_CODE_SYSTEM_PROMPT = """You are a report-code specialist for the Lambda ERP analytics runtime.
1768
+
1769
+ Your only job is to return a strict JSON object describing a custom analytics report. You NEVER write prose, commentary, markdown, or code fences — only the raw JSON object.
1770
+
1771
+ ## Output shape
1772
+
1773
+ Return JSON with these top-level fields:
1774
+ - `title` (string, short)
1775
+ - `description` (string, optional, one sentence)
1776
+ - `data_requests` (array of 1+ objects)
1777
+ - `transform_js` (string)
1778
+
1779
+ Each `data_requests[]` is `{ name, dataset, fields, filters?, limit? }`. `dataset` must be one of the semantic datasets listed below. `fields` must be a subset of that dataset's exposed field list. Never invent fields.
1780
+
1781
+ `transform_js` is a function body (NOT a function declaration). It receives the requested datasets injected as top-level variables (named after `data_requests[].name`, or the dataset name if `name` is omitted). It must end with `return { ... }`.
1782
+
1783
+ The returned object supports:
1784
+ - `kpis: [{ label, value, format? }]`
1785
+ - `tables: [{ title, columns: [{ key, label, type? }], rows }]` where `type` is one of `currency`, `number`, `string`, `date`
1786
+ - `charts: [{ title, type, x, y, dataTable? , data? }]` where `type` is `bar`, `line`, or `pie`. `y` MUST be a single string — never an array. Prefer `dataTable: '<table title>'` when the chart is based on a returned table; otherwise use inline `data`.
1787
+ - `summary: "..."` (string)
1788
+
1789
+ ## Semantic datasets
1790
+
1791
+ Use only these datasets. Exposed fields will be listed in the user message; do not invent others.
1792
+ - `sales_invoices`
1793
+ - `sales_invoice_lines`
1794
+ - `purchase_invoices`
1795
+ - `purchase_invoice_lines`
1796
+ - `payments`
1797
+ - `ar_open_items`
1798
+ - `ap_open_items`
1799
+ - `stock_balances`
1800
+ - `stock_movements`
1801
+
1802
+ These datasets already scope to submitted/open records, so do not add `docstatus = 1` filters.
1803
+
1804
+ ## Filters shape
1805
+
1806
+ `data_requests[].filters` MUST be an object (dict) keyed by field name. It is NOT a list of triples and NOT a SQL expression. Supported value shapes per key:
1807
+
1808
+ - **Equality:** `{ "customer": "CUST-001" }` → `WHERE customer = 'CUST-001'`
1809
+ - **IN list:** `{ "item_code": ["ITEM-A", "ITEM-B"] }` → `WHERE item_code IN (...)`
1810
+ - **Date / number range:** `{ "posting_date": { "from": "2025-06-20", "to": "2026-04-20" } }` — use this shape for any from/to range. Either side can be omitted.
1811
+
1812
+ A complete example:
1813
+ ```json
1814
+ "filters": {
1815
+ "posting_date": { "from": "2025-01-01", "to": "2025-12-31" },
1816
+ "customer": "CUST-001",
1817
+ "is_return": 0
1818
+ }
1819
+ ```
1820
+
1821
+ Do NOT produce `[["posting_date", ">=", "2025-06-20"], ...]`. That shape will be rejected by the backend.
1822
+
1823
+ Only filter on fields listed in the dataset's `filter_fields`. If a date range is needed, always use the `{ from, to }` sub-object under the date field, never operator strings like `>=`.
1824
+
1825
+ ## Supported runtime helpers
1826
+
1827
+ Only these patterns work inside `transform_js`:
1828
+ - `helpers.sum(rows, 'field')` or `helpers.sum(rows, row => ...)`
1829
+ - `helpers.sortBy(rows, 'field', 'asc'|'desc'|true)`
1830
+ - `helpers.topN(rows, 'field', n)` or `helpers.topN(rows, n)` when already sorted
1831
+ - `helpers.group(rows, ['field1', ...], { alias: ['sum'|'count', 'field'] })`
1832
+ - `helpers.group(rows, row => key)` returns `[{ key, rows }]`
1833
+ - `helpers.monthKey(value)`, `helpers.quarterKey(value)`, `helpers.yearKey(value)`
1834
+ - `helpers.leftJoin(left, right, 'leftKey', 'rightKey')`
1835
+ - `helpers.pivot(rows, rowKey, colKey, valueKey)`
1836
+
1837
+ Do NOT use unsupported shapes like `helpers.sortBy(rows, row => ...)` or `helpers.group(rows, keyFn, reducerFn)`.
1838
+
1839
+ ## Chart type selection
1840
+
1841
+ - `bar` — ranked lists, category comparisons, month-by-month totals, most discrete bucketed reports. Default when unsure.
1842
+ - `line` — continuous trend over many periods when the user explicitly asks for a trend.
1843
+ - `pie` — simple part-of-whole with few categories only.
1844
+
1845
+ If the user asks for a graph, chart, visual, breakdown, or comparison — include at least one chart. Table-only output is only appropriate if the user explicitly asked for just a table.
1846
+
1847
+ ## Rules
1848
+
1849
+ - Return ONLY the JSON object. No prose, no code fences, no commentary.
1850
+ - `transform_js` must be a function body ending in `return { ... }`.
1851
+ - Never hallucinate field names — only use fields explicitly listed for the chosen dataset.
1852
+ - Prefer simple transforms: group → sortBy → topN → chart.
1853
+ """
1854
+
1855
+
1856
+ def _dataset_catalog_text() -> str:
1857
+ from api.routers.analytics import SEMANTIC_DATASETS
1858
+ lines = []
1859
+ for ds, spec in SEMANTIC_DATASETS.items():
1860
+ fields = ", ".join(spec.get("fields", []))
1861
+ filter_fields = ", ".join(sorted(spec.get("filter_fields", []) or []))
1862
+ lines.append(
1863
+ f"- `{ds}` ({spec.get('label', '')}):\n"
1864
+ f" fields = [{fields}]\n"
1865
+ f" filter_fields = [{filter_fields}]"
1866
+ )
1867
+ return "\n".join(lines)
1868
+
1869
+
1870
+ def _extract_json_object(text: str) -> dict:
1871
+ text = text.strip()
1872
+ if text.startswith("```"):
1873
+ text = text.split("\n", 1)[1] if "\n" in text else text
1874
+ if text.endswith("```"):
1875
+ text = text.rsplit("```", 1)[0]
1876
+ start = text.find("{")
1877
+ end = text.rfind("}")
1878
+ if start == -1 or end == -1 or end <= start:
1879
+ raise ValueError("Code specialist did not return a JSON object.")
1880
+ candidate = text[start : end + 1]
1881
+ return json.loads(candidate)
1882
+
1883
+
1884
+ def _generate_report_spec_via_anthropic(
1885
+ intent: str,
1886
+ existing_spec: dict | None = None,
1887
+ feedback: str | None = None,
1888
+ client_ip: str | None = None,
1889
+ user_role: str | None = None,
1890
+ ) -> dict:
1891
+ ok, reason = _anthropic_available()
1892
+ if not ok:
1893
+ raise RuntimeError(reason)
1894
+ import anthropic # type: ignore
1895
+
1896
+ model = _code_model()
1897
+ api_key = os.environ["ANTHROPIC_API_KEY"]
1898
+
1899
+ user_parts: list[str] = []
1900
+ user_parts.append(f"## Today's date\n{date.today().isoformat()}")
1901
+ user_parts.append("## Available datasets\n" + _dataset_catalog_text())
1902
+ if existing_spec:
1903
+ existing_payload = {
1904
+ "title": existing_spec.get("title"),
1905
+ "description": existing_spec.get("description"),
1906
+ "data_requests": existing_spec.get("data_requests"),
1907
+ "transform_js": existing_spec.get("transform_js"),
1908
+ }
1909
+ user_parts.append(
1910
+ "## Existing draft to refine\n"
1911
+ + json.dumps(existing_payload, indent=2, default=str)
1912
+ )
1913
+ if feedback:
1914
+ user_parts.append("## User feedback / change request\n" + feedback)
1915
+ if intent:
1916
+ user_parts.append("## Intent\n" + intent)
1917
+ user_parts.append(
1918
+ "Return the updated (or new) report spec as strict JSON "
1919
+ "with fields `title`, `description`, `data_requests`, `transform_js`. "
1920
+ "No prose — JSON only."
1921
+ )
1922
+ user_msg = "\n\n".join(user_parts)
1923
+
1924
+ print(
1925
+ f"[chat_llm] provider=anthropic role=code_specialist model={model}",
1926
+ flush=True,
1927
+ )
1928
+ client = anthropic.Anthropic(api_key=api_key, timeout=120.0)
1929
+ reservation_id = None
1930
+ if is_demo_role(user_role):
1931
+ _blocked, reservation_id = demo_limiter.reserve(
1932
+ client_ip or "unknown",
1933
+ estimated_usd=demo_call_reserve_usd(),
1934
+ role=user_role,
1935
+ )
1936
+ if _blocked:
1937
+ raise RuntimeError(_blocked)
1938
+
1939
+ # try/finally + `settled` flag guarantees reservation release even if
1940
+ # a CancelledError slips between the SDK call returning and settle()
1941
+ # completing. Without this, a cancelled coroutine could leak the
1942
+ # reservation for the process lifetime (or until TTL sweep).
1943
+ settled = False
1944
+ try:
1945
+ response = client.messages.create(
1946
+ model=model,
1947
+ system=_REPORT_CODE_SYSTEM_PROMPT,
1948
+ messages=[{"role": "user", "content": user_msg}],
1949
+ max_tokens=4096,
1950
+ )
1951
+ # Log every call for the admin dashboard. Only public_manager rows
1952
+ # count against the demo cap — other roles are logged for
1953
+ # visibility but exempt from rate limiting.
1954
+ usage = getattr(response, "usage", None)
1955
+ demo_limiter.settle(
1956
+ reservation_id,
1957
+ actual_cost_usd=cost_of_anthropic_call(model, usage),
1958
+ ip=client_ip or "unknown",
1959
+ role=user_role,
1960
+ provider="anthropic",
1961
+ model=model,
1962
+ prompt_tokens=int(getattr(usage, "input_tokens", 0) or 0) if usage else 0,
1963
+ completion_tokens=int(getattr(usage, "output_tokens", 0) or 0) if usage else 0,
1964
+ )
1965
+ settled = True
1966
+ finally:
1967
+ if not settled:
1968
+ demo_limiter.release(reservation_id)
1969
+
1970
+ text = "".join(
1971
+ block.text for block in response.content if getattr(block, "type", "") == "text"
1972
+ )
1973
+ spec = _extract_json_object(text)
1974
+ if not spec.get("transform_js") or not spec.get("data_requests"):
1975
+ raise RuntimeError("Code specialist returned an incomplete spec.")
1976
+ return spec
1977
+
1978
+
1979
+ # ---------------------------------------------------------------------------
1980
+ # Title generation
1981
+ # ---------------------------------------------------------------------------
1982
+
1983
+
1984
+ async def generate_title(
1985
+ session_id: str,
1986
+ user_message: str,
1987
+ assistant_message: str,
1988
+ client_ip: str | None = None,
1989
+ user_role: str | None = None,
1990
+ ):
1991
+ """Generate a short title for the chat based on the first exchange.
1992
+
1993
+ Called after the first assistant reply. Runs in the background so it
1994
+ doesn't block the response.
1995
+ """
1996
+ api_key = os.environ.get("OPENAI_API_KEY", "")
1997
+ if not api_key or api_key == "sk-your-key-here":
1998
+ return
1999
+
2000
+ try:
2001
+ client = OpenAI(
2002
+ api_key=api_key,
2003
+ timeout=httpx.Timeout(30.0, connect=5.0),
2004
+ )
2005
+ title_model = "gpt-4.1-nano"
2006
+ reservation_id = None
2007
+ if is_demo_role(user_role):
2008
+ _blocked, reservation_id = demo_limiter.reserve(
2009
+ client_ip or "unknown",
2010
+ estimated_usd=demo_call_reserve_usd(),
2011
+ role=user_role,
2012
+ )
2013
+ if _blocked:
2014
+ return
2015
+
2016
+ settled = False
2017
+ try:
2018
+ response = await asyncio.to_thread(
2019
+ client.chat.completions.create,
2020
+ model=title_model,
2021
+ messages=[
2022
+ {"role": "system", "content": "Generate a very short title (3-6 words, no quotes) for this chat based on the first exchange. Just the title, nothing else."},
2023
+ {"role": "user", "content": f"User: {user_message[:200]}\nAssistant: {assistant_message[:200]}"},
2024
+ ],
2025
+ max_completion_tokens=30,
2026
+ )
2027
+ usage = getattr(response, "usage", None)
2028
+ demo_limiter.settle(
2029
+ reservation_id,
2030
+ actual_cost_usd=cost_of_openai_call(title_model, usage),
2031
+ ip=client_ip or "unknown",
2032
+ role=user_role,
2033
+ provider="openai",
2034
+ model=title_model,
2035
+ prompt_tokens=int(getattr(usage, "prompt_tokens", 0) or 0) if usage else 0,
2036
+ completion_tokens=int(getattr(usage, "completion_tokens", 0) or 0) if usage else 0,
2037
+ session_id=session_id,
2038
+ )
2039
+ settled = True
2040
+
2041
+ title = response.choices[0].message.content.strip().strip('"\'')
2042
+ if title:
2043
+ update_session_title(session_id, title)
2044
+ finally:
2045
+ if not settled:
2046
+ demo_limiter.release(reservation_id)
2047
+ except Exception:
2048
+ pass # Title generation is best-effort
2049
+
2050
+
2051
+ # ---------------------------------------------------------------------------
2052
+ # Reasoning loop
2053
+ # ---------------------------------------------------------------------------
2054
+
2055
+
2056
+ async def run_thinking_loop(
2057
+ messages: list[dict],
2058
+ on_event,
2059
+ session_id: str = None,
2060
+ max_iterations: int = 8,
2061
+ user_info: dict | None = None,
2062
+ client_ip: str | None = None,
2063
+ ):
2064
+ """Run the agentic reasoning loop.
2065
+
2066
+ The orchestrator is always OpenAI (gpt-5.4). When GPT decides to call
2067
+ `create_custom_analytics_report` or `update_custom_analytics_report`
2068
+ with an intent/feedback hint, the tool handler itself delegates the
2069
+ code-generation step to Anthropic (ANTHROPIC_CODE_MODEL). We emit an
2070
+ `llm_provider` event around that delegation so the UI can surface it.
2071
+ """
2072
+ openai_api_key = os.environ.get("OPENAI_API_KEY", "")
2073
+ if not openai_api_key or openai_api_key == "sk-your-key-here":
2074
+ await on_event({"type": "error", "content": "Error: OPENAI_API_KEY is not configured. Please set it in the .env file."})
2075
+ return
2076
+
2077
+ openai_client = OpenAI(
2078
+ api_key=openai_api_key,
2079
+ timeout=httpx.Timeout(120.0, connect=10.0),
2080
+ )
2081
+
2082
+ tool_handlers = dict(TOOL_HANDLERS)
2083
+
2084
+ user_role = user_info.get("role") if user_info else None
2085
+ demo_mode = is_demo_role(user_role)
2086
+ # Demo visitors get a tighter completion budget to bound worst-case
2087
+ # cost per turn; logged-in managers/admins keep the original cap.
2088
+ max_completion = demo_max_completion_tokens() if demo_mode else 4096
2089
+
2090
+ if demo_mode:
2091
+ denied = lambda _args: {"error": "Demo mode cannot create or update master data."}
2092
+ tool_handlers["create_master"] = denied
2093
+ tool_handlers["update_master"] = denied
2094
+
2095
+ # Scope session-aware tools without mutating globals.
2096
+ if session_id:
2097
+ tool_handlers["retrieve_chat_history"] = lambda args: _handle_retrieve_chat_history(args, session_id)
2098
+ user_id_for_tools = user_info.get("name") if user_info else None
2099
+ tool_handlers["list_chat_attachments"] = lambda args: _handle_list_chat_attachments(args, session_id, user_id_for_tools)
2100
+ _scoped_retrieve_attachment = (
2101
+ lambda args: _handle_retrieve_chat_attachment(args, session_id, user_id_for_tools)
2102
+ )
2103
+ tool_handlers["create_custom_analytics_report"] = (
2104
+ lambda args: _handle_create_custom_analytics_report(args, user_info, session_id, client_ip=client_ip)
2105
+ )
2106
+ tool_handlers["get_custom_analytics_report"] = (
2107
+ lambda args: _handle_get_custom_analytics_report(args, user_info)
2108
+ )
2109
+ tool_handlers["update_custom_analytics_report"] = (
2110
+ lambda args: _handle_update_custom_analytics_report(args, user_info, client_ip=client_ip)
2111
+ )
2112
+
2113
+ # Demo sessions get a per-turn cap on attachment retrieval: each
2114
+ # retrieval injects ~33k tokens of base64, and the LLM will
2115
+ # happily pull N attachments on one turn if asked. Cap at 1/turn
2116
+ # — visitors who need another retrieval can send a new message.
2117
+ # Counter is scoped per run_thinking_loop invocation, so it
2118
+ # resets naturally between user messages.
2119
+ if demo_mode:
2120
+ demo_retrieval_budget = [1]
2121
+
2122
+ def _demo_capped_retrieve(args, _inner=_scoped_retrieve_attachment, _budget=demo_retrieval_budget):
2123
+ if _budget[0] <= 0:
2124
+ return {
2125
+ "error": (
2126
+ "Demo mode allows at most 1 attachment retrieval per message. "
2127
+ "Ask about this attachment in a new message to retrieve another."
2128
+ )
2129
+ }
2130
+ _budget[0] -= 1
2131
+ return _inner(args)
2132
+
2133
+ tool_handlers["retrieve_chat_attachment"] = _demo_capped_retrieve
2134
+ else:
2135
+ tool_handlers["retrieve_chat_attachment"] = _scoped_retrieve_attachment
2136
+
2137
+ for iteration in range(max_iterations):
2138
+ # Pre-flight spend check — catches both the first call and any
2139
+ # overshoot from the previous iteration's tokens.
2140
+ if demo_mode and client_ip:
2141
+ blocked = demo_limiter.check(client_ip)
2142
+ if blocked:
2143
+ await on_event({"type": "error", "content": blocked})
2144
+ return
2145
+
2146
+ await on_event({"type": "thinking", "iteration": iteration + 1})
2147
+
2148
+ reservation_id = None
2149
+ if demo_mode:
2150
+ blocked, reservation_id = demo_limiter.reserve(
2151
+ client_ip or "unknown",
2152
+ estimated_usd=demo_call_reserve_usd(),
2153
+ role=user_role,
2154
+ )
2155
+ if blocked:
2156
+ await on_event({"type": "error", "content": blocked})
2157
+ return
2158
+
2159
+ # try/finally + settled flag guarantees the reservation is released
2160
+ # on ANY exit path — including asyncio.CancelledError raised after
2161
+ # the SDK call has already returned but before settle() ran.
2162
+ settled = False
2163
+ model_name = "gpt-5.4"
2164
+ try:
2165
+ print(f"[chat_llm] provider=openai model={model_name} session_id={session_id or '-'} iter={iteration + 1}", flush=True)
2166
+ await on_event({"type": "llm_provider", "provider": "openai", "model": model_name})
2167
+ try:
2168
+ response = await asyncio.to_thread(
2169
+ openai_client.chat.completions.create,
2170
+ model=model_name,
2171
+ messages=messages,
2172
+ tools=TOOLS,
2173
+ tool_choice="auto",
2174
+ max_completion_tokens=max_completion,
2175
+ )
2176
+ except Exception as e:
2177
+ await on_event({"type": "error", "content": f"Error calling LLM: {e}"})
2178
+ return
2179
+
2180
+ usage = getattr(response, "usage", None)
2181
+ demo_limiter.settle(
2182
+ reservation_id,
2183
+ actual_cost_usd=cost_of_openai_call(model_name, usage),
2184
+ ip=client_ip or "unknown",
2185
+ role=user_role,
2186
+ provider="openai",
2187
+ model=model_name,
2188
+ prompt_tokens=int(getattr(usage, "prompt_tokens", 0) or 0) if usage else 0,
2189
+ completion_tokens=int(getattr(usage, "completion_tokens", 0) or 0) if usage else 0,
2190
+ session_id=session_id,
2191
+ )
2192
+ settled = True
2193
+ finally:
2194
+ if not settled:
2195
+ demo_limiter.release(reservation_id)
2196
+
2197
+ choice = response.choices[0]
2198
+ message = choice.message
2199
+
2200
+ if not message.tool_calls:
2201
+ content = message.content or ""
2202
+ messages.append({"role": "assistant", "content": content})
2203
+ await on_event({"type": "complete", "content": content})
2204
+ return
2205
+
2206
+ messages.append({
2207
+ "role": "assistant",
2208
+ "content": message.content or "",
2209
+ "tool_calls": [
2210
+ {
2211
+ "id": tc.id,
2212
+ "type": "function",
2213
+ "function": {"name": tc.function.name, "arguments": tc.function.arguments},
2214
+ }
2215
+ for tc in message.tool_calls
2216
+ ],
2217
+ })
2218
+ tool_calls = message.tool_calls
2219
+
2220
+ pending_multimodal: list = []
2221
+ for tc in tool_calls:
2222
+ fn_name = tc.function.name
2223
+ try:
2224
+ fn_args = json.loads(tc.function.arguments)
2225
+ except json.JSONDecodeError:
2226
+ fn_args = {}
2227
+ tool_call_id = tc.id
2228
+
2229
+ await on_event({"type": "tool_call", "tool": fn_name, "args": fn_args})
2230
+
2231
+ # If GPT is delegating report code-gen to the Anthropic specialist,
2232
+ # surface the handoff in the UI.
2233
+ will_delegate_to_code_specialist = (
2234
+ fn_name in ("create_custom_analytics_report", "update_custom_analytics_report")
2235
+ and not fn_args.get("transform_js")
2236
+ and not fn_args.get("data_requests")
2237
+ )
2238
+ if will_delegate_to_code_specialist:
2239
+ await on_event({
2240
+ "type": "llm_provider",
2241
+ "provider": "anthropic",
2242
+ "model": _code_model(),
2243
+ "role": "code_specialist",
2244
+ })
2245
+
2246
+ handler = tool_handlers.get(fn_name)
2247
+ if not handler:
2248
+ result = {"error": f"Unknown tool: {fn_name}"}
2249
+ success = False
2250
+ else:
2251
+ try:
2252
+ result = await asyncio.to_thread(handler, fn_args)
2253
+ success = True
2254
+ except Exception as e:
2255
+ result = {"error": str(e)}
2256
+ success = False
2257
+
2258
+ # Pull any multimodal content out of the tool result before serializing.
2259
+ # retrieve_chat_attachment returns a {"_multimodal_content": {...}} block
2260
+ # which we inject as a fresh user message so the LLM can "see" the file.
2261
+ multimodal_block = None
2262
+ if isinstance(result, dict):
2263
+ multimodal_block = result.pop("_multimodal_content", None)
2264
+
2265
+ result_str = json.dumps(result, default=str)
2266
+ summary = result_str[:500] + "..." if len(result_str) > 500 else result_str
2267
+
2268
+ event_payload: dict = {
2269
+ "type": "tool_result",
2270
+ "tool": fn_name,
2271
+ "success": success,
2272
+ "summary": summary,
2273
+ }
2274
+ # Surface the report id so the sidebar can flash the specific draft.
2275
+ if (
2276
+ success
2277
+ and fn_name in ("create_custom_analytics_report", "update_custom_analytics_report")
2278
+ and isinstance(result, dict)
2279
+ and result.get("id")
2280
+ ):
2281
+ event_payload["report_id"] = result["id"]
2282
+ await on_event(event_payload)
2283
+
2284
+ messages.append({
2285
+ "role": "tool",
2286
+ "tool_call_id": tool_call_id,
2287
+ "content": result_str[:50000],
2288
+ })
2289
+
2290
+ if multimodal_block:
2291
+ pending_multimodal.append(multimodal_block)
2292
+
2293
+ # After this batch of tool calls, inject any retrieved attachments as
2294
+ # a new user message so the LLM can actually see them on the next turn.
2295
+ if pending_multimodal:
2296
+ messages.append({
2297
+ "role": "user",
2298
+ "content": [
2299
+ {"type": "text", "text": "Here are the attachments you requested:"},
2300
+ *pending_multimodal,
2301
+ ],
2302
+ })
2303
+
2304
+ content = "I've reached the maximum number of reasoning steps. Here's what I've done so far — please check the results above."
2305
+ messages.append({"role": "assistant", "content": content})
2306
+ await on_event({"type": "complete", "content": content})
2307
+
2308
+
2309
+ # ---------------------------------------------------------------------------
2310
+ # WebSocket endpoint
2311
+ # ---------------------------------------------------------------------------
2312
+
2313
+
2314
+ async def chat_websocket(
2315
+ websocket: WebSocket,
2316
+ user_info: dict | None = None,
2317
+ client_ip: str | None = None,
2318
+ ):
2319
+ """Handle a shared WebSocket chat connection across sessions."""
2320
+ await websocket.accept()
2321
+ ws_user_id = user_info.get("name") if user_info else None
2322
+ ws_user_role = user_info.get("role") if user_info else None
2323
+
2324
+ session_tasks: dict[str, asyncio.Task] = {}
2325
+ demo_typing_waiters: dict[str, tuple[int, asyncio.Future]] = {}
2326
+ send_lock = asyncio.Lock()
2327
+
2328
+ async def send_event(event_type: str, request_id: str | None = None, **payload):
2329
+ event = {"type": event_type, **payload}
2330
+ if request_id:
2331
+ event["request_id"] = request_id
2332
+ try:
2333
+ async with send_lock:
2334
+ await websocket.send_json(event)
2335
+ except Exception:
2336
+ pass
2337
+
2338
+ async def send_error(content: str, request_id: str | None = None, session_id: str | None = None):
2339
+ payload = {"content": content}
2340
+ if session_id:
2341
+ payload["session_id"] = session_id
2342
+ await send_event("error", request_id=request_id, **payload)
2343
+
2344
+ async def send_message_added(
2345
+ session_id: str,
2346
+ role: str,
2347
+ content: str,
2348
+ created_at: str | None = None,
2349
+ attachments: list | None = None,
2350
+ ):
2351
+ await send_event(
2352
+ "message_added",
2353
+ session_id=session_id,
2354
+ message=serialize_chat_message(role, content, created_at, attachments),
2355
+ )
2356
+
2357
+ async def replay_demo_session(target_session_id: str):
2358
+ try:
2359
+ demo_script = load_demo_script()
2360
+ demo_messages = load_demo_history(target_session_id)
2361
+ message_idx = 0
2362
+
2363
+ for entry in demo_script:
2364
+ role = entry.get("role")
2365
+ content = str(entry.get("content", ""))
2366
+ stored_message = demo_messages[message_idx] if message_idx < len(demo_messages) else None
2367
+ created_at = stored_message.get("created_at") if stored_message else None
2368
+ rendered_content = stored_message.get("content", content) if stored_message else content
2369
+
2370
+ if role == "user":
2371
+ typing_ms = min(DEMO_TYPE_INITIAL_MS + len(rendered_content) * DEMO_TYPE_MS_PER_CHAR, 6000)
2372
+ typing_seq = uuid.uuid4().int & 0x7FFFFFFF
2373
+ typing_waiter = asyncio.get_running_loop().create_future()
2374
+ demo_typing_waiters[target_session_id] = (typing_seq, typing_waiter)
2375
+ await send_event(
2376
+ "demo_typing",
2377
+ session_id=target_session_id,
2378
+ role=role,
2379
+ content=rendered_content,
2380
+ typing_ms=typing_ms,
2381
+ seq=typing_seq,
2382
+ )
2383
+ try:
2384
+ await asyncio.wait_for(typing_waiter, timeout=(typing_ms + 4000) / 1000)
2385
+ except asyncio.TimeoutError:
2386
+ pass
2387
+ finally:
2388
+ if demo_typing_waiters.get(target_session_id, (None,))[0] == typing_seq:
2389
+ demo_typing_waiters.pop(target_session_id, None)
2390
+ await asyncio.sleep(DEMO_AFTER_TYPED_USER_MS / 1000)
2391
+ else:
2392
+ await asyncio.sleep(0.6)
2393
+
2394
+ await send_message_added(target_session_id, role, rendered_content, created_at)
2395
+ message_idx += 1
2396
+
2397
+ flash = entry.get("flash")
2398
+ if isinstance(flash, dict):
2399
+ group = str(flash.get("group", "")).strip()
2400
+ item = str(flash.get("item", "")).strip()
2401
+ if group:
2402
+ payload = {"group": group}
2403
+ if item:
2404
+ payload["item"] = item
2405
+ await send_event("navigation_flash", session_id=target_session_id, **payload)
2406
+
2407
+ await asyncio.sleep(0.35 if role == "user" else 0.75)
2408
+
2409
+ await send_event("demo_replay_complete", session_id=target_session_id)
2410
+ except asyncio.CancelledError:
2411
+ raise
2412
+ except Exception as e:
2413
+ await send_error(str(e), session_id=target_session_id)
2414
+ finally:
2415
+ session_tasks.pop(target_session_id, None)
2416
+
2417
+ async def process_session_message(target_session_id: str, user_content: str, attachment_ids: list | None = None):
2418
+ try:
2419
+ is_first_reply = count_assistant_messages(target_session_id) == 0
2420
+
2421
+ messages = [{"role": "system", "content": build_system_prompt(user_info)}]
2422
+ conversation = build_conversation(target_session_id, limit=20)
2423
+
2424
+ # If the user attached files with this message, replace the last user
2425
+ # message in the conversation with a multimodal content array so the
2426
+ # LLM can see the images/PDFs directly.
2427
+ if attachment_ids and ws_user_id:
2428
+ from api.attachments import get_attachments_by_ids, build_multimodal_content
2429
+ atts = get_attachments_by_ids(attachment_ids, ws_user_id)
2430
+ if atts and conversation and conversation[-1].get("role") == "user":
2431
+ parts = []
2432
+ text = conversation[-1].get("content") or ""
2433
+ if text:
2434
+ parts.append({"type": "text", "text": text})
2435
+ for att in atts:
2436
+ parts.append(build_multimodal_content(att))
2437
+ conversation[-1] = {"role": "user", "content": parts}
2438
+
2439
+ messages.extend(conversation)
2440
+
2441
+ async def on_event(event: dict):
2442
+ await send_event(event["type"], session_id=target_session_id, **{
2443
+ key: value for key, value in event.items() if key != "type"
2444
+ })
2445
+
2446
+ await run_thinking_loop(
2447
+ messages, on_event,
2448
+ session_id=target_session_id,
2449
+ user_info=user_info,
2450
+ client_ip=client_ip,
2451
+ )
2452
+
2453
+ assistant_content = None
2454
+ for msg in reversed(messages):
2455
+ if msg.get("role") == "assistant" and msg.get("content"):
2456
+ assistant_content = msg["content"]
2457
+ save_chat_message(target_session_id, "assistant", assistant_content)
2458
+ await send_message_added(target_session_id, "assistant", assistant_content)
2459
+ break
2460
+
2461
+ if is_first_reply and assistant_content:
2462
+ async def _gen_and_notify():
2463
+ await generate_title(
2464
+ target_session_id,
2465
+ user_content,
2466
+ assistant_content,
2467
+ client_ip=client_ip,
2468
+ user_role=ws_user_role,
2469
+ )
2470
+ session = get_session(target_session_id)
2471
+ if session:
2472
+ await send_event(
2473
+ "session_title_updated",
2474
+ session_id=target_session_id,
2475
+ title=session["title"],
2476
+ )
2477
+
2478
+ asyncio.create_task(_gen_and_notify())
2479
+ except asyncio.CancelledError:
2480
+ raise
2481
+ except Exception as e:
2482
+ await send_error(str(e), session_id=target_session_id)
2483
+ finally:
2484
+ session_tasks.pop(target_session_id, None)
2485
+
2486
+ try:
2487
+ await send_event("sessions_list", sessions=list_sessions(user_id=ws_user_id, role=ws_user_role))
2488
+
2489
+ while True:
2490
+ raw = await websocket.receive_text()
2491
+ try:
2492
+ data = json.loads(raw)
2493
+ except json.JSONDecodeError:
2494
+ await send_error("Invalid JSON")
2495
+ continue
2496
+
2497
+ msg_type = data.get("type")
2498
+ request_id = data.get("request_id")
2499
+
2500
+ if msg_type == "list_sessions":
2501
+ await send_event("sessions_list", request_id=request_id, sessions=list_sessions(user_id=ws_user_id, role=ws_user_role))
2502
+ continue
2503
+
2504
+ if msg_type == "create_session":
2505
+ session = create_session(user_id=ws_user_id)
2506
+ await send_event("session_created", request_id=request_id, session=session)
2507
+ continue
2508
+
2509
+ if msg_type in ("load_history", "join_session"):
2510
+ session_id = data.get("session_id")
2511
+ if not session_id:
2512
+ await send_error("No session_id provided", request_id=request_id)
2513
+ continue
2514
+
2515
+ session = get_session(session_id)
2516
+ if not can_access_session(session, user_info):
2517
+ await send_error(f"Session {session_id} not found", request_id=request_id, session_id=session_id)
2518
+ continue
2519
+
2520
+ # Pagination: "before_id" for loading older messages; default page size 20.
2521
+ before_id = data.get("before_id")
2522
+ page_size = int(data.get("limit") or 20)
2523
+ page = load_serialized_chat_history(session_id, limit=page_size, before_id=before_id)
2524
+
2525
+ await send_event(
2526
+ "history_loaded",
2527
+ request_id=request_id,
2528
+ session_id=session_id,
2529
+ title=session["title"],
2530
+ messages=page["messages"],
2531
+ has_more=page["has_more"],
2532
+ oldest_id=page["oldest_id"],
2533
+ before_id=before_id,
2534
+ )
2535
+ continue
2536
+
2537
+ if msg_type == "delete_session":
2538
+ session_id = data.get("session_id")
2539
+ if not session_id:
2540
+ await send_error("No session_id provided", request_id=request_id)
2541
+ continue
2542
+ session = get_session(session_id)
2543
+ if not can_access_session(session, user_info):
2544
+ await send_error("Session not found", request_id=request_id, session_id=session_id)
2545
+ continue
2546
+
2547
+ active_task = session_tasks.get(session_id)
2548
+ if active_task and not active_task.done():
2549
+ await send_error(
2550
+ "Wait for the current response to finish before deleting this chat.",
2551
+ request_id=request_id,
2552
+ session_id=session_id,
2553
+ )
2554
+ continue
2555
+
2556
+ delete_session(session_id)
2557
+ await send_event("session_deleted", request_id=request_id, session_id=session_id)
2558
+ continue
2559
+
2560
+ if msg_type == "clear_history":
2561
+ session_id = data.get("session_id")
2562
+ if not session_id:
2563
+ await send_error("No session_id provided", request_id=request_id)
2564
+ continue
2565
+ session = get_session(session_id)
2566
+ if not can_access_session(session, user_info):
2567
+ await send_error("Session not found", request_id=request_id, session_id=session_id)
2568
+ continue
2569
+
2570
+ active_task = session_tasks.get(session_id)
2571
+ if active_task and not active_task.done():
2572
+ await send_error(
2573
+ "Wait for the current response to finish before clearing this chat.",
2574
+ request_id=request_id,
2575
+ session_id=session_id,
2576
+ )
2577
+ continue
2578
+
2579
+ clear_chat_history(session_id)
2580
+ await send_event("history_cleared", request_id=request_id, session_id=session_id)
2581
+ continue
2582
+
2583
+ if msg_type == "demo_typing_done":
2584
+ session_id = data.get("session_id")
2585
+ seq = data.get("seq")
2586
+ if not session_id or not isinstance(seq, int):
2587
+ await send_error("Invalid demo typing completion event", request_id=request_id, session_id=session_id)
2588
+ continue
2589
+
2590
+ waiter_info = demo_typing_waiters.get(session_id)
2591
+ if waiter_info and waiter_info[0] == seq and not waiter_info[1].done():
2592
+ waiter_info[1].set_result(True)
2593
+ continue
2594
+
2595
+ if msg_type == "start_demo":
2596
+ session_id = data.get("session_id")
2597
+ if not session_id:
2598
+ await send_error("No session_id provided", request_id=request_id)
2599
+ continue
2600
+
2601
+ if not user_info or user_info.get("role") != "public_manager":
2602
+ await send_error("Demo replay is only available in public demo mode.", request_id=request_id, session_id=session_id)
2603
+ continue
2604
+
2605
+ session = get_session(session_id)
2606
+ if not session:
2607
+ await send_error(f"Session {session_id} not found", request_id=request_id, session_id=session_id)
2608
+ continue
2609
+
2610
+ if session.get("user_id") != ws_user_id:
2611
+ await send_error("Session not found", request_id=request_id, session_id=session_id)
2612
+ continue
2613
+
2614
+ active_task = session_tasks.get(session_id)
2615
+ if active_task and not active_task.done():
2616
+ await send_error(
2617
+ "Wait for the current response to finish before starting the demo.",
2618
+ request_id=request_id,
2619
+ session_id=session_id,
2620
+ )
2621
+ continue
2622
+
2623
+ # Refuse to overwrite a session that already contains real
2624
+ # (non-demo) user/assistant messages.
2625
+ non_demo_rows = get_db().sql(
2626
+ 'SELECT COUNT(*) as cnt FROM "Chat Message" '
2627
+ 'WHERE session_id = ? AND message_type != "demo"',
2628
+ [session_id],
2629
+ )
2630
+ if non_demo_rows and non_demo_rows[0]["cnt"] > 0:
2631
+ await send_error(
2632
+ "This chat already contains non-demo messages.",
2633
+ request_id=request_id,
2634
+ session_id=session_id,
2635
+ )
2636
+ continue
2637
+
2638
+
2639
+ # Always re-seed the demo messages so template or Settings
2640
+ # updates show up on the next replay. Without this, stale
2641
+ # placeholder values (e.g. empty customer names from an older
2642
+ # bootstrap) would be baked into the DB forever.
2643
+ db = get_db()
2644
+ db.sql(
2645
+ 'DELETE FROM "Chat Message" '
2646
+ 'WHERE session_id = ? AND message_type = "demo"',
2647
+ [session_id],
2648
+ )
2649
+ db.conn.commit()
2650
+ for message in load_demo_script():
2651
+ save_chat_message(session_id, message["role"], message["content"], message_type="demo")
2652
+
2653
+ session = get_session(session_id)
2654
+ await send_event("demo_started", request_id=request_id, session_id=session_id, title=session["title"] if session else "New Chat")
2655
+ session_tasks[session_id] = asyncio.create_task(replay_demo_session(session_id))
2656
+ continue
2657
+
2658
+ if msg_type != "send_message":
2659
+ await send_error(f"Unknown message type: {msg_type}", request_id=request_id)
2660
+ continue
2661
+
2662
+ session_id = data.get("session_id")
2663
+ if not session_id:
2664
+ await send_error("No session_id provided", request_id=request_id)
2665
+ continue
2666
+
2667
+ session = get_session(session_id)
2668
+ if not can_access_session(session, user_info):
2669
+ await send_error(f"Session {session_id} not found", request_id=request_id, session_id=session_id)
2670
+ continue
2671
+
2672
+ active_task = session_tasks.get(session_id)
2673
+ if active_task and not active_task.done():
2674
+ await send_error(
2675
+ "Wait for the current response to finish before sending another message.",
2676
+ request_id=request_id,
2677
+ session_id=session_id,
2678
+ )
2679
+ continue
2680
+
2681
+ user_content = data.get("content", "").strip()
2682
+ attachment_ids = data.get("attachment_ids") or []
2683
+ if not isinstance(attachment_ids, list):
2684
+ attachment_ids = []
2685
+ # Cap attachments at 5 per message
2686
+ attachment_ids = [str(a) for a in attachment_ids[:5]]
2687
+ if is_demo_role(ws_user_role) and len(attachment_ids) > 1:
2688
+ await send_error(
2689
+ "Demo mode allows at most 1 attachment per message.",
2690
+ request_id=request_id,
2691
+ session_id=session_id,
2692
+ )
2693
+ continue
2694
+
2695
+ if not user_content and not attachment_ids:
2696
+ await send_error("Message content cannot be empty.", request_id=request_id, session_id=session_id)
2697
+ continue
2698
+
2699
+ # Demo-only: cap raw message length BEFORE the LLM sees it.
2700
+ # The per-call `max_completion_tokens` already bounds output
2701
+ # cost, but input tokens are uncapped — a pasted 100k-char
2702
+ # wall would otherwise burn the global hourly budget in a
2703
+ # single turn. Reject rather than truncate so the visitor
2704
+ # knows what happened.
2705
+ if is_demo_role(ws_user_role):
2706
+ max_chars = demo_max_message_chars()
2707
+ if len(user_content) > max_chars:
2708
+ await send_error(
2709
+ f"Demo messages are limited to {max_chars} characters "
2710
+ f"(your message is {len(user_content):,}). Please shorten it and try again.",
2711
+ request_id=request_id,
2712
+ session_id=session_id,
2713
+ )
2714
+ continue
2715
+
2716
+ # Short-circuit rate-limited demo visitors before we persist
2717
+ # the user message and kick off the LLM task. `run_thinking_loop`
2718
+ # re-checks on every iteration — this is just the friendly UX path.
2719
+ if is_demo_role(ws_user_role) and client_ip:
2720
+ blocked = demo_limiter.check(client_ip)
2721
+ if blocked:
2722
+ await send_error(blocked, request_id=request_id, session_id=session_id)
2723
+ continue
2724
+
2725
+ # Resolve attachment metadata for persistence + display
2726
+ from api.attachments import list_session_attachments
2727
+ session_attachments = {
2728
+ a["id"]: a for a in list_session_attachments(session_id, ws_user_id or "")
2729
+ } if ws_user_id else {}
2730
+ attached_meta = [
2731
+ {
2732
+ "id": aid,
2733
+ "filename": session_attachments[aid]["filename"],
2734
+ "mime_type": session_attachments[aid]["mime_type"],
2735
+ }
2736
+ for aid in attachment_ids
2737
+ if aid in session_attachments
2738
+ ]
2739
+
2740
+ save_chat_message(
2741
+ session_id, "user", user_content or "",
2742
+ metadata={"attachments": attached_meta} if attached_meta else None,
2743
+ )
2744
+ await send_message_added(
2745
+ session_id, "user", user_content or "",
2746
+ attachments=attached_meta if attached_meta else None,
2747
+ )
2748
+
2749
+ # Emit the thinking indicator eagerly. The async task below still
2750
+ # has to read history, build the system prompt, and call the LLM
2751
+ # before run_thinking_loop fires its own "thinking" event, and on
2752
+ # a freshly-seeded 3-year DB that gap is visibly long. Sending it
2753
+ # up-front gives the user immediate UI feedback.
2754
+ await send_event("thinking", session_id=session_id, iteration=1)
2755
+
2756
+ session_tasks[session_id] = asyncio.create_task(
2757
+ process_session_message(session_id, user_content, attachment_ids)
2758
+ )
2759
+
2760
+ except WebSocketDisconnect:
2761
+ for task in session_tasks.values():
2762
+ task.cancel()
2763
+ except Exception as e:
2764
+ await send_error(str(e))