lambda-erp 1.3.0__py3-none-any.whl → 1.3.2__py3-none-any.whl

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.
api/chat.py CHANGED
@@ -2720,6 +2720,9 @@ The `remarks` (Notes / Terms) field on quotations, sales orders, and invoices re
2720
2720
  - Separate blocks with a blank line; a single newline is just a line break.
2721
2721
  Plain text (no special characters) still renders fine, so only reach for the markup when it improves a customer-facing note.
2722
2722
 
2723
+ ### Internal notes (never printed)
2724
+ `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.
2725
+
2723
2726
  ### Purchase Cycle
2724
2727
  Purchase Order → Purchase Receipt (receiving) / Purchase Invoice (billing) → Payment Entry
2725
2728
 
@@ -3280,6 +3283,15 @@ def _generate_report_spec_via_openai(
3280
3283
  # ---------------------------------------------------------------------------
3281
3284
 
3282
3285
 
3286
+ # Small and cheap: a 3-6 word label. gpt-4.1-nano was retired by OpenAI and
3287
+ # every call failed silently, leaving all chats as "New Chat".
3288
+ TITLE_MODEL = "gpt-6-luna"
3289
+
3290
+
3291
+ def _title_model() -> str:
3292
+ return os.environ.get("LAMBDA_ERP_TITLE_MODEL") or TITLE_MODEL
3293
+
3294
+
3283
3295
  async def generate_title(
3284
3296
  session_id: str,
3285
3297
  user_message: str,
@@ -3296,12 +3308,12 @@ async def generate_title(
3296
3308
  if not api_key or api_key == "sk-your-key-here":
3297
3309
  return
3298
3310
 
3311
+ title_model = _title_model()
3299
3312
  try:
3300
3313
  client = OpenAI(
3301
3314
  api_key=api_key,
3302
3315
  timeout=httpx.Timeout(30.0, connect=5.0),
3303
3316
  )
3304
- title_model = "gpt-4.1-nano"
3305
3317
  reservation_id = None
3306
3318
  if is_demo_role(user_role):
3307
3319
  _blocked, reservation_id = demo_limiter.reserve(
@@ -3314,14 +3326,15 @@ async def generate_title(
3314
3326
 
3315
3327
  settled = False
3316
3328
  try:
3329
+ # Responses API with low effort: GPT-6 models reason, and the
3330
+ # output budget must leave room for that before the title itself.
3317
3331
  response = await asyncio.to_thread(
3318
- client.chat.completions.create,
3332
+ client.responses.create,
3319
3333
  model=title_model,
3320
- messages=[
3321
- {"role": "system", "content": "Generate a very short title (3-6 words, no quotes) for this chat based on the first exchange. Just the title, nothing else."},
3322
- {"role": "user", "content": f"User: {user_message[:200]}\nAssistant: {assistant_message[:200]}"},
3323
- ],
3324
- max_completion_tokens=30,
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]}",
3336
+ max_output_tokens=400,
3337
+ reasoning={"effort": "low"},
3325
3338
  )
3326
3339
  usage = getattr(response, "usage", None)
3327
3340
  demo_limiter.settle(
@@ -3331,20 +3344,25 @@ async def generate_title(
3331
3344
  role=user_role,
3332
3345
  provider="openai",
3333
3346
  model=title_model,
3334
- prompt_tokens=int(getattr(usage, "prompt_tokens", 0) or 0) if usage else 0,
3335
- completion_tokens=int(getattr(usage, "completion_tokens", 0) or 0) if usage else 0,
3347
+ prompt_tokens=int(getattr(usage, "input_tokens", 0) or 0) if usage else 0,
3348
+ completion_tokens=int(getattr(usage, "output_tokens", 0) or 0) if usage else 0,
3336
3349
  session_id=session_id,
3337
3350
  )
3338
3351
  settled = True
3339
3352
 
3340
- title = response.choices[0].message.content.strip().strip('"\'')
3353
+ title = (response.output_text or "").strip().strip('"\'')[:120]
3341
3354
  if title:
3342
3355
  update_session_title(session_id, title)
3356
+ else:
3357
+ logger.warning("[chat_title] model=%s returned no title (status=%s)",
3358
+ title_model, getattr(response, "status", None))
3343
3359
  finally:
3344
3360
  if not settled:
3345
3361
  demo_limiter.release(reservation_id)
3346
- except Exception:
3347
- pass # Title generation is best-effort
3362
+ except Exception as exc:
3363
+ # Best-effort, but never silent: a retired model once left every chat
3364
+ # untitled without a single log line.
3365
+ logger.warning("[chat_title] model=%s failed: %s", title_model, exc)
3348
3366
 
3349
3367
 
3350
3368
  # ---------------------------------------------------------------------------
@@ -3353,9 +3371,15 @@ async def generate_title(
3353
3371
 
3354
3372
  # GPT-6.1 Sol requires Responses for function tools and does not support
3355
3373
  # reasoning effort "none", including tool-free wrap-ups after the step limit.
3374
+ # LAMBDA_ERP_CHAT_MODEL overrides it per deployment (e.g. to trial a cheaper
3375
+ # model on dev); see docs/llm-pricing.md.
3356
3376
  ORCHESTRATOR_MODEL = "gpt-6.1-sol"
3357
3377
 
3358
3378
 
3379
+ def _chat_model() -> str:
3380
+ return os.environ.get("LAMBDA_ERP_CHAT_MODEL") or ORCHESTRATOR_MODEL
3381
+
3382
+
3359
3383
  # ---------------------------------------------------------------------------
3360
3384
  # LLM API selector: Responses API (default) vs Chat Completions
3361
3385
  #
@@ -3370,8 +3394,9 @@ ORCHESTRATOR_MODEL = "gpt-6.1-sol"
3370
3394
  # ---------------------------------------------------------------------------
3371
3395
 
3372
3396
  def _use_responses_api() -> bool:
3373
- # A legacy deployment flag must not route Sol 6.1 into an unsupported API.
3374
- if ORCHESTRATOR_MODEL == "gpt-6.1-sol":
3397
+ # A legacy deployment flag must not route a GPT-6.1 model into an
3398
+ # unsupported API.
3399
+ if _chat_model().startswith("gpt-6.1-"):
3375
3400
  return True
3376
3401
  # Default: the Responses API (needed for Office attachments + native
3377
3402
  # reasoning). Set ERP_CHAT_API=chat to fall back to Chat Completions.
@@ -3532,12 +3557,12 @@ def _shim_usage_from_responses(usage):
3532
3557
 
3533
3558
  def _orchestrator_turn(client, messages, tools, max_tokens, *, tool_choice="auto"):
3534
3559
  """One orchestrator LLM turn. Returns (message, usage) shaped like Chat
3535
- Completions. Sol 6.1 always uses Responses; compatible models honor ERP_CHAT_API.
3560
+ Completions. GPT-6.1 models always use Responses; others honor ERP_CHAT_API.
3536
3561
  Blocking — call via asyncio.to_thread."""
3537
3562
  if _use_responses_api():
3538
3563
  instructions, input_items = _responses_request_from_chat(messages)
3539
3564
  resp = client.responses.create(
3540
- model=ORCHESTRATOR_MODEL,
3565
+ model=_chat_model(),
3541
3566
  instructions=instructions,
3542
3567
  input=input_items,
3543
3568
  tools=_responses_tools_from_chat(tools),
@@ -3547,7 +3572,7 @@ def _orchestrator_turn(client, messages, tools, max_tokens, *, tool_choice="auto
3547
3572
  )
3548
3573
  return _shim_message_from_responses(resp), _shim_usage_from_responses(getattr(resp, "usage", None))
3549
3574
  resp = client.chat.completions.create(
3550
- model=ORCHESTRATOR_MODEL,
3575
+ model=_chat_model(),
3551
3576
  messages=messages,
3552
3577
  tools=tools,
3553
3578
  tool_choice=tool_choice,
@@ -3712,7 +3737,7 @@ async def run_thinking_loop(
3712
3737
  # on ANY exit path — including asyncio.CancelledError raised after
3713
3738
  # the SDK call has already returned but before settle() ran.
3714
3739
  settled = False
3715
- model_name = ORCHESTRATOR_MODEL
3740
+ model_name = _chat_model()
3716
3741
  try:
3717
3742
  print(f"[chat_llm] provider=openai model={model_name} session_id={session_id or '-'} iter={iteration + 1}", flush=True)
3718
3743
  await on_event({"type": "llm_provider", "provider": "openai", "model": model_name})
@@ -3899,9 +3924,9 @@ async def run_thinking_loop(
3899
3924
  tool_choice="none",
3900
3925
  )
3901
3926
  demo_limiter.settle(
3902
- None, actual_cost_usd=cost_of_openai_call(ORCHESTRATOR_MODEL, usage),
3927
+ None, actual_cost_usd=cost_of_openai_call(_chat_model(), usage),
3903
3928
  ip=client_ip or "unknown", role=user_role, provider="openai",
3904
- model=ORCHESTRATOR_MODEL,
3929
+ model=_chat_model(),
3905
3930
  prompt_tokens=int(getattr(usage, "prompt_tokens", 0) or 0) if usage else 0,
3906
3931
  completion_tokens=int(getattr(usage, "completion_tokens", 0) or 0) if usage else 0,
3907
3932
  session_id=session_id,
api/erp_context.py CHANGED
@@ -24,6 +24,13 @@ draft creation does not imply submission, stock movement or ledger posting. Chec
24
24
  status and warnings. Correct validation errors using the field metadata and user information.
25
25
  Use list/search filters and pagination; an empty or partial search is not proof of zero
26
26
  stock. Use query_dataset/get_report for totals and stock, with the requested scope and dates.
27
+ Text for the customer or supplier goes in a document's printed `remarks` (Notes / Terms)
28
+ field. Internal team remarks ("customer disputes line 2", "@Anna please check") go in an
29
+ internal note instead (add_record_note / list_record_notes): never printed, visible to
30
+ signed-in colleagues only, and @mentions there notify colleagues. Never put internal
31
+ remarks into `remarks`, and never copy internal notes into a document the customer sees.
32
+ Requests about the user's own sidebar menu ("hide rentals", "put CRM first") use
33
+ get_menu_layout, then update_menu_layout. They change only that user's menu, not access.
27
34
  Generate a fresh PDF after edits. Only a successful artifact descriptor proves generation;
28
35
  download that artifact, check its SHA-256 and deliver it through the calling application's
29
36
  attachment mechanism. Do not invent download URLs or reuse a stale document attachment.
api/main.py CHANGED
@@ -59,6 +59,10 @@ async def lifespan(app: FastAPI):
59
59
  with db.startup_lock():
60
60
  from api.mentions import initialize_mentions
61
61
  initialize_mentions()
62
+ from api.record_notes import initialize_record_notes
63
+ initialize_record_notes()
64
+ from api.menu_layout import initialize_menu_layout
65
+ initialize_menu_layout()
62
66
 
63
67
  # Load customer extension plugins before anything creates documents.
64
68
  load_plugins()
api/mentions.py CHANGED
@@ -18,6 +18,7 @@ from lambda_erp.database import get_db
18
18
  from lambda_erp.utils import now
19
19
 
20
20
  SOURCES = {}
21
+ _FALLBACKS = [] # (resolve(doctype) -> source | None, list_types() -> [doctype]) for unregistered types
21
22
  REAL_ROLES = {"viewer", "manager", "admin"}
22
23
  _WRITING_MENTION_NOTE = ContextVar("writing_mention_note", default=False)
23
24
  _MENTION_START = re.compile(r"(?<![\w@./+\-])@(?=\S)")
@@ -79,6 +80,31 @@ def register_mention_source(doctype, *, label, can_access, create_note, resolve_
79
80
  create_note=create_note, resolve_note=resolve_note, resolve_notes=resolve_notes)
80
81
 
81
82
 
83
+ def register_mention_source_fallback(resolve, list_types):
84
+ """Resolve sources for record types without an explicit registration
85
+ (api.record_notes uses this for every document and master type)."""
86
+ if all(entry[0] is not resolve for entry in _FALLBACKS):
87
+ _FALLBACKS.append((resolve, list_types))
88
+
89
+
90
+ def find_source(doctype):
91
+ source = SOURCES.get(doctype)
92
+ for resolve, _ in _FALLBACKS:
93
+ if source:
94
+ break
95
+ source = resolve(doctype)
96
+ return source
97
+
98
+
99
+ def supported_sources():
100
+ sources = {key: value["label"] for key, value in SOURCES.items()}
101
+ for resolve, list_types in _FALLBACKS:
102
+ for doctype in list_types():
103
+ if doctype not in sources and (source := resolve(doctype)):
104
+ sources[doctype] = source["label"]
105
+ return [{"doctype": key, "label": label} for key, label in sources.items()]
106
+
107
+
82
108
  class Input(BaseModel):
83
109
  model_config = ConfigDict(extra="forbid", strict=True)
84
110
 
@@ -121,7 +147,7 @@ def _identity(actor):
121
147
 
122
148
 
123
149
  def _source(doctype):
124
- source = SOURCES.get(doctype)
150
+ source = find_source(doctype)
125
151
  if not source:
126
152
  raise ValueError("This record type does not support mention notes.")
127
153
  return source
@@ -141,7 +167,7 @@ def search_mention_users(args, user):
141
167
  token = full_name if full_name and len(aliases.get(full_name.lower(), {})) == 1 else row["email"]
142
168
  row["mention_text"] = "@" + token
143
169
  return {"users": rows, "acting_user": _identity(actor),
144
- "sources": [{"doctype": key, "label": value["label"]} for key, value in SOURCES.items()]}
170
+ "sources": supported_sources()}
145
171
 
146
172
 
147
173
  def add_record_note(args, user):
@@ -221,7 +247,7 @@ def sync_note_mentions(doctype, record_id, note_id, body, user):
221
247
 
222
248
 
223
249
  def _visible(row, actor, notes=None, authors=None):
224
- source = SOURCES.get(row["doctype"])
250
+ source = find_source(row["doctype"])
225
251
  note = (notes.get((row["doctype"], row["note_id"])) if notes is not None else
226
252
  source["resolve_note"](row["note_id"], actor) if source else None)
227
253
  if not note:
@@ -242,7 +268,7 @@ def list_my_mentions(args, user):
242
268
  # source and author lookups so polling a populated inbox avoids N+1 queries.
243
269
  notes, authors = {}, {}
244
270
  for doctype in {row["doctype"] for row in rows}:
245
- source = SOURCES.get(doctype)
271
+ source = find_source(doctype)
246
272
  if not source:
247
273
  continue
248
274
  ids = list({row["note_id"] for row in rows if row["doctype"] == doctype})
@@ -301,7 +327,7 @@ def initialize_mentions():
301
327
  ("search_mention_users", search_mention_users, Search, "viewer",
302
328
  "Find enabled ERP colleagues for visible @mentions by name/email. Returns mention_text: insert that exact text in the note body to tag the person. Also returns canonical user IDs, acting_user and supported record types. Do not guess a person when ambiguous."),
303
329
  ("add_record_note", add_record_note, Note, "manager",
304
- "Save an internal note and notify people visibly @mentioned in its body, including yourself. First use search_mention_users, then include each recipient's exact mention_text in body, e.g. 'salü vom johnny @Jon Torus'. Recipients are determined only from body; there is no recipient-ID argument. Unknown @handles and ordinary emails remain text. Ambiguous names require @email. Generic CRM note writes use the same mention processing. Use a new request_id (UUID) per intended note; reuse it unchanged on retries. No email is sent. Authorship is the authenticated ERP account."),
330
+ "Save an internal team note on a record and notify people visibly @mentioned in its body, including yourself. doctype is the record type slug: any document or master type (e.g. sales-invoice, purchase-order, customer, supplier, item) or a plugin type listed in search_mention_users sources; record_id is the record's name. Internal notes are never printed and do not change the record; customer-facing text belongs in the document's printed `remarks` (Notes / Terms) field instead. A note needs no mention. To tag someone, first use search_mention_users, then include each recipient's exact mention_text in body, e.g. 'salü vom johnny @Jon Torus'. Recipients are determined only from body; there is no recipient-ID argument. Unknown @handles and ordinary emails remain text. Ambiguous names require @email. CRM lead notes written through the generic document tools use the same mention processing. Use a new request_id (UUID) per intended note; reuse it unchanged on retries. No email is sent. Authorship is the authenticated ERP account."),
305
331
  ("list_my_mentions", list_my_mentions, Inbox, "viewer",
306
332
  "List the authenticated ERP user's mentions, with record/note links. open means not done; unread is separate. Use this for 'my mentions', never guess or pass another user. Returns acting_user, unread_count and pagination."),
307
333
  ("set_mention_state", set_mention_state, State, "viewer",
api/menu_layout.py ADDED
@@ -0,0 +1,206 @@
1
+ """Personal sidebar menu: hide and reorder groups and items, per user.
2
+
3
+ The menu itself is defined by the frontend (core groups plus whatever plugins
4
+ register), so the web app publishes what it shows to each user as the
5
+ `nav_catalog` preference. Reads and writes here validate against that catalog;
6
+ the layout is stored as the `nav_layout` preference. Hiding only tidies the
7
+ menu: routes and permissions are unchanged.
8
+ """
9
+ import json
10
+
11
+ from pydantic import Field
12
+
13
+ from api import mentions, services
14
+ from lambda_erp.database import get_db
15
+
16
+ CATALOG_KEY = "nav_catalog"
17
+ LAYOUT_KEY = "nav_layout"
18
+ # The page that edits the menu can never be hidden, or a user could lock
19
+ # themselves out of undoing it. Its group therefore can't be hidden either.
20
+ LOCKED_ITEM = "/settings/menu"
21
+ REF = Field(min_length=1, max_length=200)
22
+
23
+
24
+ def _actor(user):
25
+ if (user or {}).get("_shared_connection"):
26
+ raise PermissionError("The menu is personal. The shared team key identifies its owner, "
27
+ "not the current user; connect with a personal ERP account.")
28
+ from api.auth import refresh_auth_principal
29
+ actor = refresh_auth_principal(user)
30
+ if not actor or actor.get("role") not in mentions.REAL_ROLES:
31
+ raise PermissionError("Sign in with a personal ERP account to change your menu.")
32
+ return actor.get("user_id") or actor["name"]
33
+
34
+
35
+ def _read(user_name, key, default):
36
+ rows = get_db().sql('SELECT value FROM "User Preference" WHERE user_name = ? AND key = ?',
37
+ [user_name, key])
38
+ try:
39
+ return json.loads(rows[0]["value"]) if rows else default
40
+ except (TypeError, ValueError):
41
+ return default
42
+
43
+
44
+ def _write(user_name, key, value):
45
+ db, text = get_db(), json.dumps(value, separators=(",", ":"))
46
+ with db.atomic():
47
+ if db.sql('SELECT 1 FROM "User Preference" WHERE user_name = ? AND key = ?', [user_name, key]):
48
+ db.sql('UPDATE "User Preference" SET value = ? WHERE user_name = ? AND key = ?',
49
+ [text, user_name, key])
50
+ else:
51
+ db.sql('INSERT INTO "User Preference" (user_name, key, value) VALUES (?, ?, ?)',
52
+ [user_name, key, text])
53
+
54
+
55
+ def _layout(user_name):
56
+ raw = _read(user_name, LAYOUT_KEY, {})
57
+ raw = raw if isinstance(raw, dict) else {}
58
+ items = raw.get("items") if isinstance(raw.get("items"), dict) else {}
59
+ return {"order": [k for k in raw.get("order") or [] if isinstance(k, str)],
60
+ "items": {g: [k for k in v if isinstance(k, str)] for g, v in items.items() if isinstance(v, list)},
61
+ "hidden": [k for k in raw.get("hidden") or [] if isinstance(k, str)]}
62
+
63
+
64
+ def _catalog(user_name):
65
+ raw = _read(user_name, CATALOG_KEY, [])
66
+ groups = []
67
+ for group in raw if isinstance(raw, list) else []:
68
+ if not isinstance(group, dict) or not isinstance(group.get("key"), str):
69
+ continue
70
+ items = [{"key": i["key"], "label": str(i.get("label") or i["key"]), "title": str(i.get("title") or "")}
71
+ for i in group.get("items") or [] if isinstance(i, dict) and isinstance(i.get("key"), str)]
72
+ groups.append({"key": group["key"], "label": str(group.get("label") or group["key"]),
73
+ "title": str(group.get("title") or ""), "items": items})
74
+ return groups
75
+
76
+
77
+ def _ordered(default, preferred):
78
+ """Keys named in `preferred` first, in that order; the rest keep their default order."""
79
+ first = [k for k in dict.fromkeys(preferred) if k in default]
80
+ return first + [k for k in default if k not in first]
81
+
82
+
83
+ def effective_menu(catalog, layout):
84
+ hidden = set(layout["hidden"])
85
+ by_key = {g["key"]: g for g in catalog}
86
+ menu = []
87
+ for key in _ordered([g["key"] for g in catalog], layout["order"]):
88
+ group = by_key[key]
89
+ items = {i["key"]: i for i in group["items"]}
90
+ menu.append({**{k: group[k] for k in ("key", "label", "title")}, "hidden": key in hidden,
91
+ "items": [{**items[i], "hidden": i in hidden, "locked": i == LOCKED_ITEM}
92
+ for i in _ordered(list(items), layout["items"].get(key, []))],
93
+ "locked": LOCKED_ITEM in items})
94
+ return menu
95
+
96
+
97
+ def _resolver(catalog):
98
+ """Map a reference (group key, item path, or an English or translated label) to a key."""
99
+ exact, named = {}, {}
100
+ for group in catalog:
101
+ exact[group["key"]] = ("group", group["key"], None)
102
+ for item in group["items"]:
103
+ exact[item["key"]] = ("item", item["key"], group["key"])
104
+ for group in catalog:
105
+ refs = [(group, "group", None)] + [(item, "item", group["key"]) for item in group["items"]]
106
+ for entry, kind, parent in refs:
107
+ for name in {entry["label"].casefold(), entry["title"].casefold()} - {""}:
108
+ named.setdefault(name, set()).add((kind, entry["key"], parent))
109
+
110
+ def resolve(ref):
111
+ if ref in exact:
112
+ return exact[ref]
113
+ matches = named.get(ref.strip().casefold(), set())
114
+ if len(matches) == 1:
115
+ return next(iter(matches))
116
+ if matches:
117
+ raise ValueError(f"{ref!r} is ambiguous: use one of the keys "
118
+ f"{', '.join(sorted(m[1] for m in matches))}.")
119
+ raise ValueError(f"Unknown menu entry {ref!r}. Call get_menu_layout for the available "
120
+ "groups and items.")
121
+ return resolve
122
+
123
+
124
+ class GetLayout(mentions.Input):
125
+ pass
126
+
127
+
128
+ class UpdateLayout(mentions.Input):
129
+ hide: list[str] = Field(default_factory=list, max_length=200)
130
+ show: list[str] = Field(default_factory=list, max_length=200)
131
+ group_order: list[str] | None = Field(default=None, max_length=200)
132
+ item_order: dict[str, list[str]] = Field(default_factory=dict, max_length=100)
133
+ reset: bool = False
134
+
135
+
136
+ def _response(catalog, layout, note=None):
137
+ result = {"menu": effective_menu(catalog, layout), "layout": layout}
138
+ if not catalog:
139
+ result["note"] = ("Your menu is not known yet: open the ERP web app once, then try again.")
140
+ if note:
141
+ result["note"] = note
142
+ return result
143
+
144
+
145
+ def get_menu_layout(args, user):
146
+ GetLayout.model_validate(args)
147
+ user_name = _actor(user)
148
+ return _response(_catalog(user_name), _layout(user_name))
149
+
150
+
151
+ def update_menu_layout(args, user):
152
+ data, user_name = UpdateLayout.model_validate(args), _actor(user)
153
+ catalog = _catalog(user_name)
154
+ if not catalog:
155
+ raise ValueError("Your menu is not known yet: open the ERP web app once, then try again.")
156
+ resolve = _resolver(catalog)
157
+ layout = {"order": [], "items": {}, "hidden": []} if data.reset else _layout(user_name)
158
+ hidden = dict.fromkeys(layout["hidden"])
159
+ for ref in data.show:
160
+ hidden.pop(resolve(ref)[1], None)
161
+ locked_group = next((g["key"] for g in catalog if any(i["key"] == LOCKED_ITEM for i in g["items"])), None)
162
+ for ref in data.hide:
163
+ kind, key, _ = resolve(ref)
164
+ if key == LOCKED_ITEM or key == locked_group:
165
+ raise ValueError(f"{ref!r} can't be hidden: it holds the menu settings page. "
166
+ "Hide the other items in it instead.")
167
+ hidden[key] = None
168
+ layout["hidden"] = list(hidden)
169
+ if data.group_order is not None:
170
+ keys = [resolve(ref) for ref in data.group_order]
171
+ if any(kind != "group" for kind, _, _ in keys):
172
+ raise ValueError("group_order takes groups only; reorder items with item_order.")
173
+ layout["order"] = [key for _, key, _ in keys]
174
+ for group_ref, refs in data.item_order.items():
175
+ kind, group, _ = resolve(group_ref)
176
+ if kind != "group":
177
+ raise ValueError(f"item_order keys are groups; {group_ref!r} is an item.")
178
+ keys = [resolve(ref) for ref in refs]
179
+ if any(kind != "item" or parent != group for kind, _, parent in keys):
180
+ raise ValueError(f"item_order[{group_ref!r}] may only list items of that group; "
181
+ "items can't move between groups.")
182
+ layout["items"][group] = [key for _, key, _ in keys]
183
+ _write(user_name, LAYOUT_KEY, layout)
184
+ return _response(catalog, layout)
185
+
186
+
187
+ def initialize_menu_layout():
188
+ services.register_action(
189
+ "get_menu_layout", get_menu_layout, minimum_role="viewer", pass_user=True,
190
+ parameters=GetLayout.model_json_schema(),
191
+ description=(
192
+ "Show the current user's personal sidebar menu: every group and item in display order, "
193
+ "with its key, English label, label in the user's language (title) and whether it is "
194
+ "hidden. Call this before update_menu_layout."))
195
+ services.register_action(
196
+ "update_menu_layout", update_menu_layout, minimum_role="viewer", pass_user=True,
197
+ parameters=UpdateLayout.model_json_schema(),
198
+ description=(
199
+ "Hide, show or reorder entries in the current user's own sidebar menu (no one else's). "
200
+ "Refer to an entry by its key from get_menu_layout (a group label such as 'Rentals', or "
201
+ "an item path such as '/app/asset') or by its label. hide/show take groups or items; "
202
+ "hiding a group hides all of it. group_order lists groups to put first, in that order "
203
+ "(the rest follow); item_order maps a group to its items in the new order. Items stay "
204
+ "in their group. reset=true restores the default menu before applying the rest. The "
205
+ "menu settings entry can't be hidden. Hiding only tidies the menu: pages stay "
206
+ "reachable and permissions are unchanged."))
api/providers.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """Pricing data for external LLM APIs the chat orchestrator uses.
2
2
 
3
- Mirrors the shape of `lambda-web/backend/providers.py`. Includes active models
4
- and GPT-6 alternatives for comparison. Prices are Standard USD per 1M tokens;
3
+ Mirrors the shape of `lambda-web/backend/providers.py`. Lists the OpenAI models
4
+ available to us. Prices are Standard USD per 1M tokens;
5
5
  see docs/llm-pricing.md for sources and billing scope.
6
6
 
7
7
  Consumers import `cost_of_openai_call` to turn an SDK `response.usage` object
@@ -19,60 +19,38 @@ from typing import Any, Optional
19
19
  # ---------------------------------------------------------------------------
20
20
 
21
21
  OPENAI_PRICING: dict[str, dict[str, Any]] = {
22
- # Official model pages, verified 2026-09-29. Above 272K input tokens,
23
- # the higher rates apply to the entire request (including cache hits).
22
+ # The models available to us, Standard tier, verified 2026-10-30. Above
23
+ # 272K input tokens the higher rates apply to the entire request
24
+ # (including cache hits). cache_write is the 30-minute retention rate,
25
+ # cache_write_12h the 12-hour one.
24
26
  "gpt-6-astra": {
25
27
  "tiered": True,
26
28
  "tiers": [
27
- {"max_input_tokens": 272_000, "input": 10.00, "cached_input": 1.00, "cache_write": 12.50, "output": 50.00},
28
- {"max_input_tokens": None, "input": 20.00, "cached_input": 2.00, "cache_write": 25.00, "output": 75.00},
29
- ],
30
- },
31
- "gpt-6-sol": {
32
- "tiered": True,
33
- "tiers": [
34
- {"max_input_tokens": 272_000, "input": 2.00, "cached_input": 0.20, "cache_write": 2.50, "output": 10.00},
35
- {"max_input_tokens": None, "input": 4.00, "cached_input": 0.40, "cache_write": 5.00, "output": 15.00},
36
- ],
37
- },
38
- "gpt-6-luna": {
39
- "tiered": True,
40
- "tiers": [
41
- {"max_input_tokens": 272_000, "input": 0.10, "cached_input": 0.01, "cache_write": 0.125, "output": 0.50},
42
- {"max_input_tokens": None, "input": 0.20, "cached_input": 0.02, "cache_write": 0.25, "output": 0.75},
29
+ {"max_input_tokens": 272_000, "input": 10.00, "cached_input": 1.00, "cache_write": 12.50, "cache_write_12h": 25.00, "output": 50.00},
30
+ {"max_input_tokens": None, "input": 20.00, "cached_input": 2.00, "cache_write": 25.00, "cache_write_12h": 50.00, "output": 75.00},
43
31
  ],
44
32
  },
45
33
  "gpt-6.1-sol": {
46
34
  "tiered": True,
47
35
  "tiers": [
48
- {"max_input_tokens": 272_000, "input": 2.00, "cached_input": 0.10, "cache_write": 2.50, "output": 10.00},
49
- {"max_input_tokens": None, "input": 4.00, "cached_input": 0.20, "cache_write": 5.00, "output": 15.00},
36
+ {"max_input_tokens": 272_000, "input": 2.00, "cached_input": 0.10, "cache_write": 2.50, "cache_write_12h": 5.00, "output": 10.00},
37
+ {"max_input_tokens": None, "input": 4.00, "cached_input": 0.20, "cache_write": 5.00, "cache_write_12h": 10.00, "output": 15.00},
50
38
  ],
51
39
  },
52
- "gpt-5.4": {
40
+ "gpt-6.1-terra": {
53
41
  "tiered": True,
54
42
  "tiers": [
55
- {"max_input_tokens": 272_000, "input": 2.50, "cached_input": 0.25, "output": 15.00},
56
- {"max_input_tokens": None, "input": 5.00, "cached_input": 0.50, "output": 22.50},
43
+ {"max_input_tokens": 272_000, "input": 1.00, "cached_input": 0.05, "cache_write": 1.25, "cache_write_12h": 2.50, "output": 5.00},
44
+ {"max_input_tokens": None, "input": 2.00, "cached_input": 0.10, "cache_write": 2.50, "cache_write_12h": 5.00, "output": 7.50},
57
45
  ],
58
46
  },
59
- # GPT-5.6 Terra — tiered like gpt-5.4 (official pricing: 2x input, 1.5x
60
- # output above 272K input; the 5.6 family also prices cache writes).
61
- "gpt-5.6-terra": {
47
+ "gpt-6-luna": {
62
48
  "tiered": True,
63
49
  "tiers": [
64
- {"max_input_tokens": 272_000, "input": 2.00, "cached_input": 0.20, "cache_write": 2.50, "output": 12.00},
65
- {"max_input_tokens": None, "input": 4.00, "cached_input": 0.40, "cache_write": 5.00, "output": 18.00},
50
+ {"max_input_tokens": 272_000, "input": 0.10, "cached_input": 0.01, "cache_write": 0.125, "cache_write_12h": 0.25, "output": 0.50},
51
+ {"max_input_tokens": None, "input": 0.20, "cached_input": 0.02, "cache_write": 0.25, "cache_write_12h": 0.50, "output": 0.75},
66
52
  ],
67
53
  },
68
- "gpt-4.1-nano": {"input": 0.10, "cached_input": 0.025, "output": 0.40},
69
- "gpt-4.1-mini": {"input": 0.40, "cached_input": 0.10, "output": 1.60},
70
- "gpt-4.1": {"input": 2.00, "cached_input": 0.50, "output": 8.00},
71
- "gpt-5": {"input": 1.25, "cached_input": 0.125, "output": 10.00},
72
- "gpt-5-mini": {"input": 0.25, "cached_input": 0.025, "output": 2.00},
73
- "gpt-5-nano": {"input": 0.05, "cached_input": 0.005, "output": 0.40},
74
- "gpt-4o": {"input": 2.50, "cached_input": 1.25, "output": 10.00},
75
- "gpt-4o-mini": {"input": 0.15, "cached_input": 0.075, "output": 0.60},
76
54
  }
77
55
 
78
56
  # Speech-to-text, priced per minute of audio (not per token).
@@ -84,14 +62,14 @@ TRANSCRIBE_PRICING: dict[str, dict[str, float]] = {
84
62
 
85
63
 
86
64
  # ---------------------------------------------------------------------------
87
- # Rate lookup — preserve the legacy gpt-5.4 estimate for unknown model IDs.
65
+ # Rate lookup — unknown model IDs are estimated at the gpt-6.1-sol rates.
88
66
  # Register a model explicitly before using it; this fallback is not a price cap.
89
67
  # ---------------------------------------------------------------------------
90
68
 
91
69
  def get_openai_rates(model: str, input_tokens: int = 0) -> dict[str, float]:
92
70
  entry = OPENAI_PRICING.get(model)
93
71
  if entry is None:
94
- entry = OPENAI_PRICING["gpt-5.4"]
72
+ entry = OPENAI_PRICING["gpt-6.1-sol"]
95
73
  if entry.get("tiered"):
96
74
  for tier in entry["tiers"]:
97
75
  cap = tier.get("max_input_tokens")
api/record_notes.py ADDED
@@ -0,0 +1,233 @@
1
+ """Internal notes on any ERP document or master record.
2
+
3
+ Notes are private to the team: they never print and never change the record
4
+ itself (no `modified` bump, so open forms are not marked stale). They plug into
5
+ the personal mention inbox as a fallback source, so every document and master
6
+ type supports @mentions without registering anything. Record types with their
7
+ own notes (a plugin's explicit mention source, e.g. CRM leads) keep theirs.
8
+ """
9
+ import uuid
10
+ from urllib.parse import quote
11
+
12
+ from fastapi import HTTPException
13
+ from pydantic import Field
14
+
15
+ from api import mentions, services
16
+ from lambda_erp.database import get_db
17
+ from lambda_erp.utils import now
18
+
19
+ TABLE = "Record Note"
20
+ _DISABLED: set[str] = set()
21
+
22
+
23
+ def disable_record_notes(record_type):
24
+ """Opt a record type out, e.g. one whose rows are themselves notes."""
25
+ _DISABLED.add(record_type)
26
+
27
+
28
+ def _target(record_type):
29
+ """(table, kind) for a record type that takes generic notes, else None."""
30
+ if record_type in _DISABLED or record_type in mentions.SOURCES:
31
+ return None
32
+ doctype = services.SLUG_TO_DOCTYPE.get(record_type)
33
+ if doctype:
34
+ return doctype, "document"
35
+ master = services.MASTER_TABLES.get(record_type)
36
+ if master:
37
+ return master[0], "master"
38
+ return None
39
+
40
+
41
+ def record_types():
42
+ return sorted({*services.SLUG_TO_DOCTYPE, *services.MASTER_TABLES} - _DISABLED - mentions.SOURCES.keys())
43
+
44
+
45
+ def _reader(user):
46
+ return (user or {}).get("role") in mentions.REAL_ROLES
47
+
48
+
49
+ def _existing(table, ids):
50
+ db, found = get_db(), {}
51
+ ids = list(ids)
52
+ for start in range(0, len(ids), 250):
53
+ chunk = ids[start:start + 250]
54
+ found.update({row["name"]: row for row in db.sql(
55
+ f'SELECT * FROM "{table}" WHERE name IN (' + ','.join('?' for _ in chunk) + ')', chunk)})
56
+ return found
57
+
58
+
59
+ def _title(record_type, record):
60
+ table, kind = _target(record_type)
61
+ if kind == "master":
62
+ label = record.get(services.MASTER_TABLES[record_type][1])
63
+ return f"{table} {record['name']}" + (f" · {label}" if label and label != record["name"] else "")
64
+ party = record.get("customer_name") or record.get("supplier_name") or record.get("party_name")
65
+ return f"{table} {record['name']}" + (f" · {party}" if party else "")
66
+
67
+
68
+ def view_url(record_type, record_id, note_id=None):
69
+ _, kind = _target(record_type)
70
+ base = "/app" if kind == "document" else "/masters"
71
+ url = f"{base}/{record_type}/{quote(record_id, safe='')}"
72
+ return url + (f"#note-{note_id}" if note_id else "")
73
+
74
+
75
+ def _can_access(record_type, record_id, user):
76
+ target = _target(record_type)
77
+ return bool(target and _reader(user) and get_db().exists(target[0], record_id))
78
+
79
+
80
+ def _create(record_type, record_id, body, user):
81
+ note_id, stamp = uuid.uuid4().hex, now()
82
+ get_db().insert(TABLE, {
83
+ "name": note_id, "record_type": record_type, "record_id": record_id, "body": body,
84
+ "author_id": user.get("user_id") or user["name"], "created_at": stamp, "updated_at": stamp,
85
+ })
86
+ return note_id
87
+
88
+
89
+ def _resolve(record_type, note_ids, user):
90
+ target = _target(record_type)
91
+ if not note_ids or not target or not _reader(user):
92
+ return {}
93
+ rows = get_db().sql(f'SELECT name, record_id, body FROM "{TABLE}" WHERE record_type = ? AND name IN ('
94
+ + ','.join('?' for _ in note_ids) + ')', [record_type, *note_ids])
95
+ records = _existing(target[0], {row["record_id"] for row in rows})
96
+ return {row["name"]: {
97
+ "record_id": row["record_id"], "title": _title(record_type, records[row["record_id"]]),
98
+ "body": row["body"], "view_url": view_url(record_type, row["record_id"], row["name"]),
99
+ } for row in rows if row["record_id"] in records}
100
+
101
+
102
+ def _source(record_type):
103
+ target = _target(record_type)
104
+ if not target:
105
+ return None
106
+ return dict(
107
+ label=target[0],
108
+ can_access=lambda record_id, user: _can_access(record_type, record_id, user),
109
+ create_note=lambda record_id, body, user: _create(record_type, record_id, body, user),
110
+ resolve_note=lambda note_id, user: _resolve(record_type, [note_id], user).get(note_id),
111
+ resolve_notes=lambda note_ids, user: _resolve(record_type, note_ids, user),
112
+ )
113
+
114
+
115
+ class ListNotes(mentions.Input):
116
+ record_type: str = Field(min_length=1, max_length=100)
117
+ record_id: str = Field(min_length=1, max_length=200)
118
+ limit: int = Field(default=50, ge=1, le=200)
119
+ offset: int = Field(default=0, ge=0)
120
+
121
+
122
+ class EditNote(mentions.Input):
123
+ note_id: str = Field(min_length=1, max_length=100)
124
+ body: str = Field(min_length=1, max_length=8000)
125
+
126
+
127
+ class DeleteNote(mentions.Input):
128
+ note_id: str = Field(min_length=1, max_length=100)
129
+
130
+
131
+ def _require_record(record_type, record_id, user):
132
+ if not _target(record_type):
133
+ raise ValueError(f"{record_type!r} does not take internal notes. "
134
+ f"Supported record types: {', '.join(record_types())}.")
135
+ if not _can_access(record_type, record_id, user):
136
+ raise HTTPException(404, "Record is unavailable")
137
+
138
+
139
+ def list_record_notes(args, user):
140
+ data = ListNotes.model_validate(args)
141
+ if not _reader(user):
142
+ raise PermissionError("Sign in with an ERP account to read internal notes.")
143
+ _require_record(data.record_type, data.record_id, user)
144
+ db, me = get_db(), user.get("user_id") or user.get("name")
145
+ where = [data.record_type, data.record_id]
146
+ total = db.sql(f'SELECT COUNT(*) AS n FROM "{TABLE}" WHERE record_type = ? AND record_id = ?', where)[0]["n"]
147
+ rows = db.sql(f'SELECT * FROM "{TABLE}" WHERE record_type = ? AND record_id = ? '
148
+ 'ORDER BY created_at DESC, name DESC LIMIT ? OFFSET ?', [*where, data.limit, data.offset])
149
+ authors = _existing("User", {row["author_id"] for row in rows})
150
+ notes = [{
151
+ "id": row["name"], "body": row["body"], "author_id": row["author_id"],
152
+ "author_name": (authors.get(row["author_id"]) or {}).get("full_name")
153
+ or (authors.get(row["author_id"]) or {}).get("email") or row["author_id"],
154
+ "created_at": row["created_at"], "updated_at": row["updated_at"],
155
+ "edited": row["updated_at"] != row["created_at"],
156
+ "can_edit": row["author_id"] == me,
157
+ "can_delete": row["author_id"] == me or user.get("role") == "admin",
158
+ "view_url": view_url(data.record_type, data.record_id, row["name"]),
159
+ } for row in rows]
160
+ return {"record_type": data.record_type, "record_id": data.record_id, "notes": notes,
161
+ "total": total, "has_more": data.offset + len(notes) < total}
162
+
163
+
164
+ def _load_note(note_id):
165
+ rows = get_db().sql(f'SELECT * FROM "{TABLE}" WHERE name = ?', [note_id])
166
+ if not rows:
167
+ raise HTTPException(404, "Note not found")
168
+ return rows[0]
169
+
170
+
171
+ def update_record_note(args, user):
172
+ data, actor = EditNote.model_validate(args), mentions._actor(user)
173
+ body = data.body.strip()
174
+ if not body:
175
+ raise ValueError("Note body must not be blank.")
176
+ db = get_db()
177
+ with db.atomic():
178
+ note = _load_note(data.note_id)
179
+ _require_record(note["record_type"], note["record_id"], actor)
180
+ if note["author_id"] != actor["user_id"]:
181
+ raise PermissionError("Only the author can edit a note.")
182
+ db.sql(f'UPDATE "{TABLE}" SET body = ?, updated_at = ? WHERE name = ?', [body, now(), note["name"]])
183
+ notified = mentions.sync_note_mentions(note["record_type"], note["record_id"], note["name"], body, actor)
184
+ return {"note_id": note["name"], "notified_count": notified,
185
+ "view_url": view_url(note["record_type"], note["record_id"], note["name"])}
186
+
187
+
188
+ def delete_record_note(args, user):
189
+ data, actor = DeleteNote.model_validate(args), mentions._actor(user)
190
+ db = get_db()
191
+ with db.atomic():
192
+ note = _load_note(data.note_id)
193
+ _require_record(note["record_type"], note["record_id"], actor)
194
+ if note["author_id"] != actor["user_id"] and actor["role"] != "admin":
195
+ raise PermissionError("Only the author or an admin can delete a note.")
196
+ db.sql('DELETE FROM "Mention Notification" WHERE doctype = ? AND note_id = ?',
197
+ [note["record_type"], note["name"]])
198
+ db.sql(f'DELETE FROM "{TABLE}" WHERE name = ?', [note["name"]])
199
+ return {"success": True, "note_id": note["name"]}
200
+
201
+
202
+ def delete_record_notes_for(record_type, record_id):
203
+ """Drop a hard-deleted record's notes and their inbox entries."""
204
+ db = get_db()
205
+ ids = [row["name"] for row in db.sql(
206
+ f'SELECT name FROM "{TABLE}" WHERE record_type = ? AND record_id = ?', [record_type, record_id])]
207
+ for start in range(0, len(ids), 250):
208
+ chunk = ids[start:start + 250]
209
+ db.sql('DELETE FROM "Mention Notification" WHERE doctype = ? AND note_id IN ('
210
+ + ','.join('?' for _ in chunk) + ')', [record_type, *chunk])
211
+ db.sql(f'DELETE FROM "{TABLE}" WHERE record_type = ? AND record_id = ?', [record_type, record_id])
212
+
213
+
214
+ _NOT_PRINTED = ("Internal notes are visible to signed-in colleagues only; they are never printed and "
215
+ "never change the record. Not the printed `remarks` (Notes / Terms) field.")
216
+
217
+
218
+ def initialize_record_notes():
219
+ mentions.register_mention_source_fallback(_source, record_types)
220
+ for name, handler, schema, role, description in (
221
+ ("list_record_notes", list_record_notes, ListNotes, "viewer",
222
+ "List the internal team notes on one document or master record, newest first. record_type is the "
223
+ "document or master type slug (e.g. sales-invoice, customer) and record_id its name. "
224
+ + _NOT_PRINTED),
225
+ ("update_record_note", update_record_note, EditNote, "manager",
226
+ "Replace the text of an internal note you wrote (note_id from list_record_notes). @mentions are "
227
+ "re-read from the new text: added colleagues are notified, removed ones lose the notification."),
228
+ ("delete_record_note", delete_record_note, DeleteNote, "manager",
229
+ "Delete an internal note. Authors can delete their own notes; admins can delete any. Also removes "
230
+ "its mention notifications."),
231
+ ):
232
+ services.register_action(name, handler, description=description,
233
+ parameters=schema.model_json_schema(), minimum_role=role, pass_user=True)
api/routers/masters.py CHANGED
@@ -748,7 +748,10 @@ def delete_master_record(master_type: str, name: str) -> dict:
748
748
  return {"ok": True, "status": "disabled", "reason": f"Referenced by {reference}"}
749
749
  raise HTTPException(status_code=409, detail=f"Cannot delete {doctype} {name}: referenced by {reference}")
750
750
 
751
- db.delete(doctype, name=name)
751
+ from api.record_notes import delete_record_notes_for
752
+ with db.atomic():
753
+ delete_record_notes_for(master_type, name)
754
+ db.delete(doctype, name=name)
752
755
  return {"ok": True, "status": "deleted"}
753
756
 
754
757
 
api/routers/mcp.py CHANGED
@@ -50,7 +50,7 @@ _EXCLUDE = {
50
50
  }
51
51
 
52
52
  _READ_ONLY = {
53
- 'search_mention_users', 'list_my_mentions',
53
+ 'search_mention_users', 'list_my_mentions', 'list_record_notes', 'get_menu_layout',
54
54
  'get_erp_context',
55
55
  'list_documents', 'get_document_fields', 'get_document', 'get_master_fields',
56
56
  'search_masters', 'get_report', 'get_current_time', 'query_dataset',
lambda_erp/database.py CHANGED
@@ -3063,6 +3063,25 @@ def _m041_chat_turn_lease(db: "Database") -> None:
3063
3063
  db.ensure_column("Chat Session", "active_turn_until", "TEXT")
3064
3064
 
3065
3065
 
3066
+ def _m042_record_notes(db: "Database") -> None:
3067
+ """Internal team notes on any document or master (see api.record_notes)."""
3068
+ db.conn.execute(
3069
+ """CREATE TABLE IF NOT EXISTS "Record Note" (
3070
+ name TEXT PRIMARY KEY,
3071
+ record_type TEXT NOT NULL,
3072
+ record_id TEXT NOT NULL,
3073
+ body TEXT NOT NULL,
3074
+ author_id TEXT NOT NULL,
3075
+ created_at TEXT NOT NULL,
3076
+ updated_at TEXT NOT NULL
3077
+ )"""
3078
+ )
3079
+ db.conn.execute(
3080
+ 'CREATE INDEX IF NOT EXISTS record_note_record ON "Record Note" (record_type, record_id, created_at)'
3081
+ )
3082
+ db.conn.commit()
3083
+
3084
+
3066
3085
  Database.MIGRATIONS = [
3067
3086
  (1, "chat_message_session_id", _m001_chat_message_session_id),
3068
3087
  (2, "chat_session_user_id", _m002_chat_session_user_id),
@@ -3105,6 +3124,7 @@ Database.MIGRATIONS = [
3105
3124
  (39, "connection_app_type", _m039_connection_app_type),
3106
3125
  (40, "naming_series", _m040_naming_series),
3107
3126
  (41, "chat_turn_lease", _m041_chat_turn_lease),
3127
+ (42, "record_notes", _m042_record_notes),
3108
3128
  ]
3109
3129
 
3110
3130
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lambda-erp
3
- Version: 1.3.0
3
+ Version: 1.3.2
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: Apache-2.0
@@ -261,15 +261,17 @@ Open `http://localhost:5173`. Vite proxies `/api/*` to the backend.
261
261
 
