lambda-erp 0.1.35__tar.gz → 0.1.37__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.35 → lambda_erp-0.1.37}/PKG-INFO +1 -1
  2. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/auth.py +135 -0
  3. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/chat.py +108 -53
  4. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/main.py +2 -1
  5. lambda_erp-0.1.37/api/routers/chat_api.py +136 -0
  6. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/frontend/src/api/client.ts +11 -0
  7. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/database.py +19 -0
  8. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/pyproject.toml +1 -1
  9. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/.gitignore +0 -0
  10. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/LICENSE +0 -0
  11. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/README.md +0 -0
  12. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/__init__.py +0 -0
  13. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/attachments.py +0 -0
  14. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/bootstrap.py +0 -0
  15. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/demo_limits.py +0 -0
  16. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/deps.py +0 -0
  17. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/errors.py +0 -0
  18. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/oauth.py +0 -0
  19. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/pdf.py +0 -0
  20. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/providers.py +0 -0
  21. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/remarks_md.py +0 -0
  22. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/routers/__init__.py +0 -0
  23. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/routers/accounting.py +0 -0
  24. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/routers/admin.py +0 -0
  25. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/routers/analytics.py +0 -0
  26. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/routers/bank_reconciliation.py +0 -0
  27. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/routers/documents.py +0 -0
  28. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/routers/masters.py +0 -0
  29. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/routers/proposals.py +0 -0
  30. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/routers/reports.py +0 -0
  31. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/routers/setup.py +0 -0
  32. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/services.py +0 -0
  33. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/templates/document.html +0 -0
  34. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/templates/proposal.html +0 -0
  35. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/docs/agents/README.md +0 -0
  36. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/frontend/README.md +0 -0
  37. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/__init__.py +0 -0
  38. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/accounting/__init__.py +0 -0
  39. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/accounting/bank_transaction.py +0 -0
  40. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/accounting/budget.py +0 -0
  41. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  42. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/accounting/general_ledger.py +0 -0
  43. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/accounting/journal_entry.py +0 -0
  44. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/accounting/payment_entry.py +0 -0
  45. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/accounting/pos_invoice.py +0 -0
  46. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/accounting/purchase_invoice.py +0 -0
  47. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/accounting/revaluation.py +0 -0
  48. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/accounting/sales_invoice.py +0 -0
  49. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/accounting/subscription.py +0 -0
  50. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/buying/__init__.py +0 -0
  51. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/buying/purchase_order.py +0 -0
  52. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/controllers/__init__.py +0 -0
  53. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/controllers/currency.py +0 -0
  54. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/controllers/defaults.py +0 -0
  55. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/controllers/pricing_rule.py +0 -0
  56. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  57. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/exceptions.py +0 -0
  58. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/hooks.py +0 -0
  59. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/model.py +0 -0
  60. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/selling/__init__.py +0 -0
  61. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/selling/proposal.py +0 -0
  62. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/selling/quotation.py +0 -0
  63. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/selling/sales_order.py +0 -0
  64. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/simulation.py +0 -0
  65. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/stock/__init__.py +0 -0
  66. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/stock/delivery_note.py +0 -0
  67. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/stock/purchase_receipt.py +0 -0
  68. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/stock/stock_entry.py +0 -0
  69. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/stock/stock_ledger.py +0 -0
  70. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/utils.py +0 -0
  71. {lambda_erp-0.1.35 → lambda_erp-0.1.37}/terraform/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lambda-erp
3
- Version: 0.1.35
3
+ Version: 0.1.37
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: MIT
@@ -582,6 +582,10 @@ DEFAULTS = {
582
582
  # When "1", anyone may self-register (as viewer) without an invite. Default
583
583
  # off: after the first user (admin), registration is invite-only.
584
584
  "allow_public_signup": "0",
585
+ # When "1", the programmatic chat API (POST /api/v1/chat, Bearer API keys) is
586
+ # active. Default off — an admin turns it on and issues keys. See
587
+ # docs/chat-api-plan.md.
588
+ "chat_api_enabled": "0",
585
589
  }
