lambda-erp 1.3.3__tar.gz → 1.4.1__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 (126) hide show
  1. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/PKG-INFO +4 -2
  2. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/README.md +1 -1
  3. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/attachments.py +22 -9
  4. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/bootstrap.py +5 -1
  5. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/chat.py +174 -54
  6. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/erp_context.py +28 -3
  7. lambda_erp-1.4.1/api/llm_provider.py +97 -0
  8. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/main.py +6 -0
  9. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/pdf.py +152 -49
  10. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/pdf_contract.py +52 -10
  11. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/pdf_exports.py +4 -2
  12. lambda_erp-1.4.1/api/pdf_fonts.py +214 -0
  13. lambda_erp-1.4.1/api/pdf_pages.py +197 -0
  14. lambda_erp-1.4.1/api/pdf_templates.py +1109 -0
  15. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/routers/mcp.py +8 -2
  16. lambda_erp-1.4.1/api/routers/pdf_templates.py +62 -0
  17. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/routers/reports.py +7 -2
  18. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/routers/setup.py +6 -2
  19. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/services.py +31 -0
  20. lambda_erp-1.4.1/api/templates/delivery_note.html +173 -0
  21. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/templates/document.html +25 -1
  22. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/templates/proposal.html +9 -2
  23. lambda_erp-1.4.1/api/templates/reservation.html +132 -0
  24. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/tool_permissions.py +4 -1
  25. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/frontend/src/api/client.ts +29 -0
  26. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/sales_invoice.py +2 -0
  27. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/buying/purchase_order.py +2 -0
  28. lambda_erp-1.4.1/lambda_erp/controllers/item_prices.py +50 -0
  29. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/controllers/pricing_rule.py +3 -0
  30. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/database.py +105 -1
  31. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/selling/quotation.py +5 -0
  32. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/selling/sales_order.py +3 -0
  33. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/stock/delivery_note.py +2 -0
  34. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/pyproject.toml +3 -1
  35. lambda_erp-1.3.3/lambda_erp/controllers/item_prices.py +0 -22
  36. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/.gitignore +0 -0
  37. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/LICENSE +0 -0
  38. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/__init__.py +0 -0
  39. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/auth.py +0 -0
  40. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/chat_links.py +0 -0
  41. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/demo_limits.py +0 -0
  42. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/deps.py +0 -0
  43. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/errors.py +0 -0
  44. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/list_values.py +0 -0
  45. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/mcp_oauth.py +0 -0
  46. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/mentions.py +0 -0
  47. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/menu_layout.py +0 -0
  48. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/oauth.py +0 -0
  49. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/pdf_profiles.py +0 -0
  50. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/providers.py +0 -0
  51. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/record_notes.py +0 -0
  52. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/remarks_md.py +0 -0
  53. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/routers/__init__.py +0 -0
  54. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/routers/accounting.py +0 -0
  55. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/routers/actions.py +0 -0
  56. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/routers/admin.py +0 -0
  57. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/routers/analytics.py +0 -0
  58. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/routers/availability.py +0 -0
  59. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/routers/bank_reconciliation.py +0 -0
  60. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/routers/bank_statements.py +0 -0
  61. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/routers/chat_api.py +0 -0
  62. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/routers/documents.py +0 -0
  63. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/routers/masters.py +0 -0
  64. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/routers/proposals.py +0 -0
  65. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/templates/_proposal_totals.html +0 -0
  66. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/templates/_record_base.html +0 -0
  67. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/templates/record.html +0 -0
  68. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/time_filters.py +0 -0
  69. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/write_context.py +0 -0
  70. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/docs/agents/README.md +0 -0
  71. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/frontend/LICENSE +0 -0
  72. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/frontend/README.md +0 -0
  73. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/__init__.py +0 -0
  74. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/__init__.py +0 -0
  75. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/bank_account.py +0 -0
  76. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/bank_reconciliation.py +0 -0
  77. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/bank_statement_import.py +0 -0
  78. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/bank_transaction.py +0 -0
  79. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/budget.py +0 -0
  80. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/camt.py +0 -0
  81. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  82. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/general_ledger.py +0 -0
  83. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/journal_entry.py +0 -0
  84. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/payment_entry.py +0 -0
  85. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/pos_invoice.py +0 -0
  86. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/purchase_invoice.py +0 -0
  87. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/revaluation.py +0 -0
  88. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/settlement.py +0 -0
  89. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/__init__.py +0 -0
  90. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/engine.py +0 -0
  91. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/pack.py +0 -0
  92. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  93. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  94. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  95. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  96. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  97. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  98. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/profiles.py +0 -0
  99. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/spine.py +0 -0
  100. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/subscription.py +0 -0
  101. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/assets/__init__.py +0 -0
  102. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/assets/asset.py +0 -0
  103. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/assets/lifecycle.py +0 -0
  104. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/assets/reservation.py +0 -0
  105. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/buying/__init__.py +0 -0
  106. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/controllers/__init__.py +0 -0
  107. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/controllers/currency.py +0 -0
  108. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/controllers/defaults.py +0 -0
  109. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/controllers/item_price.py +0 -0
  110. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  111. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/exceptions.py +0 -0
  112. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/hooks.py +0 -0
  113. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/master_integrity.py +0 -0
  114. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/model.py +0 -0
  115. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/selling/__init__.py +0 -0
  116. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/selling/proposal.py +0 -0
  117. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/simulation.py +0 -0
  118. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/stock/__init__.py +0 -0
  119. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/stock/purchase_receipt.py +0 -0
  120. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/stock/stock_entry.py +0 -0
  121. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/stock/stock_ledger.py +0 -0
  122. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/timestamps.py +0 -0
  123. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/utils.py +0 -0
  124. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/validation.py +0 -0
  125. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/workflow.py +0 -0
  126. {lambda_erp-1.3.3 → lambda_erp-1.4.1}/terraform/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lambda-erp