262
262
  ```
263
263
  OPENAI_API_KEY=sk-...
264
+ LAMBDA_ERP_CHAT_MODEL=gpt-6.1-sol # optional chat override; default shown
264
265
  LAMBDA_ERP_REPORT_MODEL=gpt-6.1-sol # optional specialist override; default shown
266
+ LAMBDA_ERP_TITLE_MODEL=gpt-6-luna # optional chat-title override; default shown
265
267
  LAMBDA_ERP_ADMIN_EMAIL=admin@example.com # optional, seeds the admin at boot
266
268
  LAMBDA_ERP_ADMIN_PASSWORD=... # optional, required with the line above
267
269
  LAMBDA_ERP_ADMIN_NAME=Administrator # optional, display name for the seeded admin
268
270
  ```
269
271
 
270
- Chat and custom-report specification generation share `OPENAI_API_KEY`. Both
271
- the orchestrator and report specialist default to GPT-5.6 Terra; the specialist
272
- can be overridden with `LAMBDA_ERP_REPORT_MODEL`.
272
+ Chat, chat titles and custom-report specification generation share
273
+ `OPENAI_API_KEY`. The chat and the report specialist default to GPT-6.1 Sol,
274
+ titles to GPT-6 Luna; see [docs/llm-pricing.md](docs/llm-pricing.md) for prices.
273
275
 
