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.
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/PKG-INFO +1 -1
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/auth.py +135 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/chat.py +108 -53
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/main.py +2 -1
- lambda_erp-0.1.37/api/routers/chat_api.py +136 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/frontend/src/api/client.ts +11 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/database.py +19 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/pyproject.toml +1 -1
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/.gitignore +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/LICENSE +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/README.md +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/__init__.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/attachments.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/bootstrap.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/demo_limits.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/deps.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/errors.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/oauth.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/pdf.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/providers.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/remarks_md.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/routers/__init__.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/routers/accounting.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/routers/admin.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/routers/analytics.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/routers/documents.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/routers/masters.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/routers/proposals.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/routers/reports.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/routers/setup.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/services.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/templates/document.html +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/api/templates/proposal.html +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/docs/agents/README.md +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/frontend/README.md +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/__init__.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/hooks.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/model.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/simulation.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/lambda_erp/utils.py +0 -0
- {lambda_erp-0.1.35 → lambda_erp-0.1.37}/terraform/README.md +0 -0
|
@@ -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
|
-
|
|
2697
|
-
|
|
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
|
-
|
|
2704
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|