lambda-erp 0.1.36__tar.gz → 0.1.38__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/PKG-INFO +1 -1
  2. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/api/chat.py +47 -22
  3. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/api/routers/chat_api.py +57 -0
  4. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/pyproject.toml +1 -1
  5. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/.gitignore +0 -0
  6. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/LICENSE +0 -0
  7. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/README.md +0 -0
  8. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/api/__init__.py +0 -0
  9. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/api/attachments.py +0 -0
  10. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/api/auth.py +0 -0
  11. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/api/bootstrap.py +0 -0
  12. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/api/demo_limits.py +0 -0
  13. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/api/deps.py +0 -0
  14. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/api/errors.py +0 -0
  15. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/api/main.py +0 -0
  16. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/api/oauth.py +0 -0
  17. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/api/pdf.py +0 -0
  18. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/api/providers.py +0 -0
  19. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/api/remarks_md.py +0 -0
  20. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/api/routers/__init__.py +0 -0
  21. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/api/routers/accounting.py +0 -0
  22. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/api/routers/admin.py +0 -0
  23. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/api/routers/analytics.py +0 -0
  24. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/api/routers/bank_reconciliation.py +0 -0
  25. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/api/routers/documents.py +0 -0
  26. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/api/routers/masters.py +0 -0
  27. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/api/routers/proposals.py +0 -0
  28. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/api/routers/reports.py +0 -0
  29. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/api/routers/setup.py +0 -0
  30. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/api/services.py +0 -0
  31. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/api/templates/document.html +0 -0
  32. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/api/templates/proposal.html +0 -0
  33. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/docs/agents/README.md +0 -0
  34. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/frontend/README.md +0 -0
  35. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/frontend/src/api/client.ts +0 -0
  36. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/lambda_erp/__init__.py +0 -0
  37. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/lambda_erp/accounting/__init__.py +0 -0
  38. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/lambda_erp/accounting/bank_transaction.py +0 -0
  39. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/lambda_erp/accounting/budget.py +0 -0
  40. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  41. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/lambda_erp/accounting/general_ledger.py +0 -0
  42. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/lambda_erp/accounting/journal_entry.py +0 -0
  43. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/lambda_erp/accounting/payment_entry.py +0 -0
  44. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/lambda_erp/accounting/pos_invoice.py +0 -0
  45. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/lambda_erp/accounting/purchase_invoice.py +0 -0
  46. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/lambda_erp/accounting/revaluation.py +0 -0
  47. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/lambda_erp/accounting/sales_invoice.py +0 -0
  48. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/lambda_erp/accounting/subscription.py +0 -0
  49. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/lambda_erp/buying/__init__.py +0 -0
  50. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/lambda_erp/buying/purchase_order.py +0 -0
  51. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/lambda_erp/controllers/__init__.py +0 -0
  52. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/lambda_erp/controllers/currency.py +0 -0
  53. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/lambda_erp/controllers/defaults.py +0 -0
  54. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/lambda_erp/controllers/pricing_rule.py +0 -0
  55. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  56. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/lambda_erp/database.py +0 -0
  57. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/lambda_erp/exceptions.py +0 -0
  58. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/lambda_erp/hooks.py +0 -0
  59. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/lambda_erp/model.py +0 -0
  60. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/lambda_erp/selling/__init__.py +0 -0
  61. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/lambda_erp/selling/proposal.py +0 -0
  62. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/lambda_erp/selling/quotation.py +0 -0
  63. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/lambda_erp/selling/sales_order.py +0 -0
  64. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/lambda_erp/simulation.py +0 -0
  65. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/lambda_erp/stock/__init__.py +0 -0
  66. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/lambda_erp/stock/delivery_note.py +0 -0
  67. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/lambda_erp/stock/purchase_receipt.py +0 -0
  68. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/lambda_erp/stock/stock_entry.py +0 -0
  69. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/lambda_erp/stock/stock_ledger.py +0 -0
  70. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/lambda_erp/utils.py +0 -0
  71. {lambda_erp-0.1.36 → lambda_erp-0.1.38}/terraform/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lambda-erp
3
- Version: 0.1.36
3
+ Version: 0.1.38
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: MIT
@@ -1623,12 +1623,47 @@ def _prompt_analytics_context() -> str:
1623
1623
  )
1624
1624
 
1625
1625
 
1626
- def build_system_prompt(user_info: dict | None = None):
1626
+ def build_system_prompt(user_info: dict | None = None, channel: str = "web"):
1627
1627
  user_name = user_info.get("full_name", "User") if user_info else "User"
1628
1628
  user_role = user_info.get("role", "viewer") if user_info else "viewer"