274
276
  **Seeded admin.** By default the first person to register becomes the admin. Set `LAMBDA_ERP_ADMIN_EMAIL` + `LAMBDA_ERP_ADMIN_PASSWORD` to instead provision that admin automatically at startup — useful when the database is recreated on every deploy, so a redeployed instance can't be claimed by whoever visits first. It's create-if-missing and idempotent (an existing account with that email is promoted to an enabled admin; its password is left untouched), and the password is only ever read from the environment.
275
277
 
@@ -2,22 +2,24 @@ api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
2
  api/attachments.py,sha256=v5WAGUafYqA4tHWkPHHnP5pYxsJx9wDf6Mn1fTyUmYc,20496
3
3
  api/auth.py,sha256=IcZnUBMv_6u4CrEPENUtsV2DdLQ2rgZGzgpbGXX7s4o,44073
4
4
  api/bootstrap.py,sha256=9r6Bz93Puhz06d8C5LsbYk2NUt2Gr6Nz85FJxOWriCc,21308
5
- api/chat.py,sha256=JaifG2AiT5OtBOGLC3nXuZ-hLhwKwpLJkLXnhj0eWKY,245353
5
+ api/chat.py,sha256=4VPRhHwVxT91LwrP6VMFkK-If8ghe8U7EScOufuO0UI,246998
6
6
  api/chat_links.py,sha256=eHuGxuMvWh42-UC3i97GVxV04NemXGMQDUhfoKiLO-A,2344
7
7
  api/demo_limits.py,sha256=b5PSNWgPIRuIVsAcJZyGLCivUv4RorEzJlZXf5LNwh0,14191
8
8
  api/deps.py,sha256=HaFNz0q0NlX49kNMs1xMe0rrO6Slqpt8-TlPOATirT4,140
9
- api/erp_context.py,sha256=FTVou9bGO_9X7xPBqaf2H9QG7qmKJvWPcw4Kduc4qBc,4483
9
+ api/erp_context.py,sha256=c_Jkik6iPtZr__fLU81rHtYCB9EcROhYjJu_f-v8xoo,5092
10
10
  api/errors.py,sha256=kEC6BUU4koPlyGoCDobAA8sHRoMqw50Xtc2105ZFKF4,5376
11
11
  api/list_values.py,sha256=mGe74gJ4fHPa5X2djJNTsRE8txa0hG40GAxMfTumU_w,963
12
- api/main.py,sha256=t4fj7kpXURkpU39O3Tv9SFc30Un7jtB75wmZtraZk1k,11345
12
+ api/main.py,sha256=jBqyJGtswyf7MBBtdbDDKYvlR4oxF2hjbuD96XJmODg,11532
13
13
  api/mcp_oauth.py,sha256=MjmScAvihkvEXdmM3QJP0FLqqXq-pQ1zmWa67LiArDM,21392