3
- Version: 1.3.3
3
+ Version: 1.4.1
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: Apache-2.0
@@ -9,6 +9,7 @@ Requires-Python: >=3.10
9
9
  Requires-Dist: bcrypt>=5
10
10
  Requires-Dist: defusedxml>=0.7
11
11
  Requires-Dist: fastapi>=0.115
12
+ Requires-Dist: fonttools[woff]>=4.38
12
13
  Requires-Dist: holidays>=0.40
13
14
  Requires-Dist: httpx>=0.27
14
15
  Requires-Dist: jinja2>=3.1
@@ -16,6 +17,7 @@ Requires-Dist: openai>=1.0
16
17
  Requires-Dist: openpyxl>=3.1
17
18
  Requires-Dist: pydantic>=2.0
18
19
  Requires-Dist: pypdf>=4.0
20
+ Requires-Dist: pypdfium2>=4.30
19
21
  Requires-Dist: python-dateutil>=2.8
20
22
  Requires-Dist: python-dotenv>=1.0
21
23
  Requires-Dist: python-jose[cryptography]>=3.3
@@ -162,6 +164,7 @@ The result is booked in one step — chart of accounts, sensible default account
162
164
  - Custom analytics drafts via chat (persisted, shareable, editable)
163
165
  - Server-side aggregation tool for in-chat factual answers across large datasets
164
166
  - PDF / image attachment → add invoices, create quotations, etc. all directly by adding them in the chat
167
+ - **PDF output with your own layouts** — every offer, order, invoice, purchase order, delivery note, proposal and reservation prints as a PDF. Layouts are HTML/CSS templates with your own images and fonts, edited in the settings or simply by asking the chat ("make the offer layout look like our letterhead"), previewed against real documents, then published as versions; submitted documents keep the exact layout they were issued with. See [`docs/pdf-output.md`](docs/pdf-output.md).
165
168
  - **Programmatic API** — drive the ERP from external systems with per-user, role-capped Bearer keys: a [chat API](docs/chat-api.md) (converse with the agent) and a [REST API](docs/rest-api.md) (documents, masters, reports — the same endpoints the web app uses, so a connector or sync job can create and submit documents directly). Both off by default; an admin enables each independently and issues keys.