1629
1629
  company_context = _prompt_company_context()
1630
1630
  analytics_context = _prompt_analytics_context()
1631
1631
 
1632
+ # Channel-aware link guidance. On the "web" channel the reader is a browser
1633
+ # inside the ERP, so web-relative links are clickable. On the "api" channel the
1634
+ # reply is relayed to an EXTERNAL app (lambda-web / the Lambda app) that is not
1635
+ # logged into the ERP web UI — `/app` and `/masters` links are dead there, so we
1636
+ # tell the agent to name records by their identifier instead. The one link we
1637
+ # DO keep is the canonical `/api/documents/{slug}/{name}/pdf` PDF link: the
1638
+ # calling platform detects it, fetches the bytes over the Bearer document API,
1639
+ # and re-delivers the PDF as a native attachment. See docs/chat-api.md.
1640
+ if channel == "api":
1641
+ markdown_links_section = """## Referring to records
1642
+ Your reply is relayed to an external application (the user's Lambda assistant / app), NOT the ERP web interface. Links into the ERP web UI are NOT clickable there, so refer to every record by its human identifier in plain text — e.g. "Quotation **QTN-2298**", "Sales Invoice **SINV-0012**". Do not paste `/app/...`, `/masters/...`, or `/reports/...` URLs; they do not work outside the ERP."""
1643
+ document_links_section = """## Delivering document PDFs
1644
+ When the user wants a document as a file (an invoice, quotation, delivery note, etc.), include exactly one canonical PDF reference in the form `/api/documents/{doctype-slug}/{name}/pdf` — e.g. `/api/documents/sales-invoice/SINV-0001/pdf`. The platform detects this reference, fetches the PDF, and delivers it to the user as a downloadable attachment. So phrase it as the file being provided ("I've attached the PDF of QTN-2298"), NOT as a link to click. Do not include `/app/...` view links or `/masters/...` links — name the record instead."""
1645
+ else:
1646
+ markdown_links_section = """## Always use markdown links
1647
+ Every URL you mention in chat MUST be written as a markdown link `[label](url)` — never a bare URL on its own. The chat UI only turns `[label](url)` into a proper clickable link. A bare `/reports/analytics?report_id=...` still works (a fallback linkifier catches it), but markdown form is the expected shape.
1648
+
1649
+ Examples:
1650
+ - Correct: `[Open report](/reports/analytics?report_id=RPT-AB12CD34)`
1651
+ - Correct: `[SINV-0012](/app/sales-invoice/SINV-0012)`
1652
+ - Avoid: `/reports/analytics?report_id=RPT-AB12CD34` (bare URL)"""
1653
+ document_links_section = """## Document & Master Links
1654
+ When referencing records, always use clickable markdown links so the user can open them directly.
1655
+
1656
+ **Documents** (quotations, invoices, orders, deliveries, receipts, payments, journal entries, stock entries):
1657
+ - **View/edit link:** `/app/{doctype-slug}/{name}` — e.g. [SINV-0001](/app/sales-invoice/SINV-0001)
1658
+ - **PDF link:** `/api/documents/{doctype-slug}/{name}/pdf` — e.g. [Download PDF](/api/documents/sales-invoice/SINV-0001/pdf)
1659
+ The doctype slug is the lowercase, hyphenated form: sales-invoice, purchase-order, delivery-note, etc.
1660
+
1661
+ **Master records** (customer, supplier, item, warehouse, company):
1662
+ - **View/edit link:** `/masters/{master-type}/{name}` — e.g. [SUPP-001](/masters/supplier/SUPP-001), [CUST-003](/masters/customer/CUST-003), [ITEM-001](/masters/item/ITEM-001)
1663
+ - NEVER use `/app/...` for masters — that path is only for transactional documents.
1664
+
1665
+ Always include the view link after creating, submitting, converting, or updating a record. Include the PDF link when the user asks for a printable version or when sharing an invoice/quotation."""
1666
+
1632
1667
  if user_role == "admin":
1633
1668
  role_desc = "You have **admin** access — full permissions to create, edit, submit, cancel documents, manage master data, run reports, and manage users."
1634
1669
  elif user_role == "manager":
@@ -1654,13 +1689,7 @@ Important constraint on the analytics report tool: the JS transform runs **clien
1654
1689
 
1655
1690
  When you do build a custom report, pass a plain-language `intent` to `create_custom_analytics_report` (a specialist model writes the code for you) and reply with the returned `/reports/analytics?report_id=…` link as a markdown link. The draft appears under **Custom Analytics** in the sidebar so the user can reopen or share it.
1656
1691
 