14
- api/mentions.py,sha256=3A4hJ8mUD9IVisz21x6XtsHeR0tHqMyIbLPRn3mb9Ps,16712
14
+ api/mentions.py,sha256=4XS8yEjVroIlxdfxCxiu7WIxtaekrG6Z7iqCs-6DqLY,18104
15
+ api/menu_layout.py,sha256=0uqL2Co1SX7_XqVdtjovL70UWOS0qaJfwr_0PeM0CE4,9574
15
16
  api/oauth.py,sha256=w1XO__oQVQse0S3U03DYLN-_jUPOTliZ4Grr1Nhs4Fs,22450
16
17
  api/pdf.py,sha256=HnJzRYVx6D43ULvYCb2ehwqrGVvaqbzT1vsNg1Gxg7M,19228
17
18
  api/pdf_contract.py,sha256=cEUntXz951OaXTvGdhYnL58LbR_efowGGHhNEBP7y5Q,16118
18
19
  api/pdf_exports.py,sha256=bFdfJVInkCH3BV8e9PpBLF9sOX2OkcFnNO0emuVnrps,3199
19
20
  api/pdf_profiles.py,sha256=gqMRM-T6nzQnJ0DTxUf9MBFtpJws07MpNUQHHgI3Y7c,9206
20
- api/providers.py,sha256=ZwuDtukTpHh553wln-AEvRKQ5kE4296rj5_Kgh84O30,6525
21
+ api/providers.py,sha256=3B4P4J6Ox-biA8lGsGWR4AaAkz8SI4TmF68rsv6kRNQ,5468
22
+ api/record_notes.py,sha256=ohaQcWsCSxgRDha9wc8Lk7nOUS0p4uqolv_Or_EuEd4,10320
21
23
  api/remarks_md.py,sha256=8MZ5oO3TNLDbjdMF2PjdFW13LdAffogOxT2nT0eYrVY,3754