166
169
  - **MCP server** — the ERP speaks the [Model Context Protocol](https://modelcontextprotocol.io) at `POST /api/mcp`, so **any MCP-capable agent (Claude, Codex, an IDE assistant) can drive it directly** — list/create/update/submit documents, manage master data, run reports — as *structured, validated tool calls*, not brittle screen-scraping or hand-written HTTP glue. The tool list is built from the live registries, so a plugin's own doctypes (e.g. a CRM's leads/contacts) are exposed automatically. It reuses the *same* Bearer keys and roles as the REST API — a viewer key is read-only, a manager key can write — so access is scoped, auditable, and off by default. In practice: point your assistant at your instance and ask "which invoices are overdue?" or "create a quotation for Acme with 3× SVC-SPARK" and it operates on real data, from wherever you already work. The API-keys settings page hands you ready-to-paste Claude and Codex config the moment you create a key.
167
170
  - Auth with admin/manager/viewer roles plus a public demo mode
@@ -176,7 +179,6 @@ The result is booked in one step — chart of accounts, sensible default account
176
179
  - Manufacturing (BOM, work orders)
177
180
  - HR / Payroll beyond the journal-entry workaround
178
181
  - Regional compliance packs (GST, VAT returns, etc.) - see below
179
- - PDF report creation directly inside the chat
180
182
 
181
183
  ---
182
184
 
@@ -134,6 +134,7 @@ The result is booked in one step — chart of accounts, sensible default account
134
134
  - Custom analytics drafts via chat (persisted, shareable, editable)
135
135
  - Server-side aggregation tool for in-chat factual answers across large datasets
136
136
  - PDF / image attachment → add invoices, create quotations, etc. all directly by adding them in the chat
137
+ - **PDF output with your own layouts** — every offer, order, invoice, purchase order, delivery note, proposal and reservation prints as a PDF. Layouts are HTML/CSS templates with your own images and fonts, edited in the settings or simply by asking the chat ("make the offer layout look like our letterhead"), previewed against real documents, then published as versions; submitted documents keep the exact layout they were issued with. See [`docs/pdf-output.md`](docs/pdf-output.md).
137
138
  - **Programmatic API** — drive the ERP from external systems with per-user, role-capped Bearer keys: a [chat API](docs/chat-api.md) (converse with the agent) and a [REST API](docs/rest-api.md) (documents, masters, reports — the same endpoints the web app uses, so a connector or sync job can create and submit documents directly). Both off by default; an admin enables each independently and issues keys.
138
139
  - **MCP server** — the ERP speaks the [Model Context Protocol](https://modelcontextprotocol.io) at `POST /api/mcp`, so **any MCP-capable agent (Claude, Codex, an IDE assistant) can drive it directly** — list/create/update/submit documents, manage master data, run reports — as *structured, validated tool calls*, not brittle screen-scraping or hand-written HTTP glue. The tool list is built from the live registries, so a plugin's own doctypes (e.g. a CRM's leads/contacts) are exposed automatically. It reuses the *same* Bearer keys and roles as the REST API — a viewer key is read-only, a manager key can write — so access is scoped, auditable, and off by default. In practice: point your assistant at your instance and ask "which invoices are overdue?" or "create a quotation for Acme with 3× SVC-SPARK" and it operates on real data, from wherever you already work. The API-keys settings page hands you ready-to-paste Claude and Codex config the moment you create a key.
139
140
  - Auth with admin/manager/viewer roles plus a public demo mode
@@ -148,7 +149,6 @@ The result is booked in one step — chart of accounts, sensible default account
148
149
  - Manufacturing (BOM, work orders)
149
150
  - HR / Payroll beyond the journal-entry workaround
150
151
  - Regional compliance packs (GST, VAT returns, etc.) - see below
151
- - PDF report creation directly inside the chat
152
152
 
153
153
  ---
154
154
 
@@ -9,6 +9,7 @@ import time
9
9
  import uuid
10
10
  from typing import Optional
11
11
 
12
+ import httpx
12
13
  import openpyxl
13
14
  from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
14
15
  from fastapi.responses import Response
@@ -17,6 +18,7 @@ from openai import OpenAI
17
18
  from lambda_erp.database import get_db
18
19
  from lambda_erp.utils import now
19
20
  from api.auth import require_role, get_current_user
21
+ from api.llm_provider import client_kwargs
20
22
  from api.demo_limits import demo_max_attachment_bytes, is_demo_role
21
23
 
22
24
  logger = logging.getLogger(__name__)
@@ -56,10 +58,14 @@ STRUCTURED_TOOL_MIME_TYPES = {
56
58
  "application/xml", "text/xml", "application/zip", "application/x-zip-compressed",
57
59
  }
58
60
 
61
+ # Font files, for PDF templates (add_attachment_to_pdf_template). The model gets
62
+ # a one-line description, never the bytes.
63
+ FONT_MIME_TYPES = {"font/ttf", "font/otf", "font/woff", "font/woff2"}
64
+
59
65
  ALLOWED_MIME_TYPES = {
60
66
  "image/png", "image/jpeg", "image/gif", "image/webp",
61
67
  "application/pdf",
62
- } | OFFICE_MIME_TYPES | TEXT_MIME_TYPES | STRUCTURED_TOOL_MIME_TYPES
68
+ } | OFFICE_MIME_TYPES | TEXT_MIME_TYPES | STRUCTURED_TOOL_MIME_TYPES | FONT_MIME_TYPES
63
69
 
64
70
  # Extension -> canonical mime, so a correctly-named file still uploads when the
65
71
  # browser/OS mislabels its type (Office files are frequently sent as
@@ -81,6 +87,7 @@ _EXT_TO_MIME = {
81
87
  "pdf": "application/pdf",
82
88
  "png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
83
89
  "gif": "image/gif", "webp": "image/webp",
90
+ "ttf": "font/ttf", "otf": "font/otf", "woff": "font/woff", "woff2": "font/woff2",
84
91
  }
85
92
  _MIME_TO_EXT = {m: e for e, m in _EXT_TO_MIME.items()}
86
93
 
@@ -100,7 +107,6 @@ _OPENAI_FILE_TTL_SECONDS = 30 * 24 * 3600 # 30 days
100
107
  _OPENAI_FILE_REUSE_MARGIN = 300 # re-upload if within 5 min of expiry
101
108
  SPREADSHEET_ROW_WARN_LIMIT = 1000 # OpenAI augments ~1000 rows/sheet
102
109
 
103
- _openai_singleton: Optional[OpenAI] = None
104
110
  _expires_after_supported = True # flips off if the API rejects it once
105
111
 
106
112
  # The single Office spreadsheet mime we can cheaply row-count (openpyxl).
@@ -108,12 +114,9 @@ _XLSX_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
108
114
 
109
115
 
110
116
  def _openai_client() -> OpenAI:
111
- """Lazily build a module-level OpenAI client for Files uploads (a separate
112
- call from the chat completion; same OPENAI_API_KEY)."""
113
- global _openai_singleton
114
- if _openai_singleton is None:
115
- _openai_singleton = OpenAI(api_key=os.environ.get("OPENAI_API_KEY", ""))
116
- return _openai_singleton
117
+ """An OpenAI client for Files uploads (a separate call from the chat
118
+ turn; same provider and key, see api/llm_provider.py)."""
119
+ return OpenAI(**client_kwargs(httpx.Timeout(60.0, connect=10.0)))
117
120
 
118
121
 
119
122
  def _create_openai_file(att: dict):
@@ -256,7 +259,7 @@ async def upload_attachment(
256
259
  status_code=400,
257
260
  detail=(f"Unsupported file type: {mime or ext or 'unknown'}. Allowed: images, PDF, "
258
261
  "spreadsheets (Excel/CSV/ODS), documents (Word/OpenDocument), "
259
- "and structured bank files (XML/ZIP)."),
262
+ "structured bank files (XML/ZIP) and fonts (TTF/OTF/WOFF/WOFF2)."),
260
263
  )
261
264
 
262
265
  chunks: list[bytes] = []
@@ -417,6 +420,16 @@ def build_multimodal_content(attachment: dict) -> dict:
417
420
  "the conversation; use the dedicated bank-statement preview tool.]"
418
421
  ),
419
422
  }
423
+ if mime in FONT_MIME_TYPES:
424
+ from api.pdf_fonts import font_file_info
425
+ try:
426
+ font = font_file_info(attachment["data"])
427
+ what = f'family "{font["family"]}", style {font["style"]}, embedding: {font["embedding"]}'
428
+ except ValueError as exc:
429
+ what = str(exc)
430
+ return {"type": "text", "text": (
431
+ f"[Font file: {filename} ({what}; id {attachment['id']}). To print a PDF template with it, "
432
+ "call add_attachment_to_pdf_template with this attachment id.]")}
420
433
  if mime in TEXT_MIME_TYPES or mime.startswith("text/"):
421
434
  text = attachment["data"].decode("utf-8", errors="replace")
422
435
  return {"type": "text", "text": f"[File: {filename}]\n{text}"}
@@ -112,6 +112,7 @@ def bootstrap_demo() -> None:
112
112
  f"({DEMO_SIM_START} -> {DEMO_SIM_END}, seed={DEMO_SIM_SEED})",
113
113
  flush=True,
114
114
  )
115
+ from api.pdf_templates import pin_only
115
116
  from lambda_erp.simulation import HistoricalSimulator
116
117
 
117
118
  sim = HistoricalSimulator(
@@ -120,7 +121,10 @@ def bootstrap_demo() -> None:
120
121
  end=DEMO_SIM_END,
121
122
  seed=DEMO_SIM_SEED,
122
123
  )