586
590
 
587
591
 
@@ -621,3 +625,134 @@ def update_settings(data: dict, user: dict = Depends(require_admin)):
621
625
  if k not in settings:
622
626
  settings[k] = v
623
627
  return settings
628
+
629
+
630
+ # ---------------------------------------------------------------------------
631
+ # Programmatic chat API — Bearer API keys
632
+ # ---------------------------------------------------------------------------
633
+
634
+ API_KEY_PREFIX = "sk_erp_"
635
+ API_KEY_ROLES = ("viewer", "manager", "admin")
636
+
637
+
638
+ def hash_api_key(token: str) -> str:
639
+ """Hash an API token for storage/lookup. Tokens are high-entropy, so a fast
640
+ sha256 is sufficient (unlike user passwords, which use bcrypt)."""
641
+ return hashlib.sha256(token.encode("utf-8")).hexdigest()
642
+
643
+
644
+ def generate_api_key() -> tuple[str, str, str]:
645
+ """Return (token, key_prefix, key_hash) for a fresh API key.
646
+
647
+ The full token is shown to the admin exactly once; only the hash + a short
648
+ display prefix are stored.
649
+ """
650
+ token = API_KEY_PREFIX + secrets.token_hex(24)
651
+ return token, token[: len(API_KEY_PREFIX) + 4], hash_api_key(token)
652
+
653
+
654
+ def get_api_caller(request: Request) -> dict:
655
+ """FastAPI dependency for the programmatic chat API.
656
+
657
+ Gated by the `chat_api_enabled` Settings flag — when off the whole surface
658
+ 404s (never advertised on instances that don't use it). Otherwise validates
659
+ the `Authorization: Bearer <token>` header against a non-revoked Api Key and
660
+ returns a `user_info`-shaped dict the chat agent already consumes (role gates
661
+ it; `name` == the key's session_owner so it can access its own sessions).
662
+ """
663
+ db = get_db()
664
+ if not _setting_enabled(db, "chat_api_enabled"):
665
+ raise HTTPException(status_code=404, detail="Not Found")
666
+
667
+ header = request.headers.get("authorization", "")
668
+ if not header.lower().startswith("bearer "):
669
+ raise HTTPException(status_code=401, detail="Missing or malformed Authorization header")
670
+ token = header[7:].strip()
671
+ if not token:
672
+ raise HTTPException(status_code=401, detail="Empty bearer token")
673
+
674
+ rows = db.sql(
675
+ 'SELECT id, name, role, session_owner FROM "Api Key" WHERE key_hash = ? AND revoked = 0',
676
+ [hash_api_key(token)],
677
+ )
678
+ if not rows:
679
+ raise HTTPException(status_code=401, detail="Invalid API key")
680
+ key = dict(rows[0])
681
+
682
+ db.sql('UPDATE "Api Key" SET last_used_at = ? WHERE id = ?', [now(), key["id"]])
683
+ db.conn.commit()
684
+
685
+ return {
686
+ "name": key["session_owner"],
687
+ "full_name": key.get("name") or "API",
688
+ "role": key["role"],
689
+ "api_key_id": key["id"],
690
+ }
691
+
692
+
693
+ class ApiKeyCreate(BaseModel):
694
+ name: str
695
+ role: str = "manager"
696
+
697
+
698
+ def _serialize_api_key(row: dict) -> dict:
699
+ return {
700
+ "id": row["id"],
701
+ "name": row["name"],
702
+ "role": row["role"],
703
+ "key_prefix": row["key_prefix"],
704
+ "created_by": row.get("created_by"),
705
+ "created_at": row.get("created_at"),
706
+ "last_used_at": row.get("last_used_at"),
707
+ "revoked": bool(row.get("revoked")),
708
+ }
709
+
710
+
711
+ @router.get("/api-keys")
712
+ def list_api_keys(user: dict = Depends(require_admin)):
713
+ """List API keys (metadata only — the token is never returned)."""
714
+ db = get_db()
715
+ rows = db.sql(
716
+ 'SELECT id, name, role, key_prefix, created_by, created_at, last_used_at, revoked '
717
+ 'FROM "Api Key" ORDER BY created_at DESC'
718
+ )
719
+ return [_serialize_api_key(dict(r)) for r in rows]
720
+
721
+
722
+ @router.post("/api-keys")
723
+ def create_api_key(data: ApiKeyCreate, user: dict = Depends(require_admin)):
724
+ """Create an API key. Returns the full token exactly once."""
725
+ name = (data.name or "").strip()
726
+ if not name:
727
+ raise HTTPException(status_code=422, detail="A name is required.")
728
+ role = data.role if data.role in API_KEY_ROLES else "manager"
729
+
730
+ db = get_db()
731
+ key_id = str(uuid.uuid4())
732
+ token, key_prefix, key_hash = generate_api_key()
733
+ db.sql(
734
+ 'INSERT INTO "Api Key" '
735
+ '(id, name, key_hash, key_prefix, role, session_owner, created_by, created_at, last_used_at, revoked) '
736
+ 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0)',
737
+ [key_id, name, key_hash, key_prefix, role, f"apikey:{key_id}", user["name"], now(), None],
738
+ )
739
+ db.conn.commit()
740
+ row = {
741
+ "id": key_id, "name": name, "role": role, "key_prefix": key_prefix,
742
+ "created_by": user["name"], "created_at": now(), "last_used_at": None, "revoked": 0,
743
+ }
744
+ result = _serialize_api_key(row)
745
+ result["token"] = token # shown once
746
+ return result
747
+
748
+
749
+ @router.post("/api-keys/{key_id}/revoke")
750
+ def revoke_api_key(key_id: str, user: dict = Depends(require_admin)):
751
+ """Revoke (soft-disable) an API key without deleting its history."""
752
+ db = get_db()
753
+ rows = db.sql('SELECT id FROM "Api Key" WHERE id = ?', [key_id])
754
+ if not rows:
755
+ raise HTTPException(status_code=404, detail="API key not found")
756
+ db.sql('UPDATE "Api Key" SET revoked = 1 WHERE id = ?', [key_id])
757
+ db.conn.commit()
758
+ return {"id": key_id, "revoked": True}
@@ -2556,6 +2556,101 @@ def transcribe_audio(audio_bytes: bytes, audio_format: str, api_key: str) -> str
2556
2556
  return text