22
24
  api/services.py,sha256=F-7O9faPulVFV11RNLquxmPli1dEezUMztsip8h5-E4,61682
23
25
  api/time_filters.py,sha256=nAlyyZD7i3UOMq8kJdLGo2-jKYl84IZ8ad-oYBTjg1c,4882
@@ -33,8 +35,8 @@ api/routers/bank_reconciliation.py,sha256=USv3-WEsgmXvpXALkwVvE9E536hYQ8Wdlr3c1z
33
35
  api/routers/bank_statements.py,sha256=QCUW0Fu3nBnPuM9zky3ne_7FEkKoCEitibheBP0EJ1I,4678
34
36
  api/routers/chat_api.py,sha256=AdnKm5XtrdMnaMhhwYm_Ck8bvh8RnExtP9K0z2o47eo,8050
35
37
  api/routers/documents.py,sha256=Ww3bL-BN0uIYPRg6UO-Gq3YjtC-nX2DUYNKOqkZOn0Q,11736
36
- api/routers/masters.py,sha256=oAPwv_zaByl_R-krloUch5NkA0BT0tmvyPvUf0VUqiI,38697
37
- api/routers/mcp.py,sha256=X91Bigk3RDmshQ49tQeAFqp3HCKO6xZGheGzweHqF1E,11168
38
+ api/routers/masters.py,sha256=C18L7i9r7jiZuDXjC6uJLOph2KqP1iQVqV2Q-wzfO0E,38831
39
+ api/routers/mcp.py,sha256=hiHKEIJH2ec7ex_SOLccRsbSzYComuy-Fh696D7cuSY,11208
38
40
  api/routers/proposals.py,sha256=KtIITJ-GHa3GSMzSDE205nnFNvMwt0KUVAxU1M8-uyU,4520
