lambda-erp 0.6.15__tar.gz → 0.6.17__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.15 → lambda_erp-0.6.17}/PKG-INFO +2 -1
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/attachments.py +151 -6
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/chat.py +217 -24
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/frontend/src/api/client.ts +1 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/database.py +15 -1
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/pyproject.toml +2 -1
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/.gitignore +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/LICENSE +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/README.md +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/__init__.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/auth.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/bootstrap.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/demo_limits.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/deps.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/errors.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/main.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/oauth.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/pdf.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/providers.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/remarks_md.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/routers/__init__.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/routers/accounting.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/routers/admin.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/routers/analytics.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/routers/chat_api.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/routers/documents.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/routers/masters.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/routers/mcp.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/routers/proposals.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/routers/reports.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/routers/setup.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/services.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/templates/document.html +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/templates/proposal.html +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/docs/agents/README.md +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/frontend/LICENSE +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/frontend/README.md +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/__init__.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/hooks.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/model.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/simulation.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/utils.py +0 -0
- {lambda_erp-0.6.15 → lambda_erp-0.6.17}/terraform/README.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lambda-erp
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.17
|
|
4
4
|
Summary: Core ERP logic - accounting, sales, purchasing, inventory
|
|
5
5
|
Author: TORUS INVESTMENTS AG
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -13,6 +13,7 @@ Requires-Dist: holidays>=0.40
|
|
|
13
13
|
Requires-Dist: httpx>=0.27
|
|
14
14
|
Requires-Dist: jinja2>=3.1
|
|
15
15
|
Requires-Dist: openai>=1.0
|
|
16
|
+
Requires-Dist: openpyxl>=3.1
|
|
16
17
|
Requires-Dist: pydantic>=2.0
|
|
17
18
|
Requires-Dist: pypdf>=4.0
|
|
18
19
|
Requires-Dist: python-dateutil>=2.8
|
|
@@ -1,18 +1,26 @@
|
|
|
1
1
|
"""Chat attachment upload/download. Files stored on the local filesystem."""
|
|
2
2
|
|
|
3
3
|
import base64
|
|
4
|
+
import io
|
|
5
|
+
import logging
|
|
4
6
|
import os
|
|
5
7
|
import tempfile
|
|
8
|
+
import time
|
|
6
9
|
import uuid
|
|
10
|
+
from typing import Optional
|
|
7
11
|
|
|
12
|
+
import openpyxl
|
|
8
13
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
|
9
14
|
from fastapi.responses import Response
|
|
15
|
+
from openai import OpenAI
|
|
10
16
|
|
|
11
17
|
from lambda_erp.database import get_db
|
|
12
18
|
from lambda_erp.utils import now
|
|
13
19
|
from api.auth import require_role, get_current_user
|
|
14
20
|
from api.demo_limits import demo_max_attachment_bytes, is_demo_role
|
|
15
21
|
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
16
24
|
router = APIRouter(prefix="/chat", tags=["chat-attachments"])
|
|
17
25
|
|
|
18
26
|
MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 # 10 MB
|
|
@@ -67,6 +75,115 @@ _EXT_TO_MIME = {
|
|
|
67
75
|
}
|
|
68
76
|
_MIME_TO_EXT = {m: e for e, m in _EXT_TO_MIME.items()}
|
|
69
77
|
|
|
78
|
+
|
|
79
|
+
# --- OpenAI Files API (Office documents) -----------------------------------
|
|
80
|
+
#
|
|
81
|
+
# The chat model can't read Office bytes from an inline base64 `file_data` (that
|
|
82
|
+
# path is PDF-only), but it CAN read them when uploaded to the Files API and
|
|
83
|
+
# referenced by file_id. We upload each Office attachment once, cache the id on
|
|
84
|
+
# its row, and reuse it across turns. Every upload carries a 30-day
|
|
85
|
+
# `expires_after` so OpenAI reclaims the file on its own (users rarely delete
|
|
86
|
+
# chats and lambda-erp has no scheduler) — if a stale id is ever referenced we
|
|
87
|
+
# simply re-upload, since the bytes stay in our own storage. The per-use cost is
|
|
88
|
+
# the file's tokens on the completion, already billed via providers.py; there's
|
|
89
|
+
# no separate file fee to book.
|
|
90
|
+
_OPENAI_FILE_TTL_SECONDS = 30 * 24 * 3600 # 30 days
|
|
91
|
+
_OPENAI_FILE_REUSE_MARGIN = 300 # re-upload if within 5 min of expiry
|
|
92
|
+
SPREADSHEET_ROW_WARN_LIMIT = 1000 # OpenAI augments ~1000 rows/sheet
|
|
93
|
+
|
|
94
|
+
_openai_singleton: Optional[OpenAI] = None
|
|
95
|
+
_expires_after_supported = True # flips off if the API rejects it once
|
|
96
|
+
|
|
97
|
+
# The single Office spreadsheet mime we can cheaply row-count (openpyxl).
|
|
98
|
+
_XLSX_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _openai_client() -> OpenAI:
|
|
102
|
+
"""Lazily build a module-level OpenAI client for Files uploads (a separate
|
|
103
|
+
call from the chat completion; same OPENAI_API_KEY)."""
|
|
104
|
+
global _openai_singleton
|
|
105
|
+
if _openai_singleton is None:
|
|
106
|
+
_openai_singleton = OpenAI(api_key=os.environ.get("OPENAI_API_KEY", ""))
|
|
107
|
+
return _openai_singleton
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _create_openai_file(att: dict):
|
|
111
|
+
"""Upload the attachment's bytes to the Files API. Tries once with a 30-day
|
|
112
|
+
`expires_after`; if the SDK/API rejects it (older SDK, or the purpose doesn't
|
|
113
|
+
honor it) we disable it process-wide and fall back to a plain upload so
|
|
114
|
+
attachments keep working (accumulation is then unbounded until a sweep)."""
|
|
115
|
+
global _expires_after_supported
|
|
116
|
+
client = _openai_client()
|
|
117
|
+
file_tuple = (att["filename"], att["data"], att["mime_type"])
|
|
118
|
+
if _expires_after_supported:
|
|
119
|
+
try:
|
|
120
|
+
return client.files.create(
|
|
121
|
+
file=file_tuple,
|
|
122
|
+
purpose="user_data",
|
|
123
|
+
expires_after={"anchor": "created_at", "seconds": _OPENAI_FILE_TTL_SECONDS},
|
|
124
|
+
)
|
|
125
|
+
except Exception as e: # noqa: BLE001 — unsupported param/purpose -> fall back
|
|
126
|
+
_expires_after_supported = False
|
|
127
|
+
logger.warning("Files API expires_after unsupported (%s); using plain uploads", e)
|
|
128
|
+
return client.files.create(file=file_tuple, purpose="user_data")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def ensure_openai_file(att: dict) -> Optional[str]:
|
|
132
|
+
"""Return a currently-valid OpenAI file_id for an Office attachment, uploading
|
|
133
|
+
it (once) when there's no cached id or the cached one is at/near expiry.
|
|
134
|
+
Mutates `att` and persists the id + expiry on the Chat Attachment row.
|
|
135
|
+
Returns None on failure (caller degrades to a text note). Blocking — both
|
|
136
|
+
call sites run it in a worker thread, off the event loop."""
|
|
137
|
+
fid = att.get("openai_file_id")
|
|
138
|
+
exp = att.get("openai_file_expires_at")
|
|
139
|
+
if fid and (not exp or time.time() < int(exp) - _OPENAI_FILE_REUSE_MARGIN):
|
|
140
|
+
return fid
|
|
141
|
+
try:
|
|
142
|
+
uploaded = _create_openai_file(att)
|
|
143
|
+
except Exception as e: # noqa: BLE001 — surface as "couldn't load", not a 500
|
|
144
|
+
logger.warning("OpenAI file upload failed for %s: %s", att.get("id"), e)
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
new_id = uploaded.id
|
|
148
|
+
new_exp = getattr(uploaded, "expires_at", None)
|
|
149
|
+
att["openai_file_id"] = new_id
|
|
150
|
+
att["openai_file_expires_at"] = new_exp
|
|
151
|
+
try:
|
|
152
|
+
db = get_db()
|
|
153
|
+
db.sql(
|
|
154
|
+
'UPDATE "Chat Attachment" SET openai_file_id = ?, openai_file_expires_at = ? WHERE id = ?',
|
|
155
|
+
[new_id, new_exp, att.get("id")],
|
|
156
|
+
)
|
|
157
|
+
db.conn.commit()
|
|
158
|
+
except Exception as e: # persistence is best-effort — the id still works this turn
|
|
159
|
+
logger.warning("Could not persist openai_file_id for %s: %s", att.get("id"), e)
|
|
160
|
+
return new_id
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def spreadsheet_row_warning(data: bytes, mime: str, filename: str) -> Optional[str]:
|
|
164
|
+
"""If an .xlsx has a sheet over the model's ~1000-row augmentation limit,
|
|
165
|
+
return a short user-facing warning (else None). Only .xlsx is inspected —
|
|
166
|
+
openpyxl read-only, dimensions only (NOT a content extraction); other
|
|
167
|
+
spreadsheet formats upload without a row check."""
|
|
168
|
+
if mime != _XLSX_MIME:
|
|
169
|
+
return None
|
|
170
|
+
try:
|
|
171
|
+
wb = openpyxl.load_workbook(io.BytesIO(data), read_only=True)
|
|
172
|
+
worst, worst_sheet = 0, None
|
|
173
|
+
for ws in wb.worksheets:
|
|
174
|
+
rows = ws.max_row or 0
|
|
175
|
+
if rows > worst:
|
|
176
|
+
worst, worst_sheet = rows, ws.title
|
|
177
|
+
wb.close()
|
|
178
|
+
except Exception as e: # a warning is best-effort — never block the upload
|
|
179
|
+
logger.info("Row-count check skipped for %s: %s", filename, e)
|
|
180
|
+
return None
|
|
181
|
+
if worst > SPREADSHEET_ROW_WARN_LIMIT:
|
|
182
|
+
return (f"Sheet “{worst_sheet}” has {worst:,} rows — the assistant reads about "
|
|
183
|
+
f"{SPREADSHEET_ROW_WARN_LIMIT:,} rows per sheet, so results may be partial. "
|
|
184
|
+
"Split the file or ask about a specific range.")
|
|
185
|
+
return None
|
|
186
|
+
|
|
70
187
|
# Where uploaded files are stored. Must be a WRITABLE path — a package-relative
|
|
71
188
|
# location (e.g. next to this module) resolves under site-packages when the
|
|
72
189
|
# package is pip-installed in a container, which is read-only for the non-root
|
|
@@ -170,13 +287,19 @@ async def upload_attachment(
|
|
|
170
287
|
)
|
|
171
288
|
db.conn.commit()
|
|
172
289
|
|
|
173
|
-
|
|
290
|
+
resp = {
|
|
174
291
|
"id": att_id,
|
|
175
292
|
"filename": file.filename or f"file.{ext}",
|
|
176
293
|
"mime_type": mime,
|
|
177
294
|
"size_bytes": len(data),
|
|
178
295
|
"created_at": now(),
|
|
179
296
|
}
|
|
297
|
+
# Advisory (non-blocking): warn if a spreadsheet exceeds the model's
|
|
298
|
+
# per-sheet augmentation limit, so the user knows results may be partial.
|
|
299
|
+
warning = spreadsheet_row_warning(data, mime, file.filename or "")
|
|
300
|
+
if warning:
|
|
301
|
+
resp["warning"] = warning
|
|
302
|
+
return resp
|
|
180
303
|
|
|
181
304
|
|
|
182
305
|
@router.get("/attachments/{attachment_id}")
|
|
@@ -223,7 +346,8 @@ def get_attachments_by_ids(attachment_ids: list[str], user_id: str) -> list[dict
|
|
|
223
346
|
db = get_db()
|
|
224
347
|
placeholders = ",".join(["?"] * len(attachment_ids))
|
|
225
348
|
rows = db.sql(
|
|
226
|
-
f'SELECT id, filename, mime_type, file_path, size_bytes
|
|
349
|
+
f'SELECT id, filename, mime_type, file_path, size_bytes, '
|
|
350
|
+
f'openai_file_id, openai_file_expires_at FROM "Chat Attachment" '
|
|
227
351
|
f'WHERE id IN ({placeholders}) AND user_id = ?',
|
|
228
352
|
list(attachment_ids) + [user_id],
|
|
229
353
|
)
|
|
@@ -237,6 +361,8 @@ def get_attachments_by_ids(attachment_ids: list[str], user_id: str) -> list[dict
|
|
|
237
361
|
"filename": row["filename"],
|
|
238
362
|
"mime_type": row["mime_type"],
|
|
239
363
|
"size_bytes": row["size_bytes"],
|
|
364
|
+
"openai_file_id": row.get("openai_file_id"),
|
|
365
|
+
"openai_file_expires_at": row.get("openai_file_expires_at"),
|
|
240
366
|
"data": data,
|
|
241
367
|
})
|
|
242
368
|
except FileNotFoundError:
|
|
@@ -247,9 +373,13 @@ def get_attachments_by_ids(attachment_ids: list[str], user_id: str) -> list[dict
|
|
|
247
373
|
def build_multimodal_content(attachment: dict) -> dict:
|
|
248
374
|
"""Convert an attachment dict (with raw data) into an OpenAI multimodal content part.
|
|
249
375
|
|
|
250
|
-
Images go as `image_url`; plain-text files (CSV/TXT)
|
|
251
|
-
|
|
252
|
-
|
|
376
|
+
Images go as `image_url`; plain-text files (CSV/TXT) inline as text; PDFs go
|
|
377
|
+
as an inline base64 `file` part; binary Office/OpenDocument files are uploaded
|
|
378
|
+
to the Files API (once, cached) and referenced by `file_id` — OpenAI only
|
|
379
|
+
accepts PDF via inline base64, but reads Office bytes fine by file_id.
|
|
380
|
+
|
|
381
|
+
May upload to the Files API (blocking) for an Office file, so both call sites
|
|
382
|
+
run it in a worker thread, off the event loop.
|
|
253
383
|
"""
|
|
254
384
|
mime = attachment["mime_type"]
|
|
255
385
|
filename = attachment["filename"]
|
|
@@ -262,7 +392,7 @@ def build_multimodal_content(attachment: dict) -> dict:
|
|
|
262
392
|
if mime in TEXT_MIME_TYPES or mime.startswith("text/"):
|
|
263
393
|
text = attachment["data"].decode("utf-8", errors="replace")
|
|
264
394
|
return {"type": "text", "text": f"[File: {filename}]\n{text}"}
|
|
265
|
-
if mime == "application/pdf"
|
|
395
|
+
if mime == "application/pdf":
|
|
266
396
|
data_b64 = base64.b64encode(attachment["data"]).decode("ascii")
|
|
267
397
|
return {
|
|
268
398
|
"type": "file",
|
|
@@ -271,6 +401,21 @@ def build_multimodal_content(attachment: dict) -> dict:
|
|
|
271
401
|
"file_data": f"data:{mime};base64,{data_b64}",
|
|
272
402
|
},
|
|
273
403
|
}
|
|
404
|
+
if mime in OFFICE_MIME_TYPES:
|
|
405
|
+
# Office bytes are read via the Files API (file_id) — but ONLY the
|
|
406
|
+
# Responses API accepts non-PDF file inputs. On the default Chat
|
|
407
|
+
# Completions backend, degrade gracefully instead of triggering a hard
|
|
408
|
+
# 400 (flip ERP_CHAT_API=responses to actually read Office files).
|
|
409
|
+
if os.environ.get("ERP_CHAT_API", "chat").strip().lower() != "responses":
|
|
410
|
+
return {"type": "text",
|
|
411
|
+
"text": (f"[Attachment “{filename}” is an Office document; the current chat "
|
|
412
|
+
"backend can only read PDFs and images. Convert it to PDF, or ask an "
|
|
413
|
+
"admin to enable the Responses backend.]")}
|
|
414
|
+
file_id = ensure_openai_file(attachment)
|
|
415
|
+
if file_id:
|
|
416
|
+
return {"type": "file", "file": {"file_id": file_id}}
|
|
417
|
+
return {"type": "text",
|
|
418
|
+
"text": f"[Attachment “{filename}” couldn’t be loaded for analysis — please re-upload it.]"}
|
|
274
419
|
return {"type": "text", "text": f"[Unsupported attachment: {filename}]"}
|
|
275
420
|
|
|
276
421
|
|
|
@@ -2698,6 +2698,204 @@ async def generate_title(
|
|
|
2698
2698
|
ORCHESTRATOR_MODEL = "gpt-5.6-terra"
|
|
2699
2699
|
|
|
2700
2700
|
|
|
2701
|
+
# ---------------------------------------------------------------------------
|
|
2702
|
+
# LLM API selector: Chat Completions (default) vs Responses API
|
|
2703
|
+
#
|
|
2704
|
+
# The Responses API (/v1/responses) is what accepts non-PDF file inputs
|
|
2705
|
+
# (Office documents via input_file), and it does reasoning natively (no
|
|
2706
|
+
# reasoning_effort="none" hack). Migrating the whole loop is risky, so it's
|
|
2707
|
+
# behind a flag and OFF by default. To keep the blast radius tiny, ALL
|
|
2708
|
+
# conversation state stays Chat-Completions-shaped; we translate to/from the
|
|
2709
|
+
# Responses shape only at the single orchestrator call boundary
|
|
2710
|
+
# (_orchestrator_turn), and shim the Responses output back into the
|
|
2711
|
+
# Chat-shaped objects the loop and providers.py already expect.
|
|
2712
|
+
# ---------------------------------------------------------------------------
|
|
2713
|
+
|
|
2714
|
+
def _use_responses_api() -> bool:
|
|
2715
|
+
return os.environ.get("ERP_CHAT_API", "chat").strip().lower() == "responses"
|
|
2716
|
+
|
|
2717
|
+
|
|
2718
|
+
# Reasoning effort for the Responses path (native; the Chat path uses "none").
|
|
2719
|
+
_RESPONSES_REASONING_EFFORT = "low"
|
|
2720
|
+
|
|
2721
|
+
|
|
2722
|
+
class _ShimFn:
|
|
2723
|
+
def __init__(self, name, arguments):
|
|
2724
|
+
self.name = name
|
|
2725
|
+
self.arguments = arguments
|
|
2726
|
+
|
|
2727
|
+
|
|
2728
|
+
class _ShimToolCall:
|
|
2729
|
+
def __init__(self, call_id, name, arguments):
|
|
2730
|
+
self.id = call_id
|
|
2731
|
+
self.type = "function"
|
|
2732
|
+
self.function = _ShimFn(name, arguments)
|
|
2733
|
+
|
|
2734
|
+
|
|
2735
|
+
class _ShimMessage:
|
|
2736
|
+
"""Quacks like a Chat Completions `choice.message`."""
|
|
2737
|
+
def __init__(self, content, tool_calls):
|
|
2738
|
+
self.content = content
|
|
2739
|
+
self.tool_calls = tool_calls or None
|
|
2740
|
+
|
|
2741
|
+
|
|
2742
|
+
class _ShimUsageDetails:
|
|
2743
|
+
def __init__(self, cached_tokens):
|
|
2744
|
+
self.cached_tokens = cached_tokens
|
|
2745
|
+
|
|
2746
|
+
|
|
2747
|
+
class _ShimUsage:
|
|
2748
|
+
"""Quacks like a Chat Completions `response.usage` (so cost_of_openai_call
|
|
2749
|
+
and demo_limiter.settle read it unchanged)."""
|
|
2750
|
+
def __init__(self, prompt_tokens, completion_tokens, cached_tokens):
|
|
2751
|
+
self.prompt_tokens = prompt_tokens
|
|
2752
|
+
self.completion_tokens = completion_tokens
|
|
2753
|
+
self.prompt_tokens_details = _ShimUsageDetails(cached_tokens)
|
|
2754
|
+
|
|
2755
|
+
|
|
2756
|
+
def _responses_tools_from_chat(tools: list) -> list:
|
|
2757
|
+
"""Chat tool schema {"type":"function","function":{name,...}} -> the flatter
|
|
2758
|
+
Responses shape {"type":"function","name",...}."""
|
|
2759
|
+
out = []
|
|
2760
|
+
for t in tools or []:
|
|
2761
|
+
fn = t.get("function") if isinstance(t, dict) else None
|
|
2762
|
+
if t.get("type") == "function" and fn:
|
|
2763
|
+
out.append({
|
|
2764
|
+
"type": "function",
|
|
2765
|
+
"name": fn["name"],
|
|
2766
|
+
"description": fn.get("description", ""),
|
|
2767
|
+
"parameters": fn.get("parameters") or {"type": "object", "properties": {}},
|
|
2768
|
+
})
|
|
2769
|
+
else:
|
|
2770
|
+
out.append(t)
|
|
2771
|
+
return out
|
|
2772
|
+
|
|
2773
|
+
|
|
2774
|
+
def _responses_msg_content(content):
|
|
2775
|
+
"""A Chat message `content` -> Responses message content: a plain string
|
|
2776
|
+
passes through unchanged; a multimodal parts list becomes input_* parts
|
|
2777
|
+
(input_text/input_image/input_file). Only user messages carry lists."""
|
|
2778
|
+
if content is None:
|
|
2779
|
+
return ""
|
|
2780
|
+
if isinstance(content, str):
|
|
2781
|
+
return content
|
|
2782
|
+
parts = []
|
|
2783
|
+
for p in content:
|
|
2784
|
+
ptype = p.get("type")
|
|
2785
|
+
if ptype == "text":
|
|
2786
|
+
parts.append({"type": "input_text", "text": p.get("text", "")})
|
|
2787
|
+
elif ptype == "image_url":
|
|
2788
|
+
url = (p.get("image_url") or {}).get("url")
|
|
2789
|
+
parts.append({"type": "input_image", "image_url": url})
|
|
2790
|
+
elif ptype == "file":
|
|
2791
|
+
f = p.get("file") or {}
|
|
2792
|
+
item = {"type": "input_file"}
|
|
2793
|
+
if f.get("file_id"):
|
|
2794
|
+
item["file_id"] = f["file_id"]
|
|
2795
|
+
else:
|
|
2796
|
+
if f.get("filename"):
|
|
2797
|
+
item["filename"] = f["filename"]
|
|
2798
|
+
if f.get("file_data"):
|
|
2799
|
+
item["file_data"] = f["file_data"]
|
|
2800
|
+
parts.append(item)
|
|
2801
|
+
else:
|
|
2802
|
+
parts.append({"type": "input_text", "text": json.dumps(p, default=str)})
|
|
2803
|
+
return parts
|
|
2804
|
+
|
|
2805
|
+
|
|
2806
|
+
def _responses_request_from_chat(messages: list) -> tuple:
|
|
2807
|
+
"""Translate Chat `messages` into (instructions, input_items) for Responses.
|
|
2808
|
+
The first system message becomes `instructions`; assistant tool_calls become
|
|
2809
|
+
`function_call` items and tool results become `function_call_output` items."""
|
|
2810
|
+
instructions = None
|
|
2811
|
+
input_items: list = []
|
|
2812
|
+
for m in messages:
|
|
2813
|
+
role = m.get("role")
|
|
2814
|
+
content = m.get("content")
|
|
2815
|
+
if role == "system":
|
|
2816
|
+
flat = content if isinstance(content, str) else " ".join(
|
|
2817
|
+
p.get("text", "") for p in (content or []) if isinstance(p, dict)
|
|
2818
|
+
)
|
|
2819
|
+
if instructions is None:
|
|
2820
|
+
instructions = flat
|
|
2821
|
+
else:
|
|
2822
|
+
input_items.append({"role": "developer", "content": _responses_msg_content(content)})
|
|
2823
|
+
elif role == "assistant":
|
|
2824
|
+
if content:
|
|
2825
|
+
input_items.append({"role": "assistant", "content": _responses_msg_content(content)})
|
|
2826
|
+
for tc in m.get("tool_calls") or []:
|
|
2827
|
+
input_items.append({
|
|
2828
|
+
"type": "function_call",
|
|
2829
|
+
"call_id": tc["id"],
|
|
2830
|
+
"name": tc["function"]["name"],
|
|
2831
|
+
"arguments": tc["function"]["arguments"],
|
|
2832
|
+
})
|
|
2833
|
+
elif role == "tool":
|
|
2834
|
+
input_items.append({
|
|
2835
|
+
"type": "function_call_output",
|
|
2836
|
+
"call_id": m.get("tool_call_id"),
|
|
2837
|
+
"output": m.get("content") or "",
|
|
2838
|
+
})
|
|
2839
|
+
else: # user
|
|
2840
|
+
input_items.append({"role": "user", "content": _responses_msg_content(content)})
|
|
2841
|
+
return instructions, input_items
|
|
2842
|
+
|
|
2843
|
+
|
|
2844
|
+
def _shim_message_from_responses(response) -> _ShimMessage:
|
|
2845
|
+
"""Map a Responses result's output items into a Chat-shaped message."""
|
|
2846
|
+
text_parts, tool_calls = [], []
|
|
2847
|
+
for item in (getattr(response, "output", None) or []):
|
|
2848
|
+
itype = getattr(item, "type", None)
|
|
2849
|
+
if itype == "function_call":
|
|
2850
|
+
tool_calls.append(_ShimToolCall(item.call_id, item.name, item.arguments))
|
|
2851
|
+
elif itype == "message":
|
|
2852
|
+
for c in (getattr(item, "content", None) or []):
|
|
2853
|
+
if getattr(c, "type", None) in ("output_text", "text"):
|
|
2854
|
+
text_parts.append(getattr(c, "text", "") or "")
|
|
2855
|
+
# reasoning items are not echoed back (state is rebuilt from history each turn)
|
|
2856
|
+
text = "".join(text_parts) or (getattr(response, "output_text", "") or "")
|
|
2857
|
+
return _ShimMessage(text, tool_calls)
|
|
2858
|
+
|
|
2859
|
+
|
|
2860
|
+
def _shim_usage_from_responses(usage):
|
|
2861
|
+
if usage is None:
|
|
2862
|
+
return None
|
|
2863
|
+
inp = int(getattr(usage, "input_tokens", 0) or 0)
|
|
2864
|
+
out = int(getattr(usage, "output_tokens", 0) or 0)
|
|
2865
|
+
details = getattr(usage, "input_tokens_details", None)
|
|
2866
|
+
cached = int(getattr(details, "cached_tokens", 0) or 0) if details else 0
|
|
2867
|
+
return _ShimUsage(inp, out, cached)
|
|
2868
|
+
|
|
2869
|
+
|
|
2870
|
+
def _orchestrator_turn(client, messages, tools, max_tokens):
|
|
2871
|
+
"""One orchestrator LLM turn. Returns (message, usage) shaped like Chat
|
|
2872
|
+
Completions, using either the Chat or Responses API per ERP_CHAT_API.
|
|
2873
|
+
Blocking — call via asyncio.to_thread."""
|
|
2874
|
+
if _use_responses_api():
|
|
2875
|
+
instructions, input_items = _responses_request_from_chat(messages)
|
|
2876
|
+
resp = client.responses.create(
|
|
2877
|
+
model=ORCHESTRATOR_MODEL,
|
|
2878
|
+
instructions=instructions,
|
|
2879
|
+
input=input_items,
|
|
2880
|
+
tools=_responses_tools_from_chat(tools),
|
|
2881
|
+
tool_choice="auto",
|
|
2882
|
+
max_output_tokens=max_tokens,
|
|
2883
|
+
reasoning={"effort": _RESPONSES_REASONING_EFFORT},
|
|
2884
|
+
)
|
|
2885
|
+
return _shim_message_from_responses(resp), _shim_usage_from_responses(getattr(resp, "usage", None))
|
|
2886
|
+
resp = client.chat.completions.create(
|
|
2887
|
+
model=ORCHESTRATOR_MODEL,
|
|
2888
|
+
messages=messages,
|
|
2889
|
+
tools=tools,
|
|
2890
|
+
tool_choice="auto",
|
|
2891
|
+
max_completion_tokens=max_tokens,
|
|
2892
|
+
# gpt-5.6-terra rejects function tools on /v1/chat/completions unless
|
|
2893
|
+
# reasoning_effort is "none" (400 otherwise).
|
|
2894
|
+
reasoning_effort="none",
|
|
2895
|
+
)
|
|
2896
|
+
return resp.choices[0].message, getattr(resp, "usage", None)
|
|
2897
|
+
|
|
2898
|
+
|
|
2701
2899
|
async def run_thinking_loop(
|
|
2702
2900
|
messages: list[dict],
|
|
2703
2901
|
on_event,
|
|
@@ -2825,24 +3023,16 @@ async def run_thinking_loop(
|
|
|
2825
3023
|
print(f"[chat_llm] provider=openai model={model_name} session_id={session_id or '-'} iter={iteration + 1}", flush=True)
|
|
2826
3024
|
await on_event({"type": "llm_provider", "provider": "openai", "model": model_name})
|
|
2827
3025
|
try:
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
tool_choice="auto",
|
|
2834
|
-
max_completion_tokens=max_completion,
|
|
2835
|
-
# gpt-5.6-terra rejects function tools on /v1/chat/completions
|
|
2836
|
-
# unless reasoning_effort is "none" (400 otherwise). Tool
|
|
2837
|
-
# calls run without hidden reasoning until this loop migrates
|
|
2838
|
-
# to the Responses API.
|
|
2839
|
-
reasoning_effort="none",
|
|
3026
|
+
# One orchestrator turn via Chat Completions (default) or the
|
|
3027
|
+
# Responses API (ERP_CHAT_API=responses). Returns Chat-shaped
|
|
3028
|
+
# (message, usage) either way, so everything below is unchanged.
|
|
3029
|
+
message, usage = await asyncio.to_thread(
|
|
3030
|
+
_orchestrator_turn, openai_client, messages, build_tools(), max_completion,
|
|
2840
3031
|
)
|
|
2841
3032
|
except Exception as e:
|
|
2842
3033
|
await on_event({"type": "error", "content": f"Error calling LLM: {e}"})
|
|
2843
3034
|
return
|
|
2844
3035
|
|
|
2845
|
-
usage = getattr(response, "usage", None)
|
|
2846
3036
|
demo_limiter.settle(
|
|
2847
3037
|
reservation_id,
|
|
2848
3038
|
actual_cost_usd=cost_of_openai_call(model_name, usage),
|
|
@@ -2859,9 +3049,6 @@ async def run_thinking_loop(
|
|
|
2859
3049
|
if not settled:
|
|
2860
3050
|
demo_limiter.release(reservation_id)
|
|
2861
3051
|
|
|
2862
|
-
choice = response.choices[0]
|
|
2863
|
-
message = choice.message
|
|
2864
|
-
|
|
2865
3052
|
if not message.tool_calls:
|
|
2866
3053
|
content = message.content or ""
|
|
2867
3054
|
messages.append({"role": "assistant", "content": content})
|
|
@@ -3112,19 +3299,25 @@ async def run_session_turn(
|
|
|
3112
3299
|
# Stateless: only the current user message, no prior turns replayed.
|
|
3113
3300
|
conversation = [{"role": "user", "content": user_content}]
|
|
3114
3301
|
|
|
3115
|
-
# Multimodal attachments: replace the last user message with
|
|
3116
|
-
#
|
|
3302
|
+
# Multimodal attachments: replace the last user message with a content array
|
|
3303
|
+
# (image/PDF inline, Office via Files-API file_id) so the LLM can see them.
|
|
3304
|
+
# build_multimodal_content may upload an Office file to the Files API, so run
|
|
3305
|
+
# the whole build off the event loop in a worker thread.
|
|
3117
3306
|
if attachment_ids and attachments_user_id:
|
|
3118
3307
|
from api.attachments import get_attachments_by_ids, build_multimodal_content
|
|
3119
3308
|
atts = get_attachments_by_ids(attachment_ids, attachments_user_id)
|
|
3120
3309
|
if atts and conversation and conversation[-1].get("role") == "user":
|
|
3121
|
-
parts = []
|
|
3122
3310
|
text = conversation[-1].get("content") or ""
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3311
|
+
|
|
3312
|
+
def _build_attachment_parts():
|
|
3313
|
+
parts = []
|
|
3314
|
+
if text:
|
|
3315
|
+
parts.append({"type": "text", "text": text})
|
|
3316
|
+
for att in atts:
|
|
3317
|
+
parts.append(build_multimodal_content(att))
|
|
3318
|
+
return parts
|
|
3319
|
+
|
|
3320
|
+
conversation[-1] = {"role": "user", "content": await asyncio.to_thread(_build_attachment_parts)}
|
|
3128
3321
|
|
|
3129
3322
|
messages.extend(conversation)
|
|
3130
3323
|
|
|
@@ -1274,7 +1274,10 @@ class Database:
|
|
|
1274
1274
|
FOREIGN KEY (session_id) REFERENCES "Chat Session"(id)
|
|
1275
1275
|
)""",
|
|
1276
1276
|
|
|
1277
|
-
# --- Chat Attachments (PDFs
|
|
1277
|
+
# --- Chat Attachments (PDFs, images, and Office docs uploaded via chat) ---
|
|
1278
|
+
# openai_file_id/openai_file_expires_at cache the OpenAI Files API
|
|
1279
|
+
# upload for Office formats (sent to the model by file_id, reused
|
|
1280
|
+
# across turns, re-uploaded once the 30-day expiry lapses).
|
|
1278
1281
|
"""CREATE TABLE IF NOT EXISTS "Chat Attachment" (
|
|
1279
1282
|
id TEXT PRIMARY KEY,
|
|
1280
1283
|
session_id TEXT NOT NULL,
|
|
@@ -1283,6 +1286,8 @@ class Database:
|
|
|
1283
1286
|
mime_type TEXT NOT NULL,
|
|
1284
1287
|
size_bytes INTEGER NOT NULL,
|
|
1285
1288
|
file_path TEXT NOT NULL,
|
|
1289
|
+
openai_file_id TEXT,
|
|
1290
|
+
openai_file_expires_at INTEGER,
|
|
1286
1291
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
1287
1292
|
FOREIGN KEY (session_id) REFERENCES "Chat Session"(id)
|
|
1288
1293
|
)""",
|
|
@@ -2038,6 +2043,14 @@ def _m019_api_keys_per_user(db: "Database") -> None:
|
|
|
2038
2043
|
db._text_col_cache.pop("Api Key", None)
|
|
2039
2044
|
|
|
2040
2045
|
|
|
2046
|
+
def _m020_chat_attachment_openai_file(db: "Database") -> None:
|
|
2047
|
+
"""Cache the OpenAI Files API id (+ its expiry) on a chat attachment, so an
|
|
2048
|
+
uploaded Office file is sent to the model by file_id — reused across turns
|
|
2049
|
+
and re-uploaded only once its 30-day expires_after lapses."""
|
|
2050
|
+
db._add_column_if_missing("Chat Attachment", "openai_file_id", "TEXT")
|
|
2051
|
+
db._add_column_if_missing("Chat Attachment", "openai_file_expires_at", "INTEGER")
|
|
2052
|
+
|
|
2053
|
+
|
|
2041
2054
|
Database.MIGRATIONS = [
|
|
2042
2055
|
(1, "chat_message_session_id", _m001_chat_message_session_id),
|
|
2043
2056
|
(2, "chat_session_user_id", _m002_chat_session_user_id),
|
|
@@ -2058,6 +2071,7 @@ Database.MIGRATIONS = [
|
|
|
2058
2071
|
(17, "proposal_cover_template", _m017_proposal_cover_template),
|
|
2059
2072
|
(18, "quotation_item_frequency", _m018_quotation_item_frequency),
|
|
2060
2073
|
(19, "api_keys_per_user", _m019_api_keys_per_user),
|
|
2074
|
+
(20, "chat_attachment_openai_file", _m020_chat_attachment_openai_file),
|
|
2061
2075
|
]
|
|
2062
2076
|
|
|
2063
2077
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "lambda-erp"
|
|
3
|
-
version = "0.6.
|
|
3
|
+
version = "0.6.17"
|
|
4
4
|
description = "Core ERP logic - accounting, sales, purchasing, inventory"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "Apache-2.0"
|
|
@@ -22,6 +22,7 @@ dependencies = [
|
|
|
22
22
|
"weasyprint>=62.0",
|
|
23
23
|
"pypdf>=4.0",
|
|
24
24
|
"holidays>=0.40",
|
|
25
|
+
"openpyxl>=3.1",
|
|
25
26
|
]
|
|
26
27
|
|
|
27
28
|
# Postgres backend is opt-in: `pip install lambda-erp[postgres]`. SQLite (the
|
|
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
|