2557
2557
 
2558
2558
 
2559
+ # ---------------------------------------------------------------------------
2560
+ # Shared agent-turn driver (used by both the WebSocket chat and the REST API)
2561
+ # ---------------------------------------------------------------------------
2562
+
2563
+
2564
+ async def run_session_turn(
2565
+ session_id: str,
2566
+ user_content: str,
2567
+ user_info: dict | None,
2568
+ on_event,
2569
+ *,
2570
+ attachment_ids: list | None = None,
2571
+ attachments_user_id: str | None = None,
2572
+ client_ip: str | None = None,
2573
+ replay_history: bool = True,
2574
+ on_assistant_message=None,
2575
+ on_title=None,
2576
+ ) -> str | None:
2577
+ """Run one agent turn for a session; return the assistant's reply text.
2578
+
2579
+ Wraps the transport-agnostic ``run_thinking_loop`` with message building,
2580
+ persistence, and first-reply title generation, so the WebSocket chat and the
2581
+ REST chat API share one code path.
2582
+
2583
+ ``replay_history`` is the single statefulness knob:
2584
+ - True -> LLM context is the session's recent history (normal chat).
2585
+ - False -> context is ONLY the current user message; prior turns are not
2586
+ replayed (stateless REST default). The turn is still persisted by the
2587
+ caller for audit — it just isn't fed back to the model.
2588
+
2589
+ Hooks (all optional, async):
2590
+ - ``on_event(event)`` streamed loop events (WS -> socket; REST -> no-op)
2591
+ - ``on_assistant_message(sid, content)`` fired after the reply is persisted
2592
+ - ``on_title(sid, title)`` fired after a first-reply title is generated
2593
+ """
2594
+ is_first_reply = count_assistant_messages(session_id) == 0
2595
+
2596
+ messages = [{"role": "system", "content": build_system_prompt(user_info)}]
2597
+
2598
+ if replay_history:
2599
+ conversation = build_conversation(session_id, limit=20)
2600
+ else:
2601
+ # Stateless: only the current user message, no prior turns replayed.
2602
+ conversation = [{"role": "user", "content": user_content}]
2603
+
2604
+ # Multimodal attachments: replace the last user message with an image/PDF-
2605
+ # bearing content array so the LLM can see them directly.
2606
+ if attachment_ids and attachments_user_id:
2607
+ from api.attachments import get_attachments_by_ids, build_multimodal_content
2608
+ atts = get_attachments_by_ids(attachment_ids, attachments_user_id)
2609
+ if atts and conversation and conversation[-1].get("role") == "user":
2610
+ parts = []
2611
+ text = conversation[-1].get("content") or ""
2612
+ if text:
2613
+ parts.append({"type": "text", "text": text})
2614
+ for att in atts:
2615
+ parts.append(build_multimodal_content(att))
2616
+ conversation[-1] = {"role": "user", "content": parts}
2617
+
2618
+ messages.extend(conversation)
2619
+
2620
+ await run_thinking_loop(
2621
+ messages, on_event,
2622
+ session_id=session_id,
2623
+ user_info=user_info,
2624
+ client_ip=client_ip,
2625
+ )
2626
+
2627
+ assistant_content = None
2628
+ for msg in reversed(messages):
2629
+ if msg.get("role") == "assistant" and msg.get("content"):
2630
+ assistant_content = msg["content"]
2631
+ save_chat_message(session_id, "assistant", assistant_content)
2632
+ if on_assistant_message:
2633
+ await on_assistant_message(session_id, assistant_content)
2634
+ break
2635
+
2636
+ if is_first_reply and assistant_content:
2637
+ async def _gen_and_notify():
2638
+ await generate_title(
2639
+ session_id,
2640
+ user_content,
2641
+ assistant_content,
2642
+ client_ip=client_ip,
2643
+ user_role=(user_info or {}).get("role"),
2644
+ )
2645
+ if on_title:
2646
+ session = get_session(session_id)
2647
+ if session:
2648
+ await on_title(session_id, session["title"])
2649
+ asyncio.create_task(_gen_and_notify())
2650
+
2651
+ return assistant_content
2652
+
2653
+
2559
2654
  # ---------------------------------------------------------------------------
