lambda-erp 0.6.14__tar.gz → 0.6.16__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.14 → lambda_erp-0.6.16}/PKG-INFO +2 -1
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/attachments.py +154 -7
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/chat.py +14 -8
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/frontend/src/api/client.ts +1 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/database.py +15 -1
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/pyproject.toml +2 -1
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/.gitignore +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/LICENSE +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/README.md +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/__init__.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/auth.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/bootstrap.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/demo_limits.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/deps.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/errors.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/main.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/oauth.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/pdf.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/providers.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/remarks_md.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/routers/__init__.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/routers/accounting.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/routers/admin.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/routers/analytics.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/routers/chat_api.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/routers/documents.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/routers/masters.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/routers/mcp.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/routers/proposals.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/routers/reports.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/routers/setup.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/services.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/templates/document.html +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/templates/proposal.html +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/docs/agents/README.md +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/frontend/LICENSE +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/frontend/README.md +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/__init__.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/hooks.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/model.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/simulation.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/utils.py +0 -0
- {lambda_erp-0.6.14 → lambda_erp-0.6.16}/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.16
|
|
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,17 +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
|
|
7
|
+
import tempfile
|
|
8
|
+
import time
|
|
5
9
|
import uuid
|
|
10
|
+
from typing import Optional
|
|
6
11
|
|
|
12
|
+
import openpyxl
|
|
7
13
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
|
8
14
|
from fastapi.responses import Response
|
|
15
|
+
from openai import OpenAI
|
|
9
16
|
|
|
10
17
|
from lambda_erp.database import get_db
|
|
11
18
|
from lambda_erp.utils import now
|
|
12
19
|
from api.auth import require_role, get_current_user
|
|
13
20
|
from api.demo_limits import demo_max_attachment_bytes, is_demo_role
|
|
14
21
|
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
15
24
|
router = APIRouter(prefix="/chat", tags=["chat-attachments"])
|
|
16
25
|
|
|
17
26
|
MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 # 10 MB
|
|
@@ -66,7 +75,126 @@ _EXT_TO_MIME = {
|
|
|
66
75
|
}
|
|
67
76
|
_MIME_TO_EXT = {m: e for e, m in _EXT_TO_MIME.items()}
|
|
68
77
|
|
|
69
|
-
|
|
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
|
+
|
|
187
|
+
# Where uploaded files are stored. Must be a WRITABLE path — a package-relative
|
|
188
|
+
# location (e.g. next to this module) resolves under site-packages when the
|
|
189
|
+
# package is pip-installed in a container, which is read-only for the non-root
|
|
190
|
+
# app user, so every upload 500s with "[Errno 13] Permission denied". Default to
|
|
191
|
+
# the OS temp dir (always writable; chat attachments are session-scoped and
|
|
192
|
+
# get_attachments_by_ids tolerates a missing file). Deployments that need the
|
|
193
|
+
# files to survive a restart set LAMBDA_ERP_UPLOAD_DIR to a mounted volume.
|
|
194
|
+
UPLOAD_ROOT = (
|
|
195
|
+
os.environ.get("LAMBDA_ERP_UPLOAD_DIR")
|
|
196
|
+
or os.path.join(tempfile.gettempdir(), "lambda-erp-uploads")
|
|
197
|
+
)
|
|
70
198
|
|
|
71
199
|
|
|
72
200
|
def _ensure_upload_dir(user_id: str) -> str:
|
|
@@ -159,13 +287,19 @@ async def upload_attachment(
|
|
|
159
287
|
)
|
|
160
288
|
db.conn.commit()
|
|
161
289
|
|
|
162
|
-
|
|
290
|
+
resp = {
|
|
163
291
|
"id": att_id,
|
|
164
292
|
"filename": file.filename or f"file.{ext}",
|
|
165
293
|
"mime_type": mime,
|
|
166
294
|
"size_bytes": len(data),
|
|
167
295
|
"created_at": now(),
|
|
168
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
|
|
169
303
|
|
|
170
304
|
|
|
171
305
|
@router.get("/attachments/{attachment_id}")
|
|
@@ -212,7 +346,8 @@ def get_attachments_by_ids(attachment_ids: list[str], user_id: str) -> list[dict
|
|
|
212
346
|
db = get_db()
|
|
213
347
|
placeholders = ",".join(["?"] * len(attachment_ids))
|
|
214
348
|
rows = db.sql(
|
|
215
|
-
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" '
|
|
216
351
|
f'WHERE id IN ({placeholders}) AND user_id = ?',
|
|
217
352
|
list(attachment_ids) + [user_id],
|
|
218
353
|
)
|
|
@@ -226,6 +361,8 @@ def get_attachments_by_ids(attachment_ids: list[str], user_id: str) -> list[dict
|
|
|
226
361
|
"filename": row["filename"],
|
|
227
362
|
"mime_type": row["mime_type"],
|
|
228
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"),
|
|
229
366
|
"data": data,
|
|
230
367
|
})
|
|
231
368
|
except FileNotFoundError:
|
|
@@ -236,9 +373,13 @@ def get_attachments_by_ids(attachment_ids: list[str], user_id: str) -> list[dict
|
|
|
236
373
|
def build_multimodal_content(attachment: dict) -> dict:
|
|
237
374
|
"""Convert an attachment dict (with raw data) into an OpenAI multimodal content part.
|
|
238
375
|
|
|
239
|
-
Images go as `image_url`; plain-text files (CSV/TXT)
|
|
240
|
-
|
|
241
|
-
|
|
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.
|
|
242
383
|
"""
|
|
243
384
|
mime = attachment["mime_type"]
|
|
244
385
|
filename = attachment["filename"]
|
|
@@ -251,7 +392,7 @@ def build_multimodal_content(attachment: dict) -> dict:
|
|
|
251
392
|
if mime in TEXT_MIME_TYPES or mime.startswith("text/"):
|
|
252
393
|
text = attachment["data"].decode("utf-8", errors="replace")
|
|
253
394
|
return {"type": "text", "text": f"[File: {filename}]\n{text}"}
|
|
254
|
-
if mime == "application/pdf"
|
|
395
|
+
if mime == "application/pdf":
|
|
255
396
|
data_b64 = base64.b64encode(attachment["data"]).decode("ascii")
|
|
256
397
|
return {
|
|
257
398
|
"type": "file",
|
|
@@ -260,6 +401,12 @@ def build_multimodal_content(attachment: dict) -> dict:
|
|
|
260
401
|
"file_data": f"data:{mime};base64,{data_b64}",
|
|
261
402
|
},
|
|
262
403
|
}
|
|
404
|
+
if mime in OFFICE_MIME_TYPES:
|
|
405
|
+
file_id = ensure_openai_file(attachment)
|
|
406
|
+
if file_id:
|
|
407
|
+
return {"type": "file", "file": {"file_id": file_id}}
|
|
408
|
+
return {"type": "text",
|
|
409
|
+
"text": f"[Attachment “{filename}” couldn’t be loaded for analysis — please re-upload it.]"}
|
|
263
410
|
return {"type": "text", "text": f"[Unsupported attachment: {filename}]"}
|
|
264
411
|
|
|
265
412
|
|
|
@@ -3112,19 +3112,25 @@ async def run_session_turn(
|
|
|
3112
3112
|
# Stateless: only the current user message, no prior turns replayed.
|
|
3113
3113
|
conversation = [{"role": "user", "content": user_content}]
|
|
3114
3114
|
|
|
3115
|
-
# Multimodal attachments: replace the last user message with
|
|
3116
|
-
#
|
|
3115
|
+
# Multimodal attachments: replace the last user message with a content array
|
|
3116
|
+
# (image/PDF inline, Office via Files-API file_id) so the LLM can see them.
|
|
3117
|
+
# build_multimodal_content may upload an Office file to the Files API, so run
|
|
3118
|
+
# the whole build off the event loop in a worker thread.
|
|
3117
3119
|
if attachment_ids and attachments_user_id:
|
|
3118
3120
|
from api.attachments import get_attachments_by_ids, build_multimodal_content
|
|
3119
3121
|
atts = get_attachments_by_ids(attachment_ids, attachments_user_id)
|
|
3120
3122
|
if atts and conversation and conversation[-1].get("role") == "user":
|
|
3121
|
-
parts = []
|
|
3122
3123
|
text = conversation[-1].get("content") or ""
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3124
|
+
|
|
3125
|
+
def _build_attachment_parts():
|
|
3126
|
+
parts = []
|
|
3127
|
+
if text:
|
|
3128
|
+
parts.append({"type": "text", "text": text})
|
|
3129
|
+
for att in atts:
|
|
3130
|
+
parts.append(build_multimodal_content(att))
|
|
3131
|
+
return parts
|
|
3132
|
+
|
|
3133
|
+
conversation[-1] = {"role": "user", "content": await asyncio.to_thread(_build_attachment_parts)}
|
|
3128
3134
|
|
|
3129
3135
|
messages.extend(conversation)
|
|
3130
3136
|
|
|
@@ -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.16"
|
|
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
|