123
- sim.run(simulate_activity=True)
124
+ # Rendering an archive PDF per seeded offer would turn a ~3-minute
125
+ # boot into far longer than the host's startup window allows.
126
+ with pin_only():
127
+ sim.run(simulate_activity=True)
124
128
  else:
125
129
  print(f"[bootstrap] simulation data present ({qtn_count} quotations), skipping", flush=True)
126
130
 
@@ -17,6 +17,7 @@ import json
17
17
  import logging
18
18
  import os
19
19
  import re
20
+ import time
20
21
  import traceback
21
22
  import uuid
22
23
  from datetime import date
@@ -38,6 +39,7 @@ from api.demo_limits import (
38
39
  is_demo_role,
39
40
  limiter as demo_limiter,
40
41
  )
42
+ from api.llm_provider import client_kwargs, llm_configured, not_configured_message
41
43
  from api.providers import cost_of_openai_call, cost_of_transcription
42
44
  from api.routers.masters import create_master_record, update_master_record, delete_master_record
43
45
  from api.routers.analytics import DATASET_FILTER_DESCRIPTION
@@ -566,7 +568,7 @@ TOOLS = [
566
568
  "include_meta": {"type": "boolean", "description": "Use true for complete lists or overviews: returns exact total and pagination/coverage metadata."},
567
569
  "include_discarded": {"type": "boolean", "description": "Include discarded drafts when checking recent changes; default false."},
568
570
  "doctype": {"type": "string", "enum": DOCUMENT_SLUGS, "description": "Document type slug"},
569
- "filters": {"type": "object", "description": "Optional filters on any column. Equality: {\"status\": \"Draft\", \"customer\": \"CUST-001\"}. Comparison (value = a 2-item array [op, value]): {\"grand_total\": [\">\", 100]}, {\"fit\": [\"!=\", \"A\"]}. Case-insensitive text substring: {\"customer_name\": [\"contains\", \"acme\"]}. NULL checks (1-item array): {\"fit\": [\"is null\"]}, {\"main_email\": [\"is not null\"]}. Allowed ops: =, !=, >, <, >=, <=, in, not in, contains, like, not like, is null, is not null. IN example: {\"type\": [\"in\", [\"call\", \"email\", \"note\"]]}. Free-text search: {\"search\": \"acme\"} matches the doctype's default text columns; narrow it with {\"search\": \"acme\", \"search_fields\": [\"company_name\", \"tags\"]}.", "default": {}},
571
+ "filters": {"type": "object", "description": "Optional filters on any column. Equality: {\"status\": \"Draft\", \"customer\": \"CUST-001\"}. Comparison (value = a 2-item array [op, value]): {\"grand_total\": [\">\", 100]}, {\"fit\": [\"!=\", \"A\"]}. Case-insensitive text substring: {\"customer_name\": [\"contains\", \"acme\"]}. NULL checks (1-item array): {\"fit\": [\"is null\"]}, {\"main_email\": [\"is not null\"]}. Allowed ops: =, !=, >, <, >=, <=, in, not in, contains, like, not like, is null, is not null. IN example: {\"type\": [\"in\", [\"call\", \"email\", \"note\"]]}. Free-text search: {\"search\": \"acme\"} matches the doctype's default text columns, and on documents with lines (offers, orders, invoices, delivery notes, receipts, stock entries, subscriptions) also any line's item code, item name or description; narrow the document's own columns with {\"search\": \"acme\", \"search_fields\": [\"company_name\", \"tags\"]}.", "default": {}},
570
572
  "order_by": {"type": "string", "description": "Optional column to sort by. Defaults to the time_filter field when supplied, otherwise creation."},
571
573
  "order": {"type": "string", "enum": ["asc", "desc"], "description": "Sort direction (default desc)", "default": "desc"},
572
574
  "limit": {"type": "integer", "description": "Max results (default 20, max 500)", "default": 20},
@@ -1074,6 +1076,36 @@ TOOLS = [
1074
1076
  },
1075
1077
  },
1076
1078
  },