2560
2655
  # WebSocket endpoint
2561
2656
  # ---------------------------------------------------------------------------
@@ -2666,66 +2761,26 @@ async def chat_websocket(
2666
2761
 
2667
2762
  async def process_session_message(target_session_id: str, user_content: str, attachment_ids: list | None = None):
2668
2763
  try:
2669
- is_first_reply = count_assistant_messages(target_session_id) == 0
2670
-
2671
- messages = [{"role": "system", "content": build_system_prompt(user_info)}]
2672
- conversation = build_conversation(target_session_id, limit=20)
2673
-
2674
- # If the user attached files with this message, replace the last user
2675
- # message in the conversation with a multimodal content array so the
2676
- # LLM can see the images/PDFs directly.
2677
- if attachment_ids and ws_user_id:
2678
- from api.attachments import get_attachments_by_ids, build_multimodal_content
2679
- atts = get_attachments_by_ids(attachment_ids, ws_user_id)
2680
- if atts and conversation and conversation[-1].get("role") == "user":
2681
- parts = []
2682
- text = conversation[-1].get("content") or ""
2683
- if text:
2684
- parts.append({"type": "text", "text": text})
2685
- for att in atts:
2686
- parts.append(build_multimodal_content(att))
2687
- conversation[-1] = {"role": "user", "content": parts}
2688
-
2689
- messages.extend(conversation)
2690
-
2691
2764
  async def on_event(event: dict):
2692
2765
  await send_event(event["type"], session_id=target_session_id, **{
2693
2766
  key: value for key, value in event.items() if key != "type"
2694
2767
  })
2695
2768
 
2696
- await run_thinking_loop(
2697
- messages, on_event,
2698
- session_id=target_session_id,
2699
- user_info=user_info,
2700
- client_ip=client_ip,
2701
- )
2769
+ async def on_assistant_message(sid: str, content: str):
2770
+ await send_message_added(sid, "assistant", content)
2702
2771
 
2703
- assistant_content = None
2704
- for msg in reversed(messages):
2705
- if msg.get("role") == "assistant" and msg.get("content"):
2706
- assistant_content = msg["content"]
2707
- save_chat_message(target_session_id, "assistant", assistant_content)
2708
- await send_message_added(target_session_id, "assistant", assistant_content)
2709
- break
2710
-
2711
- if is_first_reply and assistant_content:
2712
- async def _gen_and_notify():
2713
- await generate_title(
2714
- target_session_id,
2715
- user_content,
2716
- assistant_content,
2717
- client_ip=client_ip,
2718
- user_role=ws_user_role,
2719
- )
2720
- session = get_session(target_session_id)
2721
- if session:
2722
- await send_event(
2723
- "session_title_updated",
2724
- session_id=target_session_id,
2725
- title=session["title"],
2726
- )
2772
+ async def on_title(sid: str, title: str):
2773
+ await send_event("session_title_updated", session_id=sid, title=title)
2727
2774
 
2728
- asyncio.create_task(_gen_and_notify())
2775
+ await run_session_turn(
2776
+ target_session_id, user_content, user_info, on_event,
2777
+ attachment_ids=attachment_ids,
2778
+ attachments_user_id=ws_user_id,
2779
+ client_ip=client_ip,
2780
+ replay_history=True,
2781
+ on_assistant_message=on_assistant_message,
2782
+ on_title=on_title,
2783
+ )
2729
2784
  except asyncio.CancelledError:
2730
2785
  raise
2731
2786
  except Exception as e:
@@ -18,7 +18,7 @@ from api.auth import router as auth_router, COOKIE_NAME, decode_token
18
18
  from api.oauth import router as oauth_router
19
19
  from api.attachments import router as attachments_router
20
20
  from api.chat import chat_websocket, router as chat_router
21
- from api.routers import admin, documents, masters, reports, setup as setup_router, bank_reconciliation, analytics, accounting, proposals
21
+ from api.routers import admin, documents, masters, reports, setup as setup_router, bank_reconciliation, analytics, accounting, proposals, chat_api
22
22
 
23
23
 
24
24
  def load_plugins() -> None:
@@ -100,6 +100,7 @@ app.include_router(bank_reconciliation.router, prefix="/api")
100
100
  app.include_router(accounting.router, prefix="/api")
101
101
  app.include_router(admin.router, prefix="/api")
102
102
  app.include_router(chat_router, prefix="/api")
103
+ app.include_router(chat_api.router, prefix="/api")
103
104
 
104
105
 
105
106
  @app.get("/api/health")
@@ -0,0 +1,136 @@
1
+ """Programmatic chat API (v1).
2
+
3
+ A small, synchronous REST surface over the ERP's chat agent, authenticated by
4
+ Bearer API keys and gated by the `chat_api_enabled` Settings flag (off by
5
+ default). Lets an external orchestrator (Lambda's own lambda-web infra, then the
6
+ iPhone app) hold a conversation with an ERP instance the way a connector script
7
+ talks to Dynamics NAV.
8
+
9
+ Statefulness (see docs/chat-api-plan.md):
10
+ - no `session_id` -> stateless reasoning: the agent answers using only the
11
+ current message; the turn is still persisted to a rolling audit session for
12
+ visibility, but prior turns are NOT replayed. This suits a caller (the
13
+ orchestrator) that owns the real conversation and sends self-contained
14
+ prompts.
15
+ - with `session_id` -> stateful: that session's history is replayed —
16
+ caller-owned, ephemeral working memory for a single multi-step ERP task.
17
+ """
18
+ from fastapi import APIRouter, Depends, HTTPException, Request
19
+ from fastapi.responses import Response
20
+ from pydantic import BaseModel
21
+
22
+ from api.auth import get_api_caller
23
+ from api.chat import (
24
+ create_session,
25
+ delete_session,
26
+ get_session,
27
+ list_sessions,
28
+ run_session_turn,
29
+ save_chat_message,
30
+ )
31
+ from api.pdf import generate_pdf
32
+ from api.services import load_document
33
+
34
+ router = APIRouter(prefix="/v1", tags=["chat-api"])
35
+
36
+
37
+ class ChatApiRequest(BaseModel):
38
+ message: str
39
+ session_id: str | None = None
40
+
41
+
42
+ async def _noop_event(event: dict) -> None:
43
+ """Discard streamed loop events — the REST response is the final reply only."""
44
+ return None
45
+
46
+
47
+ def _client_ip(request: Request) -> str | None:
48
+ xff = request.headers.get("x-forwarded-for", "")
49
+ if xff:
50
+ return xff.split(",")[0].strip()
51
+ return request.client.host if request.client else None
52
+
53
+
54
+ @router.post("/chat")
55
+ async def chat(payload: ChatApiRequest, request: Request, caller: dict = Depends(get_api_caller)):
56
+ """Send one message to the ERP chat agent and return its final reply.
57
+
58
+ Blocks until the agent finishes (it may run several tool calls internally).
59
+ """
60
+ message = (payload.message or "").strip()
61
+ if not message:
62
+ raise HTTPException(status_code=422, detail="message cannot be empty")
63
+
64
+ owner = caller["name"]
65
+
66
+ if payload.session_id:
67
+ session = get_session(payload.session_id)
68
+ if not session or session.get("user_id") != owner:
69
+ raise HTTPException(status_code=404, detail="Session not found")
70
+ target_session_id = payload.session_id
71
+ replay_history = True # opt-in continuity
72
+ else:
73
+ # Rolling audit session: append to the caller's most-recent session, or
74
+ # open one. Stateless reasoning — persisted for visibility, not replayed.
75
+ existing = list_sessions(user_id=owner)
76
+ target_session_id = existing[0]["id"] if existing else create_session(user_id=owner)["id"]
77
+ replay_history = False
78
+
79
+ save_chat_message(target_session_id, "user", message)
80
+
81
+ reply = await run_session_turn(
82
+ target_session_id,
83
+ message,
84
+ caller,
85
+ _noop_event,
86
+ client_ip=_client_ip(request),
87
+ replay_history=replay_history,
88
+ )
89
+
90
+ session = get_session(target_session_id)
91
+ return {
92
+ "reply": reply or "",
93
+ "session_id": target_session_id,
94
+ "title": session["title"] if session else None,
95
+ }
96
+
97
+
98
+ @router.get("/documents/{doctype_slug}/{name}/pdf")
99
+ def document_pdf(doctype_slug: str, name: str, caller: dict = Depends(get_api_caller)):
100
+ """Render a document's PDF for a Bearer-key caller.
101
+
102
+ The chat agent's replies link to `/api/documents/{slug}/{name}/pdf`, but that
103
+ route is cookie-gated (require_role) and so unreachable by an API caller. This
104
+ mirrors it for the programmatic surface so an orchestrator (lambda-web → the
105
+ iPhone app) can fetch the actual bytes. A missing document raises
106
+ ValidationError("… not found") → 404, an unknown doctype ValueError → 422, via
107
+ the global handlers.
108
+ """
109
+ pdf_bytes = generate_pdf(doctype_slug, name)
110
+ return Response(
111
+ content=pdf_bytes,
112
+ media_type="application/pdf",
113
+ headers={"Content-Disposition": f'inline; filename="{name}.pdf"'},
114
+ )
115
+
116
+
117
+ @router.get("/documents/{doctype_slug}/{name}")
118
+ def document_json(doctype_slug: str, name: str, caller: dict = Depends(get_api_caller)):
119
+ """Return a document's structured data for a Bearer-key caller (read-only)."""
120
+ return load_document(doctype_slug, name)
121
+
122
+
123
+ @router.get("/chat/sessions")
124
+ def sessions(caller: dict = Depends(get_api_caller)):
125
+ """List the caller's chat sessions (id, title, timestamps)."""
126
+ return list_sessions(user_id=caller["name"])
127
+
128
+
129
+ @router.delete("/chat/sessions/{session_id}")
130
+ def delete(session_id: str, caller: dict = Depends(get_api_caller)):
131
+ """Delete one of the caller's sessions. The next stateless call opens a fresh one."""
132
+ session = get_session(session_id)
133
+ if not session or session.get("user_id") != caller["name"]:
134
+ raise HTTPException(status_code=404, detail="Session not found")
135
+ delete_session(session_id)
136
+ return {"id": session_id, "deleted": True}
@@ -445,6 +445,17 @@ export const api = {
445
445
  getSettings: () => request<Record<string, string>>("/auth/settings"),
446
446
  updateSettings: (data: Record<string, string>) =>
447
447
  request<Record<string, string>>("/auth/settings", { method: "PUT", body: JSON.stringify(data) }),
448
+ getApiKeys: () =>
449
+ request<Array<{
450
+ id: string; name: string; role: string; key_prefix: string;
451
+ created_by?: string; created_at?: string; last_used_at?: string | null; revoked: boolean;
452
+ }>>("/auth/api-keys"),
453
+ createApiKey: (name: string, role: string) =>
454
+ request<{ id: string; name: string; role: string; key_prefix: string; token: string }>(
455
+ "/auth/api-keys", { method: "POST", body: JSON.stringify({ name, role }) }),
456
+ revokeApiKey: (id: string) =>
457
+ request<{ id: string; revoked: boolean }>(
458
+ `/auth/api-keys/${encodeURIComponent(id)}/revoke`, { method: "POST" }),
448
459
 
449
460
  getDemoSpend: () =>
450
461
  request<{
@@ -1308,6 +1308,25 @@ class Database:
1308
1308
  value TEXT
1309
1309
  )""",
1310
1310
 
1311
+ # Bearer API keys for the programmatic chat API (off by default via
1312
+ # the `chat_api_enabled` Settings flag). Tokens are stored hashed
1313
+ # (sha256); `key_prefix` is a display-only fragment. `role` bounds the
1314
+ # agent's tool access; `session_owner` is the user_id the key's chat
1315
+ # sessions are owned under (isolated from human users). See
1316
+ # docs/chat-api-plan.md.
1317
+ """CREATE TABLE IF NOT EXISTS "Api Key" (
1318
+ id TEXT PRIMARY KEY,
1319
+ name TEXT,
1320
+ key_hash TEXT UNIQUE,
1321
+ key_prefix TEXT,
1322
+ role TEXT DEFAULT 'manager',
1323
+ session_owner TEXT,
1324
+ created_by TEXT,
1325
+ created_at TEXT,
1326
+ last_used_at TEXT,
1327
+ revoked INTEGER DEFAULT 0
1328
+ )""",
1329
+
1311
1330
  # Exchange rates for multi-currency. A lookup carries forward the
1312
1331
  # most recent rate on/before a transaction's date; the rate is then
1313
1332
  # snapshotted onto the document, so editing this table never changes
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lambda-erp"
3
- version = "0.1.35"
3
+ version = "0.1.37"
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