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.
Files changed (84) hide show
  1. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/PKG-INFO +2 -1
  2. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/attachments.py +151 -6
  3. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/chat.py +217 -24
  4. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/frontend/src/api/client.ts +1 -0
  5. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/database.py +15 -1
  6. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/pyproject.toml +2 -1
  7. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/.gitignore +0 -0
  8. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/LICENSE +0 -0
  9. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/README.md +0 -0
  10. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/__init__.py +0 -0
  11. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/auth.py +0 -0
  12. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/bootstrap.py +0 -0
  13. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/demo_limits.py +0 -0
  14. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/deps.py +0 -0
  15. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/errors.py +0 -0
  16. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/main.py +0 -0
  17. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/oauth.py +0 -0
  18. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/pdf.py +0 -0
  19. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/providers.py +0 -0
  20. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/remarks_md.py +0 -0
  21. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/routers/__init__.py +0 -0
  22. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/routers/accounting.py +0 -0
  23. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/routers/admin.py +0 -0
  24. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/routers/analytics.py +0 -0
  25. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/routers/bank_reconciliation.py +0 -0
  26. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/routers/chat_api.py +0 -0
  27. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/routers/documents.py +0 -0
  28. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/routers/masters.py +0 -0
  29. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/routers/mcp.py +0 -0
  30. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/routers/proposals.py +0 -0
  31. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/routers/reports.py +0 -0
  32. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/routers/setup.py +0 -0
  33. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/services.py +0 -0
  34. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/templates/document.html +0 -0
  35. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/api/templates/proposal.html +0 -0
  36. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/docs/agents/README.md +0 -0
  37. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/frontend/LICENSE +0 -0
  38. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/frontend/README.md +0 -0
  39. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/__init__.py +0 -0
  40. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/__init__.py +0 -0
  41. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/bank_transaction.py +0 -0
  42. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/budget.py +0 -0
  43. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  44. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/general_ledger.py +0 -0
  45. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/journal_entry.py +0 -0
  46. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/payment_entry.py +0 -0
  47. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/pos_invoice.py +0 -0
  48. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/purchase_invoice.py +0 -0
  49. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/revaluation.py +0 -0
  50. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/sales_invoice.py +0 -0
  51. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/setup/__init__.py +0 -0
  52. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/setup/engine.py +0 -0
  53. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/setup/pack.py +0 -0
  54. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  55. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  56. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  57. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  58. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  59. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  60. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/setup/profiles.py +0 -0
  61. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/setup/spine.py +0 -0
  62. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/accounting/subscription.py +0 -0
  63. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/buying/__init__.py +0 -0
  64. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/buying/purchase_order.py +0 -0
  65. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/controllers/__init__.py +0 -0
  66. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/controllers/currency.py +0 -0
  67. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/controllers/defaults.py +0 -0
  68. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/controllers/pricing_rule.py +0 -0
  69. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  70. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/exceptions.py +0 -0
  71. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/hooks.py +0 -0
  72. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/model.py +0 -0
  73. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/selling/__init__.py +0 -0
  74. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/selling/proposal.py +0 -0
  75. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/selling/quotation.py +0 -0
  76. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/selling/sales_order.py +0 -0
  77. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/simulation.py +0 -0
  78. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/stock/__init__.py +0 -0
  79. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/stock/delivery_note.py +0 -0
  80. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/stock/purchase_receipt.py +0 -0
  81. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/stock/stock_entry.py +0 -0
  82. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/stock/stock_ledger.py +0 -0
  83. {lambda_erp-0.6.15 → lambda_erp-0.6.17}/lambda_erp/utils.py +0 -0
  84. {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.15
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
- return {
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 FROM "Chat Attachment" '
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) are inlined as text;
251
- PDFs and binary Office/OpenDocument files go as a `file` part carrying the
252
- raw bytes the model reads them directly, no server-side conversion.
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" or mime in OFFICE_MIME_TYPES:
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
- response = await asyncio.to_thread(
2829
- openai_client.chat.completions.create,
2830
- model=model_name,
2831
- messages=messages,
2832
- tools=build_tools(),
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 an image/PDF-
3116
- # bearing content array so the LLM can see them directly.
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
- if text:
3124
- parts.append({"type": "text", "text": text})
3125
- for att in atts:
3126
- parts.append(build_multimodal_content(att))
3127
- conversation[-1] = {"role": "user", "content": parts}
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
 
@@ -98,6 +98,7 @@ export const api = {
98
98
  mime_type: string;
99
99
  size_bytes: number;
100
100
  created_at: string;
101
+ warning?: string;
101
102
  }>;
102
103
  },
103
104
  getChatAttachmentUrl: (id: string) => `${BASE}/chat/attachments/${encodeURIComponent(id)}`,
@@ -1274,7 +1274,10 @@ class Database:
1274
1274
  FOREIGN KEY (session_id) REFERENCES "Chat Session"(id)
1275
1275
  )""",
1276
1276
 
1277
- # --- Chat Attachments (PDFs + images uploaded via chat) ---
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.15"
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