39
41
  api/routers/reports.py,sha256=6GLlUJQfstS2p_7F5Il_XYwmZIifSHqaVjFdxTQM1OU,29738
40
42
  api/routers/setup.py,sha256=4qVLO0TExVCTGORDI4dNMUyxauvhLRbUS1VydnSubNw,16206
@@ -44,7 +46,7 @@ api/templates/document.html,sha256=6B1fhdrgq2WaEHmpcGR_LkbZ7hNU6wggLt-a9ojO5Pw,9
44
46
  api/templates/proposal.html,sha256=sx2qRsfl0kp8VeqgYrU_4MVs7rokWavfuDH0fU71ppU,5169
45
47
  api/templates/record.html,sha256=aPCWz4uB1ZBEf8KTFHeChMAbWgObEvx3VuhV1aC4x7A,34
46
48
  lambda_erp/__init__.py,sha256=ahOPqWyRzyy70oKnhA95TZnOWOCSaOZIjGSQ-qLojro,1088
47
- lambda_erp/database.py,sha256=v3JcOZrg8hhTLxze2mmNLoSOMHKtLalLrXSut64JMFs,136250
49
+ lambda_erp/database.py,sha256=WGgbWy5palYdohL8LPN4OJcCNdwskj7pKHlUJMuIz8I,136933
48
50
  lambda_erp/exceptions.py,sha256=ml-iuIefPDTNKqsOJCbdipstaAEKK5re9djIG-UFDxw,516