1657
- ## Always use markdown links
1658
- Every URL you mention in chat MUST be written as a markdown link `[label](url)` — never a bare URL on its own. The chat UI only turns `[label](url)` into a proper clickable link. A bare `/reports/analytics?report_id=...` still works (a fallback linkifier catches it), but markdown form is the expected shape.
1659
-
1660
- Examples:
1661
- - Correct: `[Open report](/reports/analytics?report_id=RPT-AB12CD34)`
1662
- - Correct: `[SINV-0012](/app/sales-invoice/SINV-0012)`
1663
- - Avoid: `/reports/analytics?report_id=RPT-AB12CD34` (bare URL)
1692
+ {markdown_links_section}
1664
1693
 
1665
1694
  ## Current User
1666
1695
  You are speaking with **{user_name}** (role: **{user_role}**).
@@ -1903,19 +1932,7 @@ If a `create_document` / `update_document` call fails with an "item not found" /
1903
1932
  - Be concise but helpful
1904
1933
  - If a tool call fails, explain the error clearly and suggest how to fix it
1905
1934
 
1906
- ## Document & Master Links
1907
- When referencing records, always use clickable markdown links so the user can open them directly.
1908
-
1909
- **Documents** (quotations, invoices, orders, deliveries, receipts, payments, journal entries, stock entries):
1910
- - **View/edit link:** `/app/{{doctype-slug}}/{{name}}` — e.g. [SINV-0001](/app/sales-invoice/SINV-0001)
1911
- - **PDF link:** `/api/documents/{{doctype-slug}}/{{name}}/pdf` — e.g. [Download PDF](/api/documents/sales-invoice/SINV-0001/pdf)
1912
- The doctype slug is the lowercase, hyphenated form: sales-invoice, purchase-order, delivery-note, etc.
1913
-
1914
- **Master records** (customer, supplier, item, warehouse, company):
1915
- - **View/edit link:** `/masters/{{master-type}}/{{name}}` — e.g. [SUPP-001](/masters/supplier/SUPP-001), [CUST-003](/masters/customer/CUST-003), [ITEM-001](/masters/item/ITEM-001)
1916
- - NEVER use `/app/...` for masters — that path is only for transactional documents.
1917
-
1918
- Always include the view link after creating, submitting, converting, or updating a record. Include the PDF link when the user asks for a printable version or when sharing an invoice/quotation.
1935
+ {document_links_section}
1919
1936
 
1920
1937
  ## File Attachments
1921
1938
  Users can attach PDFs and images (receipts, bills, contracts, screenshots) to their messages using the paperclip button in the chat input. When attachments are sent, they appear as multimodal content in the current message and you can directly see their contents.
@@ -2571,6 +2588,7 @@ async def run_session_turn(
2571
2588
  attachments_user_id: str | None = None,
2572
2589
  client_ip: str | None = None,
2573
2590
  replay_history: bool = True,
2591
+ channel: str = "web",
2574
2592
  on_assistant_message=None,
2575
2593
  on_title=None,
2576
2594
  ) -> str | None:
@@ -2586,6 +2604,13 @@ async def run_session_turn(
2586
2604
  replayed (stateless REST default). The turn is still persisted by the
2587
2605
  caller for audit — it just isn't fed back to the model.
2588
2606
 
2607
+ ``channel`` selects the link/formatting guidance in the system prompt:
2608
+ - "web" (default) -> the reader is a browser inside the ERP; clickable
2609
+ web-relative links (`/app/…`, `/masters/…`) are used.
2610
+ - "api" -> the reply is relayed to an external app (the chat API);
2611
+ the agent names records in plain text and only emits the canonical PDF
2612
+ reference the caller re-delivers as an attachment.
2613
+
2589
2614
  Hooks (all optional, async):
2590
2615
  - ``on_event(event)`` streamed loop events (WS -> socket; REST -> no-op)
2591
2616
  - ``on_assistant_message(sid, content)`` fired after the reply is persisted
@@ -2593,7 +2618,7 @@ async def run_session_turn(
2593
2618
  """
2594
2619
  is_first_reply = count_assistant_messages(session_id) == 0
2595
2620
 
2596
- messages = [{"role": "system", "content": build_system_prompt(user_info)}]
2621
+ messages = [{"role": "system", "content": build_system_prompt(user_info, channel=channel)}]
2597
2622
 
2598
2623
  if replay_history:
2599
2624
  conversation = build_conversation(session_id, limit=20)
@@ -15,7 +15,10 @@ Statefulness (see docs/chat-api-plan.md):
15
15
  - with `session_id` -> stateful: that session's history is replayed —
16
16
  caller-owned, ephemeral working memory for a single multi-step ERP task.
17
17
  """
18
+ import re
19
+
18
20
  from fastapi import APIRouter, Depends, HTTPException, Request
21
+ from fastapi.responses import Response
19
22
  from pydantic import BaseModel
20
23
 
21
24
  from api.auth import get_api_caller
@@ -27,6 +30,8 @@ from api.chat import (
27
30
  run_session_turn,
28
31
  save_chat_message,
29
32
  )
33
+ from api.pdf import generate_pdf
34
+ from api.services import load_document
30
35
 
31
36
  router = APIRouter(prefix="/v1", tags=["chat-api"])
32
37
 
@@ -36,6 +41,31 @@ class ChatApiRequest(BaseModel):
36
41
  session_id: str | None = None
37
42
 
38
43
 
44
+ # The agent references document PDFs as `/api/documents/{slug}/{name}/pdf` (a
45
+ # cookie-gated web path). An API caller can't open that, so we surface each one as
46
+ # a structured `documents` entry whose `pdf_url` points at the Bearer-gated v1
47
+ # document endpoint the caller CAN fetch. This is the machine-readable contract the
48
+ # orchestrator uses to attach PDFs, instead of re-parsing the prose itself.
49
+ _PDF_LINK_RE = re.compile(r"/api/documents/([^/()\s]+)/([^/()\s]+)/pdf")
50
+
51
+
52
+ def _extract_documents(reply: str, request: Request) -> list[dict]:
53
+ """Pull referenced document PDFs out of a reply as absolute, fetchable refs."""
54
+ base = str(request.base_url).rstrip("/")
55
+ seen: set[tuple[str, str]] = set()
56
+ documents: list[dict] = []
57
+ for doctype_slug, name in _PDF_LINK_RE.findall(reply or ""):
58
+ if (doctype_slug, name) in seen:
59
+ continue
60
+ seen.add((doctype_slug, name))
61
+ documents.append({
62
+ "doctype": doctype_slug,
63
+ "name": name,
64
+ "pdf_url": f"{base}/api/v1/documents/{doctype_slug}/{name}/pdf",
65
+ })
66
+ return documents
67
+
68
+
39
69
  async def _noop_event(event: dict) -> None:
40
70
  """Discard streamed loop events — the REST response is the final reply only."""
41
71
  return None
@@ -82,6 +112,7 @@ async def chat(payload: ChatApiRequest, request: Request, caller: dict = Depends
82
112
  _noop_event,
83
113
  client_ip=_client_ip(request),
84
114
  replay_history=replay_history,
115
+ channel="api",
85
116
  )
86
117
 
87
118
  session = get_session(target_session_id)
@@ -89,9 +120,35 @@ async def chat(payload: ChatApiRequest, request: Request, caller: dict = Depends
89
120
  "reply": reply or "",
90
121
  "session_id": target_session_id,
91
122
  "title": session["title"] if session else None,
123
+ "documents": _extract_documents(reply or "", request),
92
124
  }
93
125
 
94
126
 
127
+ @router.get("/documents/{doctype_slug}/{name}/pdf")
128
+ def document_pdf(doctype_slug: str, name: str, caller: dict = Depends(get_api_caller)):
129
+ """Render a document's PDF for a Bearer-key caller.
130
+
131
+ The chat agent's replies link to `/api/documents/{slug}/{name}/pdf`, but that
132
+ route is cookie-gated (require_role) and so unreachable by an API caller. This
133
+ mirrors it for the programmatic surface so an orchestrator (lambda-web → the
134
+ iPhone app) can fetch the actual bytes. A missing document raises
135
+ ValidationError("… not found") → 404, an unknown doctype ValueError → 422, via
136
+ the global handlers.
137
+ """
138
+ pdf_bytes = generate_pdf(doctype_slug, name)
139
+ return Response(
140
+ content=pdf_bytes,
141
+ media_type="application/pdf",
142
+ headers={"Content-Disposition": f'inline; filename="{name}.pdf"'},
143
+ )
144
+
145
+
146
+ @router.get("/documents/{doctype_slug}/{name}")
147
+ def document_json(doctype_slug: str, name: str, caller: dict = Depends(get_api_caller)):
148
+ """Return a document's structured data for a Bearer-key caller (read-only)."""
149
+ return load_document(doctype_slug, name)
150
+
151
+
95
152
  @router.get("/chat/sessions")
96
153
  def sessions(caller: dict = Depends(get_api_caller)):
97
154
  """List the caller's chat sessions (id, title, timestamps)."""
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lambda-erp"
3
- version = "0.1.36"
3
+ version = "0.1.38"
4
4
  description = "Core ERP logic - accounting, sales, purchasing, inventory"
5
5
  readme = "README.md"
6
6
  license = "MIT"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes