lambda-erp 0.6.13__tar.gz → 0.6.14__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/PKG-INFO +1 -1
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/attachments.py +72 -14
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/chat.py +97 -15
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/routers/mcp.py +2 -1
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/services.py +155 -52
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/pyproject.toml +1 -1
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/.gitignore +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/LICENSE +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/README.md +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/__init__.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/auth.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/bootstrap.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/demo_limits.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/deps.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/errors.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/main.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/oauth.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/pdf.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/providers.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/remarks_md.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/routers/__init__.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/routers/accounting.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/routers/admin.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/routers/analytics.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/routers/chat_api.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/routers/documents.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/routers/masters.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/routers/proposals.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/routers/reports.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/routers/setup.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/templates/document.html +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/api/templates/proposal.html +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/docs/agents/README.md +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/frontend/LICENSE +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/frontend/README.md +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/frontend/src/api/client.ts +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/__init__.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/database.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/hooks.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/model.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/simulation.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/lambda_erp/utils.py +0 -0
- {lambda_erp-0.6.13 → lambda_erp-0.6.14}/terraform/README.md +0 -0
|
@@ -16,10 +16,55 @@ router = APIRouter(prefix="/chat", tags=["chat-attachments"])
|
|
|
16
16
|
|
|
17
17
|
MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 # 10 MB
|
|
18
18
|
MAX_ATTACHMENTS_PER_SESSION = 100 # sanity cap
|
|
19
|
+
|
|
20
|
+
# Binary Office / OpenDocument formats. The chat model reads these directly via
|
|
21
|
+
# the OpenAI `file` content part (the same mechanism PDFs use) — the endpoint
|
|
22
|
+
# accepts the raw bytes and reasons about them, so there is NO server-side
|
|
23
|
+
# conversion/parsing here. Keep this set authoritative: the upload gate, the
|
|
24
|
+
# extension map, and build_multimodal_content all read from it.
|
|
25
|
+
OFFICE_MIME_TYPES = {
|
|
26
|
+
# Spreadsheets
|
|
27
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", # xlsx
|
|
28
|
+
"application/vnd.ms-excel", # xls
|
|
29
|
+
"application/vnd.oasis.opendocument.spreadsheet", # ods
|
|
30
|
+
# Word-processing documents
|
|
31
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", # docx
|
|
32
|
+
"application/msword", # doc
|
|
33
|
+
"application/vnd.oasis.opendocument.text", # odt
|
|
34
|
+
# Presentations
|
|
35
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation", # pptx
|
|
36
|
+
"application/vnd.ms-powerpoint", # ppt
|
|
37
|
+
"application/vnd.oasis.opendocument.presentation", # odp
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# Plain-text formats — inlined into the prompt as text (no file part needed).
|
|
41
|
+
TEXT_MIME_TYPES = {"text/csv", "text/plain"}
|
|
42
|
+
|
|
19
43
|
ALLOWED_MIME_TYPES = {
|
|
20
44
|
"image/png", "image/jpeg", "image/gif", "image/webp",
|
|
21
45
|
"application/pdf",
|
|
46
|
+
} | OFFICE_MIME_TYPES | TEXT_MIME_TYPES
|
|
47
|
+
|
|
48
|
+
# Extension -> canonical mime, so a correctly-named file still uploads when the
|
|
49
|
+
# browser/OS mislabels its type (Office files are frequently sent as
|
|
50
|
+
# application/octet-stream, and CSV as application/vnd.ms-excel).
|
|
51
|
+
_EXT_TO_MIME = {
|
|
52
|
+
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
53
|
+
"xls": "application/vnd.ms-excel",
|
|
54
|
+
"ods": "application/vnd.oasis.opendocument.spreadsheet",
|
|
55
|
+
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
56
|
+
"doc": "application/msword",
|
|
57
|
+
"odt": "application/vnd.oasis.opendocument.text",
|
|
58
|
+
"pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
59
|
+
"ppt": "application/vnd.ms-powerpoint",
|
|
60
|
+
"odp": "application/vnd.oasis.opendocument.presentation",
|
|
61
|
+
"csv": "text/csv",
|
|
62
|
+
"txt": "text/plain",
|
|
63
|
+
"pdf": "application/pdf",
|
|
64
|
+
"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
|
|
65
|
+
"gif": "image/gif", "webp": "image/webp",
|
|
22
66
|
}
|
|
67
|
+
_MIME_TO_EXT = {m: e for e, m in _EXT_TO_MIME.items()}
|
|
23
68
|
|
|
24
69
|
UPLOAD_ROOT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "uploads")
|
|
25
70
|
|
|
@@ -46,14 +91,7 @@ def _safe_ext(filename: str, mime: str) -> str:
|
|
|
46
91
|
ext = filename.rsplit(".", 1)[1].lower()
|
|
47
92
|
if len(ext) <= 5 and ext.isalnum():
|
|
48
93
|
return ext
|
|
49
|
-
|
|
50
|
-
"image/png": "png",
|
|
51
|
-
"image/jpeg": "jpg",
|
|
52
|
-
"image/gif": "gif",
|
|
53
|
-
"image/webp": "webp",
|
|
54
|
-
"application/pdf": "pdf",
|
|
55
|
-
}
|
|
56
|
-
return mime_ext.get(mime, "bin")
|
|
94
|
+
return _MIME_TO_EXT.get(mime, "bin")
|
|
57
95
|
|
|
58
96
|
|
|
59
97
|
@router.post("/attachments")
|
|
@@ -65,7 +103,17 @@ async def upload_attachment(
|
|
|
65
103
|
"""Upload a chat attachment. Returns metadata the client uses to attach it to a message."""
|
|
66
104
|
mime = (file.content_type or "application/octet-stream").lower()
|
|
67
105
|
if mime not in ALLOWED_MIME_TYPES:
|
|
68
|
-
|
|
106
|
+
# Browsers/OSes often mislabel Office files (e.g. octet-stream) — fall
|
|
107
|
+
# back to the filename extension so a correctly-named file still uploads.
|
|
108
|
+
ext = file.filename.rsplit(".", 1)[-1].lower() if file.filename and "." in file.filename else ""
|
|
109
|
+
if ext in _EXT_TO_MIME:
|
|
110
|
+
mime = _EXT_TO_MIME[ext]
|
|
111
|
+
else:
|
|
112
|
+
raise HTTPException(
|
|
113
|
+
status_code=400,
|
|
114
|
+
detail=(f"Unsupported file type: {mime or ext or 'unknown'}. Allowed: images, PDF, "
|
|
115
|
+
"spreadsheets (Excel/CSV/ODS), and documents (Word/OpenDocument)."),
|
|
116
|
+
)
|
|
69
117
|
|
|
70
118
|
data = await file.read()
|
|
71
119
|
if len(data) > MAX_ATTACHMENT_SIZE:
|
|
@@ -186,23 +234,33 @@ def get_attachments_by_ids(attachment_ids: list[str], user_id: str) -> list[dict
|
|
|
186
234
|
|
|
187
235
|
|
|
188
236
|
def build_multimodal_content(attachment: dict) -> dict:
|
|
189
|
-
"""Convert an attachment dict (with raw data) into an OpenAI multimodal content part.
|
|
237
|
+
"""Convert an attachment dict (with raw data) into an OpenAI multimodal content part.
|
|
238
|
+
|
|
239
|
+
Images go as `image_url`; plain-text files (CSV/TXT) are inlined as text;
|
|
240
|
+
PDFs and binary Office/OpenDocument files go as a `file` part carrying the
|
|
241
|
+
raw bytes — the model reads them directly, no server-side conversion.
|
|
242
|
+
"""
|
|
190
243
|
mime = attachment["mime_type"]
|
|
191
|
-
|
|
244
|
+
filename = attachment["filename"]
|
|
192
245
|
if mime.startswith("image/"):
|
|
246
|
+
data_b64 = base64.b64encode(attachment["data"]).decode("ascii")
|
|
193
247
|
return {
|
|
194
248
|
"type": "image_url",
|
|
195
249
|
"image_url": {"url": f"data:{mime};base64,{data_b64}"},
|
|
196
250
|
}
|
|
197
|
-
if mime
|
|
251
|
+
if mime in TEXT_MIME_TYPES or mime.startswith("text/"):
|
|
252
|
+
text = attachment["data"].decode("utf-8", errors="replace")
|
|
253
|
+
return {"type": "text", "text": f"[File: {filename}]\n{text}"}
|
|
254
|
+
if mime == "application/pdf" or mime in OFFICE_MIME_TYPES:
|
|
255
|
+
data_b64 = base64.b64encode(attachment["data"]).decode("ascii")
|
|
198
256
|
return {
|
|
199
257
|
"type": "file",
|
|
200
258
|
"file": {
|
|
201
|
-
"filename":
|
|
259
|
+
"filename": filename,
|
|
202
260
|
"file_data": f"data:{mime};base64,{data_b64}",
|
|
203
261
|
},
|
|
204
262
|
}
|
|
205
|
-
return {"type": "text", "text": f"[Unsupported attachment: {
|
|
263
|
+
return {"type": "text", "text": f"[Unsupported attachment: {filename}]"}
|
|
206
264
|
|
|
207
265
|
|
|
208
266
|
def list_session_attachments(session_id: str, user_id: str) -> list[dict]:
|
|
@@ -458,15 +458,17 @@ TOOLS = [
|
|
|
458
458
|
"type": "function",
|
|
459
459
|
"function": {
|
|
460
460
|
"name": "list_documents",
|
|
461
|
-
"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).",
|
|
461
|
+
"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). Page past `limit` with `offset`; trim the payload to only the columns you need with `fields`.",
|
|
462
462
|
"parameters": {
|
|
463
463
|
"type": "object",
|
|
464
464
|
"properties": {
|
|
465
465
|
"doctype": {"type": "string", "enum": DOCUMENT_SLUGS, "description": "Document type slug"},
|
|
466
|
-
"filters": {"type": "object", "description": "Optional
|
|
466
|
+
"filters": {"type": "object", "description": "Optional filters on any column. Equality: {\"status\": \"Draft\", \"customer\": \"CUST-001\"}. Comparison (value = a 2-item array [op, value]): {\"grand_total\": [\">\", 100]}, {\"fit\": [\"!=\", \"A\"]}. NULL checks (1-item array): {\"fit\": [\"is null\"]}, {\"main_email\": [\"is not null\"]}. Allowed ops: =, !=, >, <, >=, <=, like, not like, is null, is not null.", "default": {}},
|
|
467
467
|
"order_by": {"type": "string", "description": "Optional column to sort by (e.g. \"occurred_at\" for a timeline). Defaults to creation."},
|
|
468
468
|
"order": {"type": "string", "enum": ["asc", "desc"], "description": "Sort direction (default desc)", "default": "desc"},
|
|
469
469
|
"limit": {"type": "integer", "description": "Max results (default 20, max 500)", "default": 20},
|
|
470
|
+
"offset": {"type": "integer", "description": "Skip this many rows before returning — for paging past `limit`. Default 0.", "default": 0},
|
|
471
|
+
"fields": {"type": "array", "items": {"type": "string"}, "description": "Optional: return ONLY these columns (plus name) instead of every field — keeps results lean when you need just a few. Unknown fields are ignored."},
|
|
470
472
|
},
|
|
471
473
|
"required": ["doctype"],
|
|
472
474
|
},
|
|
@@ -521,6 +523,41 @@ TOOLS = [
|
|
|
521
523
|
},
|
|
522
524
|
},
|
|
523
525
|
},
|
|
526
|
+
{
|
|
527
|
+
"type": "function",
|
|
528
|
+
"function": {
|
|
529
|
+
"name": "batch_update_documents",
|
|
530
|
+
"description": (
|
|
531
|
+
"Update MANY existing documents of the SAME doctype in ONE call — far cheaper than a "
|
|
532
|
+
"separate update_document per record when applying changes across a set (e.g. tagging or "
|
|
533
|
+
"qualifying a batch of leads). Pass `doctype` and `updates`: an array of {\"name\", \"data\"} "
|
|
534
|
+
"objects where `data` holds the fields to change on that record (identical shape to "
|
|
535
|
+
"update_document's `data`, including child-table arrays). Best-effort and per-item: each "
|
|
536
|
+
"record is updated independently, so one failure doesn't undo the others. Max 200 per call. "
|
|
537
|
+
"Only drafts (docstatus=0) can be updated. Returns {\"updated\", \"failed\", "
|
|
538
|
+
"\"results\": [{\"name\", \"ok\", \"error\"?}]}."
|
|
539
|
+
),
|
|
540
|
+
"parameters": {
|
|
541
|
+
"type": "object",
|
|
542
|
+
"properties": {
|
|
543
|
+
"doctype": {"type": "string", "enum": DOCUMENT_SLUGS},
|
|
544
|
+
"updates": {
|
|
545
|
+
"type": "array",
|
|
546
|
+
"description": "Up to 200 {name, data} objects — one per record to update.",
|
|
547
|
+
"items": {
|
|
548
|
+
"type": "object",
|
|
549
|
+
"properties": {
|
|
550
|
+
"name": {"type": "string", "description": "Document name/ID to update"},
|
|
551
|
+
"data": {"type": "object", "description": "Fields to change on this record (same as update_document's data)."},
|
|
552
|
+
},
|
|
553
|
+
"required": ["name", "data"],
|
|
554
|
+
},
|
|
555
|
+
},
|
|
556
|
+
},
|
|
557
|
+
"required": ["doctype", "updates"],
|
|
558
|
+
},
|
|
559
|
+
},
|
|
560
|
+
},
|
|
524
561
|
{
|
|
525
562
|
"type": "function",
|
|
526
563
|
"function": {
|
|
@@ -746,7 +783,7 @@ TOOLS = [
|
|
|
746
783
|
"type": "function",
|
|
747
784
|
"function": {
|
|
748
785
|
"name": "list_chat_attachments",
|
|
749
|
-
"description": "List all files
|
|
786
|
+
"description": "List all files the user has uploaded in this chat session — PDFs, images, spreadsheets (Excel/CSV/ODS), and documents (Word/OpenDocument). 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.",
|
|
750
787
|
"parameters": {"type": "object", "properties": {}},
|
|
751
788
|
},
|
|
752
789
|
},
|
|
@@ -754,7 +791,7 @@ TOOLS = [
|
|
|
754
791
|
"type": "function",
|
|
755
792
|
"function": {
|
|
756
793
|
"name": "retrieve_chat_attachment",
|
|
757
|
-
"description": "Retrieve a previously uploaded file (PDF or
|
|
794
|
+
"description": "Retrieve a previously uploaded file (PDF, image, spreadsheet, or document) by its id and inject it back into the conversation so you can read/analyze it — e.g. to cross-check a spreadsheet of companies against the CRM. 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.",
|
|
758
795
|
"parameters": {
|
|
759
796
|
"type": "object",
|
|
760
797
|
"properties": {
|
|
@@ -1251,22 +1288,59 @@ def build_tools():
|
|
|
1251
1288
|
# ---------------------------------------------------------------------------
|
|
1252
1289
|
|
|
1253
1290
|
|
|
1291
|
+
# Filter/order keys that aren't real columns but are meaningful to list_documents.
|
|
1292
|
+
_LIST_META_FILTER_KEYS = {"from_date", "to_date", "date_field", "search", "search_fields"}
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
def _validate_filter_columns(doctype_slug, filters) -> str | None:
|
|
1296
|
+
"""Reject a filter naming a column the doctype doesn't have. The key is
|
|
1297
|
+
interpolated into SQL, so this both closes that injection seam and hands the
|
|
1298
|
+
model a clean error instead of a 500. Returns an error string, or None."""
|
|
1299
|
+
if not filters:
|
|
1300
|
+
return None
|
|
1301
|
+
cols = services.document_columns(doctype_slug)
|
|
1302
|
+
if not cols:
|
|
1303
|
+
return None # unknown doctype -> handled downstream
|
|
1304
|
+
bad = [k for k in filters if k not in cols and k not in _LIST_META_FILTER_KEYS]
|
|
1305
|
+
if bad:
|
|
1306
|
+
return (f"Unknown filter column(s) for {doctype_slug}: {', '.join(bad)}. "
|
|
1307
|
+
f"Valid columns include: {', '.join(sorted(cols)[:40])}.")
|
|
1308
|
+
return None
|
|
1309
|
+
|
|
1310
|
+
|
|
1254
1311
|
def _handle_list_documents(args):
|
|
1255
1312
|
# List views don't need child tables — strip them so the LLM can see more rows
|
|
1256
1313
|
# within the tool-result budget. Use get_document to drill into one doc.
|
|
1257
|
-
|
|
1314
|
+
doctype = args["doctype"]
|
|
1315
|
+
filters = args.get("filters") or {}
|
|
1316
|
+
err = _validate_filter_columns(doctype, filters)
|
|
1317
|
+
if err:
|
|
1318
|
+
return {"error": err}
|
|
1319
|
+
order_by = args.get("order_by")
|
|
1320
|
+
cols = services.document_columns(doctype)
|
|
1321
|
+
if order_by and cols and order_by not in cols:
|
|
1322
|
+
return {"error": f"Unknown order_by column for {doctype}: {order_by}"}
|
|
1323
|
+
|
|
1324
|
+
cls_entry = services.DOCUMENT_CLASSES.get(services.SLUG_TO_DOCTYPE.get(doctype, ""))
|
|
1258
1325
|
child_keys = list(cls_entry.CHILD_TABLES.keys()) if cls_entry and cls_entry.CHILD_TABLES else []
|
|
1259
1326
|
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1327
|
+
fields = args.get("fields")
|
|
1328
|
+
try:
|
|
1329
|
+
rows = services.list_documents(
|
|
1330
|
+
doctype,
|
|
1331
|
+
filters=filters,
|
|
1332
|
+
limit=args.get("limit", 20),
|
|
1333
|
+
offset=args.get("offset", 0) or 0,
|
|
1334
|
+
order_by=order_by,
|
|
1335
|
+
order=args.get("order", "desc"),
|
|
1336
|
+
fields=fields,
|
|
1337
|
+
)
|
|
1338
|
+
except ValueError as e: # bad filter operator / malformed operator list
|
|
1339
|
+
return {"error": str(e)}
|
|
1340
|
+
if not fields: # projection already excludes child tables
|
|
1341
|
+
for row in rows:
|
|
1342
|
+
for key in child_keys:
|
|
1343
|
+
row.pop(key, None)
|
|
1270
1344
|
return rows
|
|
1271
1345
|
|
|
1272
1346
|
|
|
@@ -1285,6 +1359,13 @@ def _handle_update_document(args):
|
|
|
1285
1359
|
return services.update_document(args["doctype"], args["name"], args.get("data", {}))
|
|
1286
1360
|
|
|
1287
1361
|
|
|
1362
|
+
def _handle_batch_update_documents(args):
|
|
1363
|
+
try:
|
|
1364
|
+
return services.batch_update_documents(args["doctype"], args.get("updates"))
|
|
1365
|
+
except ValueError as e: # bad doctype / empty or oversized batch
|
|
1366
|
+
return {"error": str(e)}
|
|
1367
|
+
|
|
1368
|
+
|
|
1288
1369
|
def _handle_submit_document(args):
|
|
1289
1370
|
return services.submit_document(args["doctype"], args["name"])
|
|
1290
1371
|
|
|
@@ -1813,6 +1894,7 @@ TOOL_HANDLERS = {
|
|
|
1813
1894
|
"get_document": _handle_get_document,
|
|
1814
1895
|
"create_document": _handle_create_document,
|
|
1815
1896
|
"update_document": _handle_update_document,
|
|
1897
|
+
"batch_update_documents": _handle_batch_update_documents,
|
|
1816
1898
|
"submit_document": _handle_submit_document,
|
|
1817
1899
|
"cancel_document": _handle_cancel_document,
|
|
1818
1900
|
"discard_document": _handle_discard_document,
|
|
@@ -44,7 +44,8 @@ _EXCLUDE = {
|
|
|
44
44
|
# Mirror the REST permission model: reads are viewer+, writes are manager+,
|
|
45
45
|
# delete_master is admin-only (the handler also re-checks).
|
|
46
46
|
_WRITE = {
|
|
47
|
-
"create_document", "update_document", "
|
|
47
|
+
"create_document", "update_document", "batch_update_documents",
|
|
48
|
+
"submit_document", "cancel_document",
|
|
48
49
|
"discard_document", "convert_document", "create_master", "update_master",
|
|
49
50
|
}
|
|
50
51
|
_ADMIN = {"delete_master"}
|
|
@@ -313,6 +313,45 @@ def update_document(doctype_slug: str, name: str, data: dict) -> dict:
|
|
|
313
313
|
return doc.as_dict()
|
|
314
314
|
|
|
315
315
|
|
|
316
|
+
def batch_update_documents(doctype_slug: str, updates: list) -> dict:
|
|
317
|
+
"""Apply many ``{"name", "data"}`` updates to ONE doctype in a single call.
|
|
318
|
+
|
|
319
|
+
Best-effort and per-item: each update runs as its own update_document (its
|
|
320
|
+
own save + hooks), so a failing row doesn't roll back the others. Returns
|
|
321
|
+
``{"updated", "failed", "results": [{"name", "ok", "error"?}]}``. Because it
|
|
322
|
+
reuses update_document, every plugin save-hook still fires — e.g. a lead
|
|
323
|
+
update carrying a transient ``_note`` records its timeline Activity here too.
|
|
324
|
+
"""
|
|
325
|
+
doctype, cls = get_document_class(doctype_slug)
|
|
326
|
+
if not cls:
|
|
327
|
+
raise ValueError(f"Unknown document type: {doctype_slug}")
|
|
328
|
+
if not isinstance(updates, list) or not updates:
|
|
329
|
+
raise ValueError("updates must be a non-empty array of {name, data} objects")
|
|
330
|
+
if len(updates) > _BATCH_MAX:
|
|
331
|
+
raise ValueError(f"Too many updates ({len(updates)}); max {_BATCH_MAX} per call")
|
|
332
|
+
|
|
333
|
+
results, updated = [], 0
|
|
334
|
+
for i, item in enumerate(updates):
|
|
335
|
+
if not isinstance(item, dict):
|
|
336
|
+
results.append({"name": None, "ok": False, "error": f"item {i} is not an object"})
|
|
337
|
+
continue
|
|
338
|
+
name = item.get("name")
|
|
339
|
+
data = item.get("data")
|
|
340
|
+
if not name:
|
|
341
|
+
results.append({"name": None, "ok": False, "error": f"item {i} is missing 'name'"})
|
|
342
|
+
continue
|
|
343
|
+
if not isinstance(data, dict) or not data:
|
|
344
|
+
results.append({"name": name, "ok": False, "error": "missing 'data' object"})
|
|
345
|
+
continue
|
|
346
|
+
try:
|
|
347
|
+
update_document(doctype_slug, name, data)
|
|
348
|
+
results.append({"name": name, "ok": True})
|
|
349
|
+
updated += 1
|
|
350
|
+
except Exception as e: # noqa: BLE001 — per-item best effort; report, don't abort
|
|
351
|
+
results.append({"name": name, "ok": False, "error": str(e)})
|
|
352
|
+
return {"updated": updated, "failed": len(results) - updated, "results": results}
|
|
353
|
+
|
|
354
|
+
|
|
316
355
|
def submit_document(doctype_slug: str, name: str) -> dict:
|
|
317
356
|
doctype, cls = get_document_class(doctype_slug)
|
|
318
357
|
if not cls:
|
|
@@ -473,8 +512,87 @@ def _order_clause(order_by: str = None, order: str = "desc") -> str:
|
|
|
473
512
|
return f'"{order_by}" {direction}'
|
|
474
513
|
|
|
475
514
|
|
|
515
|
+
# --- Filter operators (safe, whitelisted) ---
|
|
516
|
+
#
|
|
517
|
+
# A list-style filter value is normally a scalar (equality), but it may also be
|
|
518
|
+
# an operator form so callers can express inequality / range / NULL checks:
|
|
519
|
+
# {"amount": [">", 100]} {"fit": ["!=", "A"]}
|
|
520
|
+
# {"fit": ["is null"]} {"main_email": ["is not null"]}
|
|
521
|
+
# The operator is looked up in this whitelist and NEVER interpolated from raw
|
|
522
|
+
# caller input, so an operator filter can't inject SQL. (The column name is
|
|
523
|
+
# validated separately against the doctype's real columns by the callers.)
|
|
524
|
+
_FILTER_OPS = {
|
|
525
|
+
"=": "=", "==": "=", "!=": "!=", "<>": "!=",
|
|
526
|
+
">": ">", "<": "<", ">=": ">=", "<=": "<=",
|
|
527
|
+
"like": "LIKE", "not like": "NOT LIKE",
|
|
528
|
+
"is null": "IS NULL", "is not null": "IS NOT NULL",
|
|
529
|
+
}
|
|
530
|
+
_NULLARY_OPS = {"IS NULL", "IS NOT NULL"} # take no bound value
|
|
531
|
+
|
|
532
|
+
# Max records a single batch write may touch (keeps one call bounded).
|
|
533
|
+
_BATCH_MAX = 200
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _resolve_op(raw) -> str:
|
|
537
|
+
op = _FILTER_OPS.get(str(raw).strip().lower())
|
|
538
|
+
if not op:
|
|
539
|
+
raise ValueError(
|
|
540
|
+
f"Unsupported filter operator: {raw!r} "
|
|
541
|
+
f"(allowed: {', '.join(sorted(set(_FILTER_OPS)))})"
|
|
542
|
+
)
|
|
543
|
+
return op
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _filter_atom(col: str, value):
|
|
547
|
+
"""Build (sql_clause, params) for one filter entry. `value` may be:
|
|
548
|
+
scalar -> `"col" = ?`
|
|
549
|
+
[op, val] -> `"col" <op> ?` (op whitelisted)
|
|
550
|
+
[op] -> `"col" IS [NOT] NULL` (nullary op only)
|
|
551
|
+
Raises ValueError on an unknown operator or a malformed operator list — the
|
|
552
|
+
tool/REST layer surfaces that as a clean error rather than bad SQL."""
|
|
553
|
+
if isinstance(value, (list, tuple)):
|
|
554
|
+
if len(value) == 1:
|
|
555
|
+
op = _resolve_op(value[0])
|
|
556
|
+
if op not in _NULLARY_OPS:
|
|
557
|
+
raise ValueError(f"Filter operator {value[0]!r} requires a value")
|
|
558
|
+
return f'"{col}" {op}', []
|
|
559
|
+
if len(value) == 2:
|
|
560
|
+
op = _resolve_op(value[0])
|
|
561
|
+
if op in _NULLARY_OPS:
|
|
562
|
+
return f'"{col}" {op}', [] # value ignored for IS [NOT] NULL
|
|
563
|
+
return f'"{col}" {op} ?', [value[1]]
|
|
564
|
+
raise ValueError(
|
|
565
|
+
f"Malformed filter for {col!r}: expected a scalar, [op], or [op, value]"
|
|
566
|
+
)
|
|
567
|
+
return f'"{col}" = ?', [value]
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _where_from_filters(db_filters: dict):
|
|
571
|
+
"""(where_parts, params) for a dict of already-parsed equality/operator
|
|
572
|
+
filters, via the whitelisted _filter_atom builder."""
|
|
573
|
+
where_parts, params = [], []
|
|
574
|
+
for k, v in db_filters.items():
|
|
575
|
+
clause, ps = _filter_atom(k, v)
|
|
576
|
+
where_parts.append(clause)
|
|
577
|
+
params.extend(ps)
|
|
578
|
+
return where_parts, params
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _projection_columns(doctype_slug: str, fields) -> set:
|
|
582
|
+
"""The set of real columns to keep for a `fields` projection — always
|
|
583
|
+
includes 'name'. Unknown field names are dropped (validated against the
|
|
584
|
+
doctype's real columns), so a projection can never widen or inject."""
|
|
585
|
+
real = document_columns(doctype_slug)
|
|
586
|
+
keep = {"name"}
|
|
587
|
+
for f in (fields or []):
|
|
588
|
+
if f in real:
|
|
589
|
+
keep.add(f)
|
|
590
|
+
return keep
|
|
591
|
+
|
|
592
|
+
|
|
476
593
|
def list_documents(doctype_slug: str, filters: dict = None, limit: int = 50, offset: int = 0,
|
|
477
|
-
include_discarded: bool = False, order_by: str = None, order: str = "desc"
|
|
594
|
+
include_discarded: bool = False, order_by: str = None, order: str = "desc",
|
|
595
|
+
fields: list = None) -> list:
|
|
478
596
|
doctype = SLUG_TO_DOCTYPE.get(doctype_slug)
|
|
479
597
|
if not doctype:
|
|
480
598
|
raise ValueError(f"Unknown document type: {doctype_slug}")
|
|
@@ -520,31 +638,39 @@ def list_documents(doctype_slug: str, filters: dict = None, limit: int = 50, off
|
|
|
520
638
|
|
|
521
639
|
search_where, search_params = _search_clause(db, doctype, doctype_slug, search, search_fields)
|
|
522
640
|
|
|
523
|
-
|
|
524
|
-
|
|
641
|
+
order_clause = _order_clause(order_by, order)
|
|
642
|
+
|
|
643
|
+
# get_all handles only pure-equality filters (no OFFSET, no free-text OR-group,
|
|
644
|
+
# no whitelisted operator/NULL forms), so drop to raw SQL whenever any of those
|
|
645
|
+
# is in play. An operator filter shows up as a list/tuple value in db_filters.
|
|
646
|
+
has_op_filter = any(isinstance(v, (list, tuple)) for v in db_filters.values())
|
|
647
|
+
|
|
525
648
|
if date_field and from_date and to_date:
|
|
526
|
-
|
|
649
|
+
# Both bounds set: the dict can only hold one constraint per key, so this
|
|
650
|
+
# path adds both date conditions explicitly.
|
|
651
|
+
rows = _list_with_date_range(
|
|
527
652
|
db, doctype, doctype_slug, db_filters, date_field, from_date, to_date, limit, offset,
|
|
528
653
|
extra_where=search_where, extra_params=search_params,
|
|
529
654
|
)
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
# get_all can't express OFFSET or a free-text OR-group, so drop to raw SQL
|
|
534
|
-
# whenever either is in play.
|
|
535
|
-
if offset or search_where:
|
|
536
|
-
return _list_with_offset(db, doctype, doctype_slug, db_filters, limit, offset, order_clause,
|
|
655
|
+
elif offset or search_where or has_op_filter:
|
|
656
|
+
rows = _list_with_offset(db, doctype, doctype_slug, db_filters, limit, offset, order_clause,
|
|
537
657
|
extra_where=search_where, extra_params=search_params)
|
|
658
|
+
else:
|
|
659
|
+
rows = db.get_all(
|
|
660
|
+
doctype,
|
|
661
|
+
filters=db_filters if db_filters else None,
|
|
662
|
+
fields=["*"],
|
|
663
|
+
order_by=order_clause,
|
|
664
|
+
limit=limit,
|
|
665
|
+
)
|
|
666
|
+
rows = _attach_children(db, doctype_slug, rows)
|
|
538
667
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
)
|
|
546
|
-
|
|
547
|
-
return _attach_children(db, doctype_slug, rows)
|
|
668
|
+
# `fields` projection: keep only the requested real columns (+ name) to trim
|
|
669
|
+
# the payload — child tables (never selected here) drop out automatically.
|
|
670
|
+
if fields:
|
|
671
|
+
keep = _projection_columns(doctype_slug, fields)
|
|
672
|
+
rows = [{k: v for k, v in row.items() if k in keep} for row in rows]
|
|
673
|
+
return rows
|
|
548
674
|
|
|
549
675
|
|
|
550
676
|
def count_documents(doctype_slug: str, filters: dict = None, include_discarded: bool = False) -> int:
|
|
@@ -588,14 +714,9 @@ def count_documents(doctype_slug: str, filters: dict = None, include_discarded:
|
|
|
588
714
|
if date_field and to_date:
|
|
589
715
|
where_parts.append(f'"{date_field}" <= ?')
|
|
590
716
|
params.append(to_date)
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
where_parts.append(f'"{k}" {op} ?')
|
|
595
|
-
params.append(val)
|
|
596
|
-
else:
|
|
597
|
-
where_parts.append(f'"{k}" = ?')
|
|
598
|
-
params.append(v)
|
|
717
|
+
fparts, fparams = _where_from_filters(db_filters)
|
|
718
|
+
where_parts.extend(fparts)
|
|
719
|
+
params.extend(fparams)
|
|
599
720
|
|
|
600
721
|
search_where, search_params = _search_clause(db, doctype, doctype_slug, search, search_fields)
|
|
601
722
|
if search_where:
|
|
@@ -639,12 +760,8 @@ def _filter_where(db, doctype: str, doctype_slug: str, filters: dict, include_di
|
|
|
639
760
|
where_parts.append(f'"{date_field}" >= ?'); params.append(from_date)
|
|
640
761
|
if date_field and to_date:
|
|
641
762
|
where_parts.append(f'"{date_field}" <= ?'); params.append(to_date)
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
op, val = v
|
|
645
|
-
where_parts.append(f'"{k}" {op} ?'); params.append(val)
|
|
646
|
-
else:
|
|
647
|
-
where_parts.append(f'"{k}" = ?'); params.append(v)
|
|
763
|
+
fparts, fparams = _where_from_filters(db_filters)
|
|
764
|
+
where_parts.extend(fparts); params.extend(fparams)
|
|
648
765
|
search_where, search_params = _search_clause(db, doctype, doctype_slug, search, search_fields)
|
|
649
766
|
if search_where:
|
|
650
767
|
where_parts.append(search_where)
|
|
@@ -692,16 +809,7 @@ def adjacent_documents(doctype_slug: str, name: str, filters: dict = None,
|
|
|
692
809
|
|
|
693
810
|
def _list_with_offset(db, doctype, doctype_slug, db_filters, limit, offset, order_clause="creation DESC",
|
|
694
811
|
extra_where=None, extra_params=None):
|
|
695
|
-
where_parts =
|
|
696
|
-
params = []
|
|
697
|
-
for k, v in db_filters.items():
|
|
698
|
-
if isinstance(v, (list, tuple)) and len(v) == 2:
|
|
699
|
-
op, val = v
|
|
700
|
-
where_parts.append(f'"{k}" {op} ?')
|
|
701
|
-
params.append(val)
|
|
702
|
-
else:
|
|
703
|
-
where_parts.append(f'"{k}" = ?')
|
|
704
|
-
params.append(v)
|
|
812
|
+
where_parts, params = _where_from_filters(db_filters)
|
|
705
813
|
if extra_where:
|
|
706
814
|
where_parts.append(extra_where)
|
|
707
815
|
params.extend(extra_params or [])
|
|
@@ -740,14 +848,9 @@ def _list_with_date_range(db, doctype, doctype_slug, extra_filters, date_field,
|
|
|
740
848
|
"""List documents when both from_date and to_date are set."""
|
|
741
849
|
where_parts = [f'"{date_field}" >= ?', f'"{date_field}" <= ?']
|
|
742
850
|
params = [from_date, to_date]
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
where_parts.append(f'"{k}" {op} ?')
|
|
747
|
-
params.append(val)
|
|
748
|
-
else:
|
|
749
|
-
where_parts.append(f'"{k}" = ?')
|
|
750
|
-
params.append(v)
|
|
851
|
+
fparts, fparams = _where_from_filters(extra_filters)
|
|
852
|
+
where_parts.extend(fparts)
|
|
853
|
+
params.extend(fparams)
|
|
751
854
|
if extra_where:
|
|
752
855
|
where_parts.append(extra_where)
|
|
753
856
|
params.extend(extra_params or [])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|