49
51
  lambda_erp/hooks.py,sha256=koA8a_kNGVIknIhDWjqaq_LI0OYS9Tjt3JT08O5XJcc,1369
50
52
  lambda_erp/master_integrity.py,sha256=xGonkRsJZn92CrBcj8oFBnNnzIncIR1C3fuTOM3UrOw,1191
@@ -104,7 +106,7 @@ lambda_erp/stock/delivery_note.py,sha256=Z3s57gVA7oSIpLauSd3DIWu-1y3tmvEE-zsopTv
104
106
  lambda_erp/stock/purchase_receipt.py,sha256=E42oHRO4MAowQniLumbOjYY38vVfHbHchkxCWyM3KDg,11463
105
107
  lambda_erp/stock/stock_entry.py,sha256=Itbt9a1u8b1s1OItKwVYMJDLjbNze2-UI2raIemCuts,12493
106
108
  lambda_erp/stock/stock_ledger.py,sha256=AFybuScWwfaTvsqOSAon3gwsjIza7skXvrvyQowL7b0,13265
107
- lambda_erp-1.3.0.dist-info/METADATA,sha256=ifoUfHeiHp1YhAATK6TabtikjcCPGS-r9QRLEwSglu0,31852
108
- lambda_erp-1.3.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
109
- lambda_erp-1.3.0.dist-info/licenses/LICENSE,sha256=8H08P22jiM0mJ97SgnpYchU0NeaPunGxZc3IqEaLgtc,11394
110
- lambda_erp-1.3.0.dist-info/RECORD,,
109
+ lambda_erp-1.3.2.dist-info/METADATA,sha256=kg58R1l0PQtbb_xN_n9ccGliyrW1YHjGKIDK0pXQOJI,32030
110
+ lambda_erp-1.3.2.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
111
+ lambda_erp-1.3.2.dist-info/licenses/LICENSE,sha256=8H08P22jiM0mJ97SgnpYchU0NeaPunGxZc3IqEaLgtc,11394
112
+ lambda_erp-1.3.2.dist-info/RECORD,,