1079
+ {
1080
+ "type": "function",
1081
+ "function": {
1082
+ "name": "inspect_pdf_fonts",
1083
+ "description": "List the exact fonts an uploaded PDF uses (read from the file, not guessed from its look), or identify an uploaded font file, and say for each whether PDF templates can print with it: installed, metric_compatible (another name with the same letter widths) or missing (falls back). Use it before styling a PDF template after a reference PDF.",
1084
+ "parameters": {
1085
+ "type": "object",
1086
+ "properties": {
1087
+ "attachment_id": {"type": "string", "description": "The PDF or font file attachment (from list_chat_attachments)."},
1088
+ },
1089
+ "required": ["attachment_id"],
1090
+ },
1091
+ },
1092
+ },
1093
+ {
1094
+ "type": "function",
1095
+ "function": {
1096
+ "name": "add_attachment_to_pdf_template",
1097
+ "description": "Add a font file (ttf, otf, woff, woff2) or image (png, jpg, gif, webp) the user uploaded in this chat to a PDF template's working copy as an asset. Reference it by its file name in the template: url(\"name.woff2\") in @font-face (the result includes a ready @font-face rule), <img src=\"logo.png\">. Only add fonts the user may use: a commercial font needs a licence that allows embedding it in PDFs; mention that when a user uploads one.",
1098
+ "parameters": {
1099
+ "type": "object",
1100
+ "properties": {
1101
+ "template": {"type": "string", "description": "PDF template id (e.g. PDFT-0001)."},
1102
+ "attachment_id": {"type": "string", "description": "The uploaded font or image (from list_chat_attachments)."},
1103
+ "filename": {"type": "string", "description": "Asset file name to use in the template; defaults to the upload's name."},
1104
+ },
1105
+ "required": ["template", "attachment_id"],
1106
+ },
1107
+ },
1108
+ },
1077
1109
  {
1078
1110
  "type": "function",
1079
1111
  "function": {
@@ -1570,6 +1602,11 @@ def _handle_list_documents(args):
1570
1602
  return {"error": str(exc)}
1571
1603
 
1572
1604
 
1605
+ # Tools whose result is a stored PDF export the reply may link to; the reply
1606
+ # check in run_session_turn rejects any other /documents/.../pdf link.
1607
+ PDF_EXPORT_TOOLS = ("generate_document_pdf", "preview_pdf_template")
1608
+
1609
+
1573
1610
  def _handle_generate_document_pdf(args, user=None):
1574
1611
  from api.pdf_exports import create_pdf_export
1575
1612
  from api.pdf_contract import PDFError
@@ -1989,6 +2026,56 @@ def _handle_retrieve_chat_attachment(args, session_id: str | None = None, user_i
1989
2026
  }
1990
2027
 
1991
2028
 
2029
+ def _chat_attachment(args, session_id, user_id):
2030
+ if not session_id or not user_id:
2031
+ return None, {"error": "No active chat session for attachments."}
2032
+ from api.attachments import get_attachments_by_ids
2033
+ atts = get_attachments_by_ids([args.get("attachment_id") or ""], user_id)
2034
+ if not atts:
2035
+ return None, {"error": "Attachment not found or access denied."}
2036
+ return atts[0], None
2037
+
2038
+
2039
+ def _handle_inspect_pdf_fonts(args, session_id: str | None = None, user_id: str | None = None):
2040
+ from api import pdf_fonts
2041
+ from api.attachments import FONT_MIME_TYPES
2042
+ att, error = _chat_attachment(args, session_id, user_id)
2043
+ if error:
2044
+ return error
2045
+ try:
2046
+ if att["mime_type"] == "application/pdf":
2047
+ fonts = pdf_fonts.fonts_in_pdf(att["data"])
2048
+ elif att["mime_type"] in FONT_MIME_TYPES:
2049
+ info = pdf_fonts.font_file_info(att["data"])
2050
+ fonts = [{**info, **pdf_fonts.availability(info["family"]),
2051
+ "note": "Add it to a template with add_attachment_to_pdf_template to print with this exact file."}]
2052
+ else:
2053
+ return {"error": f"{att['filename']} is neither a PDF nor a font file."}
2054
+ except Exception as exc: # noqa: BLE001 — a malformed upload is the user's file, not a server fault
2055
+ return {"error": f"Could not read the fonts of {att['filename']}: {exc}"}
2056
+ return {"filename": att["filename"], "fonts": fonts, "installed_fonts": list(pdf_fonts.installed_families())}
2057
+
2058
+
2059
+ def _handle_add_attachment_to_pdf_template(args, user=None, session_id: str | None = None,
2060
+ user_id: str | None = None):
2061
+ from api import pdf_templates
2062
+ att, error = _chat_attachment(args, session_id, user_id)
2063
+ if error:
2064
+ return error
2065
+ filename = args.get("filename") or re.sub(r"[^A-Za-z0-9._-]+", "-", att["filename"]).strip("-")
2066
+ try:
2067
+ result = pdf_templates.add_pdf_template_asset(args.get("template") or "", filename, att["data"], user)
2068
+ except (ValueError, pdf_templates.PDFError) as exc:
2069
+ return {"error": str(exc)}
2070
+ out = {"template": result["template"], "asset": filename, "assets": result["assets"]}
2071
+ if result.get("font"):
2072
+ out["font"] = result["font"]
2073
+ out["note"] = "Add the css rule to the template's <style> and use the family in font-family."
2074
+ else:
2075
+ out["note"] = f'Reference it as <img src="{filename}"> or url("{filename}").'
2076
+ return out
2077
+
2078
+
1992
2079
  def _load_camt_chat_attachments(args, user_id: str | None):
1993
2080
  attachment_ids = args.get("attachment_ids") or []
1994
2081
  if not user_id:
@@ -2348,6 +2435,8 @@ TOOL_HANDLERS = {
2348
2435
  "retrieve_chat_history": lambda args: _handle_retrieve_chat_history(args),
2349
2436
  "list_chat_attachments": _handle_list_chat_attachments,
2350
2437
  "retrieve_chat_attachment": _handle_retrieve_chat_attachment,
2438
+ "inspect_pdf_fonts": _handle_inspect_pdf_fonts,
2439
+ "add_attachment_to_pdf_template": _handle_add_attachment_to_pdf_template,
2351
2440
  "preview_bank_statement_attachments": _handle_preview_bank_statement_attachments,
2352
2441
  "import_bank_statement_attachments": _handle_import_bank_statement_attachments,
2353
2442
  "list_bank_reconciliation_queue": _handle_list_bank_reconciliation_queue,
@@ -2719,6 +2808,10 @@ The `remarks` (Notes / Terms) field on quotations, sales orders, and invoices re
2719
2808
  - A line starting with `>>` is a price line, split by the FIRST `|` pipe: the text BEFORE the pipe goes in the left (frequency) column, the text AFTER it in the right (amount) column — both placed literally as you write them, NOT interpreted (it does not know "Monatlich" is a frequency or that "CHF 380" is a number). Omit the pipe to put everything in the amount column. The two columns line up with the line-items table's frequency/amount columns, so use it for a separately- or recurring-billed item, e.g. `>> Monatlich | CHF 380.—`.
2720
2809
  - Separate blocks with a blank line; a single newline is just a line break.
2721
2810
  Plain text (no special characters) still renders fine, so only reach for the markup when it improves a customer-facing note.
2811
+ Quotations also have `intro_text`: the opening text (greeting, "Gerne unterbreiten wir Ihnen …"), printed above the line items with the same markup; `remarks` is the closing text / conditions after the totals. Put each in its own field. Both may use placeholders filled in at print time: {{ customer_name }}, {{ valid_till }}, {{ transaction_date }}, {{ name }}, {{ company }}, {{ currency }}. New offers start with the default texts (get_pdf_text_defaults / set_pdf_text_defaults); sending a field explicitly overrides its default.
2812
+
2813
+ ### Deductions on a line
2814
+ To take an amount off one position (already invoiced, credited from a previous project, a project rebate), keep the position at its full price and put the deduction on that same line: `price_list_rate` = full price per unit, `discount_amount` = deduction per unit, `discount_label` = its printed text (e.g. "Abzüglich bereits verrechnet (Rechnung SINV-0003)"). The line then counts the net (rate = price_list_rate − discount_amount); the PDF prints the full price and the deduction as an italic sub-line. A deduction can't exceed its position's price, so a line never goes negative and keeps its tax rate. Sending only `rate` with a new deduction takes that rate as the full price. To remove one, set `discount_amount` 0, `discount_label` empty and `rate` back to the full price. The deduction is carried into the sales order and invoice. There are no negative lines: a deduction always belongs to a position.
2722
2815
 
2723
2816
  ### Internal notes (never printed)
2724
2817
  `remarks` is PRINTED and goes to the customer or supplier. Internal team remarks ("customer disputes line 2", "@Anna please check the price") belong in an internal note on the record instead: `add_record_note` (doctype = the record type slug, e.g. `sales-invoice` or `customer`; record_id = its name), read with `list_record_notes`, change with `update_record_note` / `delete_record_note`. Every document and master type supports them; they appear in the record's "Internal notes" card and never change the record. To notify a colleague, first `search_mention_users` and put their exact `mention_text` in the body. Never put internal remarks into `remarks`, and never copy internal notes into customer-facing text.
@@ -2942,7 +3035,7 @@ Users can attach PDFs and images (receipts, bills, contracts, screenshots) to th
2942
3035
  # GPT-6.1 Sol acts as the planner/orchestrator. When it decides a report needs
2943
3036
  # a custom analytics view it calls the create/update custom analytics tools.
2944
3037
  # Those handlers make a separate, narrowly prompted GPT-6.1 Sol call for the bounded
2945
- # declarative report spec. Both roles use the existing OPENAI_API_KEY.
3038
+ # declarative report spec. Both roles use the same provider and key (api/llm_provider.py).
2946
3039
  # ---------------------------------------------------------------------------
2947
3040
 
2948
3041
 
@@ -3154,9 +3247,8 @@ def _generate_report_spec_via_openai(
3154
3247
  user_role: str | None = None,
3155
3248
  session_id: str | None = None,
3156
3249
  ) -> dict:
3157
- api_key = os.environ.get("OPENAI_API_KEY", "")
3158
- if not api_key or api_key == "sk-your-key-here":
3159
- raise RuntimeError("OPENAI_API_KEY is not configured")
3250
+ if not llm_configured():
3251
+ raise RuntimeError(not_configured_message())
3160
3252
 
3161
3253
  model = _report_specialist_model()
3162
3254
 
@@ -3186,10 +3278,7 @@ def _generate_report_spec_via_openai(
3186
3278
  )
3187
3279
  user_msg = "\n\n".join(user_parts)
3188
3280
 
3189
- client = OpenAI(
3190
- api_key=api_key,
3191
- timeout=httpx.Timeout(120.0, connect=10.0),
3192
- )
3281
+ client = OpenAI(**client_kwargs(httpx.Timeout(120.0, connect=10.0)))
3193
3282
  validation_feedback: str | None = None
3194
3283
  max_attempts = 2
3195
3284
 
@@ -3292,28 +3381,30 @@ def _title_model() -> str:
3292
3381
  return os.environ.get("LAMBDA_ERP_TITLE_MODEL") or TITLE_MODEL
3293
3382
 
3294
3383
 
3384
+ # Background title tasks, kept referenced so they aren't garbage-collected
3385
+ # before they finish.
3386
+ _title_tasks: set[asyncio.Task] = set()
3387
+
3388
+
3295
3389
  async def generate_title(
3296
3390
  session_id: str,
3297
3391
  user_message: str,
3298
- assistant_message: str,
3392
+ assistant_message: str = "",
3299
3393
  client_ip: str | None = None,
3300
3394
  user_role: str | None = None,
3301
3395
  ):
3302
- """Generate a short title for the chat based on the first exchange.
3396
+ """Generate a short title for the chat from its first message.
3303
3397
 
3304
- Called after the first assistant reply. Runs in the background so it
3305
- doesn't block the response.
3398
+ Started in the background as soon as the first message arrives, so the
3399
+ title shows up while the reply is still being written. Without a text
3400
+ message (attachments only) it runs after the first reply and uses that too.
3306
3401
  """
3307
- api_key = os.environ.get("OPENAI_API_KEY", "")
3308
- if not api_key or api_key == "sk-your-key-here":
3402
+ if not llm_configured():
3309
3403
  return
3310
3404
 
3311
3405
  title_model = _title_model()
3312
3406
  try:
3313
- client = OpenAI(
3314
- api_key=api_key,
3315
- timeout=httpx.Timeout(30.0, connect=5.0),
3316
- )
3407
+ client = OpenAI(**client_kwargs(httpx.Timeout(30.0, connect=5.0)))
3317
3408
  reservation_id = None
3318
3409
  if is_demo_role(user_role):
3319
3410
  _blocked, reservation_id = demo_limiter.reserve(
@@ -3325,14 +3416,18 @@ async def generate_title(
3325
3416
  return
3326
3417
 
3327
3418
  settled = False
3419
+ started = time.monotonic()
3420
+ exchange = f"User: {user_message[:200]}"
3421
+ if assistant_message:
3422
+ exchange += f"\nAssistant: {assistant_message[:200]}"
3328
3423
  try:
3329
3424
  # Responses API with low effort: GPT-6 models reason, and the
3330
3425
  # output budget must leave room for that before the title itself.
3331
3426
  response = await asyncio.to_thread(
3332
3427
  client.responses.create,
3333
3428
  model=title_model,
3334
- instructions="Generate a very short title (3-6 words, no quotes) for this chat based on the first exchange. Just the title, nothing else.",
3335
- input=f"User: {user_message[:200]}\nAssistant: {assistant_message[:200]}",
3429
+ instructions="Generate a very short title (3-6 words, no quotes) for this chat based on its first message. Just the title, nothing else.",
3430
+ input=exchange,
3336
3431
  max_output_tokens=400,
3337
3432
  reasoning={"effort": "low"},
3338
3433
  )
@@ -3353,6 +3448,8 @@ async def generate_title(
3353
3448
  title = (response.output_text or "").strip().strip('"\'')[:120]
3354
3449
  if title:
3355
3450
  update_session_title(session_id, title)
3451
+ print(f"[chat_title] model={title_model} session_id={session_id} "
3452
+ f"ms={int((time.monotonic() - started) * 1000)}", flush=True)
3356
3453
  else:
3357
3454
  logger.warning("[chat_title] model=%s returned no title (status=%s)",
3358
3455
  title_model, getattr(response, "status", None))
@@ -3473,8 +3570,11 @@ def _responses_msg_content(content):
3473
3570
  if ptype == "text":
3474
3571
  parts.append({"type": "input_text", "text": p.get("text", "")})
3475
3572
  elif ptype == "image_url":
3476
- url = (p.get("image_url") or {}).get("url")
3477
- parts.append({"type": "input_image", "image_url": url})
3573
+ image = p.get("image_url") or {}
3574
+ part = {"type": "input_image", "image_url": image.get("url")}
3575
+ if image.get("detail"):
3576
+ part["detail"] = image["detail"]
3577
+ parts.append(part)
3478
3578
  elif ptype == "file":
3479
3579
  f = p.get("file") or {}
3480
3580
  item = {"type": "input_file"}
@@ -3601,15 +3701,14 @@ async def run_thinking_loop(
3601
3701
  declarative-spec step to a separate narrowly prompted GPT-6.1 Sol call. We emit an
3602
3702
  `llm_provider` event around that delegation so the UI can surface it.
3603
3703
  """
3604
- openai_api_key = os.environ.get("OPENAI_API_KEY", "")
3605
- if not openai_api_key or openai_api_key == "sk-your-key-here":
3606
- await on_event({"type": "error", "content": "Error: OPENAI_API_KEY is not configured. Please set it in the .env file."})
3704
+ if not llm_configured():
3705
+ await on_event({"type": "error", "content": f"Error: {not_configured_message()}. Set it in the environment (.env)."})
3607
3706
  return
3608
3707
 
3609
- openai_client = OpenAI(
3610
- api_key=openai_api_key,
3611
- timeout=httpx.Timeout(120.0, connect=10.0),
3612
- )
3708
+ openai_client = OpenAI(**client_kwargs(
3709
+ httpx.Timeout(120.0, connect=10.0),
3710
+ erp_user=user_info.get("name") if user_info else None,
3711
+ ))
3613
3712
 
3614
3713
  # Closures below read through this reference so a live role/key refresh
3615
3714
  # immediately affects resource-aware handlers as well as the shared policy.
@@ -3654,6 +3753,9 @@ async def run_thinking_loop(
3654
3753
  tool_handlers["retrieve_chat_history"] = lambda args: _handle_retrieve_chat_history(args, session_id)
3655
3754
  user_id_for_tools = user_info.get("name") if user_info else None
3656
3755
  tool_handlers["list_chat_attachments"] = lambda args: _handle_list_chat_attachments(args, session_id, user_id_for_tools)
3756
+ tool_handlers["inspect_pdf_fonts"] = lambda args: _handle_inspect_pdf_fonts(args, session_id, user_id_for_tools)
3757
+ tool_handlers["add_attachment_to_pdf_template"] = lambda args: _handle_add_attachment_to_pdf_template(
3758
+ args, principal_ref[0], session_id, user_id_for_tools)
3657
3759
  tool_handlers["preview_bank_statement_attachments"] = (
3658
3760
  lambda args: _handle_preview_bank_statement_attachments(args, user_id_for_tools)
3659
3761
  )
@@ -3844,9 +3946,13 @@ async def run_thinking_loop(
3844
3946
  # Pull any multimodal content out of the tool result before serializing.
3845
3947
  # retrieve_chat_attachment returns a {"_multimodal_content": {...}} block
3846
3948
  # which we inject as a fresh user message so the LLM can "see" the file.
3949
+ # view_pdf_pages returns rendered pages under "_images" the same way;
3950
+ # they are shown to the model for this turn and never persisted.
3847
3951
  multimodal_block = None
3952
+ page_images = None
3848
3953
  if isinstance(result, dict):
3849
3954
  multimodal_block = result.pop("_multimodal_content", None)
3955
+ page_images = result.pop("_images", None)
3850
3956
 
3851
3957
  result_str = json.dumps(result, default=str)
3852
3958
  summary = result_str[:500] + "..." if len(result_str) > 500 else result_str
@@ -3871,7 +3977,7 @@ async def run_thinking_loop(
3871
3977
  event_payload["report_id"] = result["id"]
3872
3978
  if fn_name == "generate_document_pdf":
3873
3979
  event_payload["document_request"] = {key: fn_args.get(key) for key in ("doctype", "name")}
3874
- if success and fn_name == "generate_document_pdf" and isinstance(result, dict) and result.get("artifact_id"):
3980
+ if success and fn_name in PDF_EXPORT_TOOLS and isinstance(result, dict) and result.get("artifact_id"):
3875
3981
  event_payload["document"] = result
3876
3982
  if session_id:
3877
3983
  _save_tool_trace(session_id, "tool_result", {**event_payload, "result": result})
@@ -3885,6 +3991,9 @@ async def run_thinking_loop(
3885
3991
 
3886
3992
  if multimodal_block:
3887
3993
  pending_multimodal.append(multimodal_block)
3994
+ if page_images:
3995
+ from api.pdf_pages import image_parts
3996
+ pending_multimodal.extend(image_parts(page_images))
3888
3997
 
3889
3998
  # After this batch of tool calls, inject any retrieved attachments as
3890
3999
  # a new user message so the LLM can actually see them on the next turn.
@@ -3892,7 +4001,7 @@ async def run_thinking_loop(
3892
4001
  messages.append({
3893
4002
  "role": "user",
3894
4003
  "content": [
3895
- {"type": "text", "text": "Here are the attachments you requested:"},
4004
+ {"type": "text", "text": "Here are the attachments and PDF pages you requested:"},
3896
4005
  *pending_multimodal,
3897
4006
  ],
3898
4007
  })
@@ -3962,14 +4071,14 @@ VOICE_TRANSCRIBE_PROMPT = (
3962
4071
  _MIN_TRANSCRIBE_BYTES = 1500 # ~1s of webm/opus at the ~1.5 KB/s we assume below
3963
4072
 
3964
4073
 
3965
- def transcribe_audio(audio_bytes: bytes, audio_format: str, api_key: str) -> str:
4074
+ def transcribe_audio(audio_bytes: bytes, audio_format: str, erp_user: str | None = None) -> str:
3966
4075
  """Transcribe audio to text via OpenAI gpt-4o-transcribe.
3967
4076
 
3968
4077
  Returns the transcript, or "" when the model detects silence/near-silence
3969
4078
  (empty result or a prompt-echo hallucination on a near-silent clip).
3970
4079
  Raises on an actual API error so the caller can surface it.
3971
4080
  """
3972
- client = OpenAI(api_key=api_key, timeout=httpx.Timeout(60.0, connect=10.0))
4081
+ client = OpenAI(**client_kwargs(httpx.Timeout(60.0, connect=10.0), erp_user=erp_user))
3973
4082
 
3974
4083
  audio_file = io.BytesIO(audio_bytes)
3975
4084
  audio_file.name = f"voice.{audio_format or 'webm'}"
@@ -4040,10 +4149,34 @@ async def run_session_turn(
4040
4149
  Hooks (all optional, async):
4041
4150
  - ``on_event(event)`` streamed loop events (WS -> socket; REST -> no-op)
4042
4151
  - ``on_assistant_message(sid, content)`` fired after the reply is persisted
4043
- - ``on_title(sid, title)`` fired after a first-reply title is generated
4152
+ - ``on_title(sid, title)`` fired once the first message's title is generated
4044
4153
  """
4045
4154
  is_first_reply = count_assistant_messages(session_id) == 0
4046
4155
 
4156
+ def start_title(assistant_text: str = ""):
4157
+ async def _gen_and_notify():
4158
+ await generate_title(
4159
+ session_id,
4160
+ user_content,
4161
+ assistant_text,
4162
+ client_ip=client_ip,
4163
+ user_role=(user_info or {}).get("role"),
4164
+ )
4165
+ if on_title:
4166
+ session = get_session(session_id)
4167
+ if session:
4168
+ await on_title(session_id, session["title"])
4169
+ task = asyncio.create_task(_gen_and_notify())
4170
+ _title_tasks.add(task)
4171
+ task.add_done_callback(_title_tasks.discard)
4172
+
4173
+ # Title the chat from the first message right away, in parallel with the
4174
+ # reply: waiting for the whole reply left "New Chat" in the sidebar for
4175
+ # the length of the turn plus the title call.
4176
+ title_started = is_first_reply and bool((user_content or "").strip())
4177
+ if title_started:
4178
+ start_title()
4179
+
4047
4180
  messages = [{"role": "system", "content": build_system_prompt(user_info, channel=channel)}]
4048
4181
 
4049
4182
  if replay_history:
@@ -4077,7 +4210,7 @@ async def run_session_turn(
4077
4210
  generated_files = []
4078
4211
 
4079
4212
  async def pdf_events(event):
4080
- if event.get('type') == 'tool_result' and event.get('tool') == 'generate_document_pdf' and event.get('success') and event.get('document'):
4213
+ if event.get('type') == 'tool_result' and event.get('tool') in PDF_EXPORT_TOOLS and event.get('success') and event.get('document'):
4081
4214
  generated_files.append(event['document'])
4082
4215
  await on_event(event)
4083
4216
 
@@ -4111,20 +4244,8 @@ async def run_session_turn(
4111
4244
  await on_assistant_message(session_id, assistant_content)
4112
4245
  break
4113
4246
 
4114
- if is_first_reply and assistant_content:
4115
- async def _gen_and_notify():
4116
- await generate_title(
4117
- session_id,
4118
- user_content,
4119
- assistant_content,
4120
- client_ip=client_ip,
4121
- user_role=(user_info or {}).get("role"),
4122
- )
4123
- if on_title:
4124
- session = get_session(session_id)
4125
- if session:
4126
- await on_title(session_id, session["title"])
4127
- asyncio.create_task(_gen_and_notify())
4247
+ if is_first_reply and not title_started and assistant_content:
4248
+ start_title(assistant_content)
4128
4249
 
4129
4250
  return assistant_content
4130
4251
 
@@ -4454,11 +4575,10 @@ async def chat_websocket(
4454
4575
  # Voice → text: transcribe a recorded clip and return the text
4455
4576
  # for the composer. No LLM turn here — the user reviews/edits
4456
4577
  # the text and sends it like any typed message.
4457
- openai_api_key = os.environ.get("OPENAI_API_KEY", "")
4458
- if not openai_api_key or openai_api_key == "sk-your-key-here":
4578
+ if not llm_configured():
4459
4579
  await send_event(
4460
4580
  "transcription_result", request_id=request_id,
4461
- success=False, error="Speech-to-text is not available (OPENAI_API_KEY not configured).",
4581
+ success=False, error=f"Speech-to-text is not available ({not_configured_message()}).",
4462
4582
  )
4463
4583
  continue
4464
4584
 
@@ -4491,7 +4611,7 @@ async def chat_websocket(
4491
4611
 
4492
4612
  try:
4493
4613
  text = await asyncio.to_thread(
4494
- transcribe_audio, audio_bytes, audio_format, openai_api_key,
4614
+ transcribe_audio, audio_bytes, audio_format, ws_user_id,
4495
4615
  )
4496
4616
  except Exception as e:
4497
4617
  logger.warning("Transcription failed: %s", e)