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.
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/PKG-INFO +4 -2
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/README.md +1 -1
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/attachments.py +22 -9
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/bootstrap.py +5 -1
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/chat.py +174 -54
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/erp_context.py +28 -3
- lambda_erp-1.4.1/api/llm_provider.py +97 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/main.py +6 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/pdf.py +152 -49
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/pdf_contract.py +52 -10
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/pdf_exports.py +4 -2
- lambda_erp-1.4.1/api/pdf_fonts.py +214 -0
- lambda_erp-1.4.1/api/pdf_pages.py +197 -0
- lambda_erp-1.4.1/api/pdf_templates.py +1109 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/routers/mcp.py +8 -2
- lambda_erp-1.4.1/api/routers/pdf_templates.py +62 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/routers/reports.py +7 -2
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/routers/setup.py +6 -2
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/services.py +31 -0
- lambda_erp-1.4.1/api/templates/delivery_note.html +173 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/templates/document.html +25 -1
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/templates/proposal.html +9 -2
- lambda_erp-1.4.1/api/templates/reservation.html +132 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/tool_permissions.py +4 -1
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/frontend/src/api/client.ts +29 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/sales_invoice.py +2 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/buying/purchase_order.py +2 -0
- lambda_erp-1.4.1/lambda_erp/controllers/item_prices.py +50 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/controllers/pricing_rule.py +3 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/database.py +105 -1
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/selling/quotation.py +5 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/selling/sales_order.py +3 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/stock/delivery_note.py +2 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/pyproject.toml +3 -1
- lambda_erp-1.3.3/lambda_erp/controllers/item_prices.py +0 -22
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/.gitignore +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/LICENSE +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/__init__.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/auth.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/chat_links.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/demo_limits.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/deps.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/errors.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/list_values.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/mcp_oauth.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/mentions.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/menu_layout.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/oauth.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/pdf_profiles.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/providers.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/record_notes.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/remarks_md.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/routers/__init__.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/routers/accounting.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/routers/actions.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/routers/admin.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/routers/analytics.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/routers/availability.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/routers/bank_statements.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/routers/chat_api.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/routers/documents.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/routers/masters.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/routers/proposals.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/templates/_proposal_totals.html +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/templates/_record_base.html +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/templates/record.html +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/time_filters.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/api/write_context.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/docs/agents/README.md +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/frontend/LICENSE +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/frontend/README.md +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/__init__.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/bank_account.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/bank_reconciliation.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/bank_statement_import.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/camt.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/settlement.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/assets/__init__.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/assets/asset.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/assets/lifecycle.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/assets/reservation.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/controllers/item_price.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/hooks.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/master_integrity.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/model.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/simulation.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/timestamps.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/utils.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/validation.py +0 -0
- {lambda_erp-1.3.3 → lambda_erp-1.4.1}/lambda_erp/workflow.py +0 -0
- {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
|
+
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
|
-
"""
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
3158
|
-
|
|
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
|
|
3396
|
+
"""Generate a short title for the chat from its first message.
|
|
3303
3397
|
|
|
3304
|
-
|
|
3305
|
-
|
|
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
|
-
|
|
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
|
|
3335
|
-
input=
|
|
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
|
-
|
|
3477
|
-
|
|
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
|
-
|
|
3605
|
-
|
|
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
|
-
|
|
3611
|
-
|
|
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
|
|
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,
|
|
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(
|
|
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
|
|
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')
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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,
|
|
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)
|