lambda-erp 0.1.34__tar.gz → 0.1.36__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.34 → lambda_erp-0.1.36}/PKG-INFO +1 -1
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/api/auth.py +179 -3
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/api/chat.py +108 -53
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/api/main.py +2 -1
- lambda_erp-0.1.36/api/routers/chat_api.py +108 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/frontend/src/api/client.ts +16 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/lambda_erp/database.py +19 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/pyproject.toml +1 -1
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/.gitignore +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/LICENSE +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/README.md +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/api/__init__.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/api/attachments.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/api/bootstrap.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/api/demo_limits.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/api/deps.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/api/errors.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/api/oauth.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/api/pdf.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/api/providers.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/api/remarks_md.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/api/routers/__init__.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/api/routers/accounting.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/api/routers/admin.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/api/routers/analytics.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/api/routers/documents.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/api/routers/masters.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/api/routers/proposals.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/api/routers/reports.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/api/routers/setup.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/api/services.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/api/templates/document.html +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/api/templates/proposal.html +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/docs/agents/README.md +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/frontend/README.md +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/lambda_erp/__init__.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/lambda_erp/hooks.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/lambda_erp/model.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/lambda_erp/simulation.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/lambda_erp/utils.py +0 -0
- {lambda_erp-0.1.34 → lambda_erp-0.1.36}/terraform/README.md +0 -0
|
@@ -96,6 +96,13 @@ def verify_password(plain: str, hashed: str) -> bool:
|
|
|
96
96
|
return False
|
|
97
97
|
|
|
98
98
|
|
|
99
|
+
def has_usable_password(hashed: str | None) -> bool:
|
|
100
|
+
"""True if the stored hash is a real bcrypt password. Social-login-only
|
|
101
|
+
users carry a non-matchable sentinel that fails this check — they have no
|
|
102
|
+
password to change (they can set a first one via /auth/set-password)."""
|
|
103
|
+
return bool(hashed) and hashed.startswith(PASSWORD_HASH_PREFIX)
|
|
104
|
+
|
|
105
|
+
|
|
99
106
|
# ---------------------------------------------------------------------------
|
|
100
107
|
# JWT helpers
|
|
101
108
|
# ---------------------------------------------------------------------------
|
|
@@ -178,6 +185,10 @@ class ChangePasswordRequest(BaseModel):
|
|
|
178
185
|
new_password: str
|
|
179
186
|
|
|
180
187
|
|
|
188
|
+
class SetPasswordRequest(BaseModel):
|
|
189
|
+
new_password: str
|
|
190
|
+
|
|
191
|
+
|
|
181
192
|
# ---------------------------------------------------------------------------
|
|
182
193
|
# Dependencies
|
|
183
194
|
# ---------------------------------------------------------------------------
|
|
@@ -321,7 +332,7 @@ def register(data: RegisterRequest, request: Request, response: Response):
|
|
|
321
332
|
token = create_access_token(user_name)
|
|
322
333
|
set_auth_cookie(request, response, token)
|
|
323
334
|
|
|
324
|
-
return {"name": user_name, "email": data.email.lower(), "full_name": data.full_name, "role": role}
|
|
335
|
+
return {"name": user_name, "email": data.email.lower(), "full_name": data.full_name, "role": role, "has_password": True}
|
|
325
336
|
|
|
326
337
|
|
|
327
338
|
@router.post("/login")
|
|
@@ -345,7 +356,8 @@ def login(data: LoginRequest, request: Request, response: Response):
|
|
|
345
356
|
token = create_access_token(user["name"])
|
|
346
357
|
set_auth_cookie(request, response, token)
|
|
347
358
|
|
|
348
|
-
return {"name": user["name"], "email": user["email"], "full_name": user["full_name"], "role": user["role"]
|
|
359
|
+
return {"name": user["name"], "email": user["email"], "full_name": user["full_name"], "role": user["role"],
|
|
360
|
+
"has_password": has_usable_password(user["hashed_password"])}
|
|
349
361
|
|
|
350
362
|
|
|
351
363
|
@router.post("/logout")
|
|
@@ -356,7 +368,11 @@ def logout(response: Response):
|
|
|
356
368
|
|
|
357
369
|
@router.get("/me")
|
|
358
370
|
def me(user: dict = Depends(get_current_user)):
|
|
359
|
-
|
|
371
|
+
db = get_db()
|
|
372
|
+
row = db.get_value("User", user["name"], ["hashed_password"])
|
|
373
|
+
has_password = has_usable_password(row["hashed_password"]) if row else False
|
|
374
|
+
return {"name": user["name"], "email": user["email"], "full_name": user["full_name"],
|
|
375
|
+
"role": user["role"], "has_password": has_password}
|
|
360
376
|
|
|
361
377
|
|
|
362
378
|
@router.post("/change-password")
|
|
@@ -381,6 +397,31 @@ def change_password(data: ChangePasswordRequest, user: dict = Depends(get_curren
|
|
|
381
397
|
return {"ok": True}
|
|
382
398
|
|
|
383
399
|
|
|
400
|
+
@router.post("/set-password")
|
|
401
|
+
def set_password(data: SetPasswordRequest, user: dict = Depends(get_current_user)):
|
|
402
|
+
"""Set a first password for an account that has none — e.g. a user created
|
|
403
|
+
via social login who wants an email+password fallback. Requires an
|
|
404
|
+
authenticated session; refuses if a usable password already exists (that
|
|
405
|
+
path is change-password, which verifies the current one) or for the shared
|
|
406
|
+
demo account."""
|
|
407
|
+
if user["role"] == "public_manager":
|
|
408
|
+
raise HTTPException(status_code=403, detail="Demo accounts cannot set a password")
|
|
409
|
+
|
|
410
|
+
if len(data.new_password) < 6:
|
|
411
|
+
raise HTTPException(status_code=422, detail="Password must be at least 6 characters")
|
|
412
|
+
|
|
413
|
+
db = get_db()
|
|
414
|
+
row = db.get_value("User", user["name"], ["hashed_password"])
|
|
415
|
+
if not row:
|
|
416
|
+
raise HTTPException(status_code=404, detail="User not found")
|
|
417
|
+
if has_usable_password(row["hashed_password"]):
|
|
418
|
+
raise HTTPException(status_code=409, detail="A password is already set; use change password instead")
|
|
419
|
+
|
|
420
|
+
db.set_value("User", user["name"],
|
|
421
|
+
{"hashed_password": hash_password(data.new_password), "modified": now()})
|
|
422
|
+
return {"ok": True}
|
|
423
|
+
|
|
424
|
+
|
|
384
425
|
@router.post("/invite")
|
|
385
426
|
def create_invite(data: InviteRequest, user: dict = Depends(require_admin)):
|
|
386
427
|
db = get_db()
|
|
@@ -541,6 +582,10 @@ DEFAULTS = {
|
|
|
541
582
|
# When "1", anyone may self-register (as viewer) without an invite. Default
|
|
542
583
|
# off: after the first user (admin), registration is invite-only.
|
|
543
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",
|
|
544
589
|
}
|
|
545
590
|
|
|
546
591
|
|
|
@@ -580,3 +625,134 @@ def update_settings(data: dict, user: dict = Depends(require_admin)):
|
|
|
580
625
|
if k not in settings:
|
|
581
626
|
settings[k] = v
|
|
582
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,108 @@
|
|
|
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 pydantic import BaseModel
|
|
20
|
+
|
|
21
|
+
from api.auth import get_api_caller
|
|
22
|
+
from api.chat import (
|
|
23
|
+
create_session,
|
|
24
|
+
delete_session,
|
|
25
|
+
get_session,
|
|
26
|
+
list_sessions,
|
|
27
|
+
run_session_turn,
|
|
28
|
+
save_chat_message,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
router = APIRouter(prefix="/v1", tags=["chat-api"])
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ChatApiRequest(BaseModel):
|
|
35
|
+
message: str
|
|
36
|
+
session_id: str | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def _noop_event(event: dict) -> None:
|
|
40
|
+
"""Discard streamed loop events — the REST response is the final reply only."""
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _client_ip(request: Request) -> str | None:
|
|
45
|
+
xff = request.headers.get("x-forwarded-for", "")
|
|
46
|
+
if xff:
|
|
47
|
+
return xff.split(",")[0].strip()
|
|
48
|
+
return request.client.host if request.client else None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@router.post("/chat")
|
|
52
|
+
async def chat(payload: ChatApiRequest, request: Request, caller: dict = Depends(get_api_caller)):
|
|
53
|
+
"""Send one message to the ERP chat agent and return its final reply.
|
|
54
|
+
|
|
55
|
+
Blocks until the agent finishes (it may run several tool calls internally).
|
|
56
|
+
"""
|
|
57
|
+
message = (payload.message or "").strip()
|
|
58
|
+
if not message:
|
|
59
|
+
raise HTTPException(status_code=422, detail="message cannot be empty")
|
|
60
|
+
|
|
61
|
+
owner = caller["name"]
|
|
62
|
+
|
|
63
|
+
if payload.session_id:
|
|
64
|
+
session = get_session(payload.session_id)
|
|
65
|
+
if not session or session.get("user_id") != owner:
|
|
66
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
67
|
+
target_session_id = payload.session_id
|
|
68
|
+
replay_history = True # opt-in continuity
|
|
69
|
+
else:
|
|
70
|
+
# Rolling audit session: append to the caller's most-recent session, or
|
|
71
|
+
# open one. Stateless reasoning — persisted for visibility, not replayed.
|
|
72
|
+
existing = list_sessions(user_id=owner)
|
|
73
|
+
target_session_id = existing[0]["id"] if existing else create_session(user_id=owner)["id"]
|
|
74
|
+
replay_history = False
|
|
75
|
+
|
|
76
|
+
save_chat_message(target_session_id, "user", message)
|
|
77
|
+
|
|
78
|
+
reply = await run_session_turn(
|
|
79
|
+
target_session_id,
|
|
80
|
+
message,
|
|
81
|
+
caller,
|
|
82
|
+
_noop_event,
|
|
83
|
+
client_ip=_client_ip(request),
|
|
84
|
+
replay_history=replay_history,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
session = get_session(target_session_id)
|
|
88
|
+
return {
|
|
89
|
+
"reply": reply or "",
|
|
90
|
+
"session_id": target_session_id,
|
|
91
|
+
"title": session["title"] if session else None,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@router.get("/chat/sessions")
|
|
96
|
+
def sessions(caller: dict = Depends(get_api_caller)):
|
|
97
|
+
"""List the caller's chat sessions (id, title, timestamps)."""
|
|
98
|
+
return list_sessions(user_id=caller["name"])
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@router.delete("/chat/sessions/{session_id}")
|
|
102
|
+
def delete(session_id: str, caller: dict = Depends(get_api_caller)):
|
|
103
|
+
"""Delete one of the caller's sessions. The next stateless call opens a fresh one."""
|
|
104
|
+
session = get_session(session_id)
|
|
105
|
+
if not session or session.get("user_id") != caller["name"]:
|
|
106
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
107
|
+
delete_session(session_id)
|
|
108
|
+
return {"id": session_id, "deleted": True}
|
|
@@ -424,6 +424,11 @@ export const api = {
|
|
|
424
424
|
method: "POST",
|
|
425
425
|
body: JSON.stringify({ current_password, new_password }),
|
|
426
426
|
}),
|
|
427
|
+
authSetPassword: (new_password: string) =>
|
|
428
|
+
request<any>("/auth/set-password", {
|
|
429
|
+
method: "POST",
|
|
430
|
+
body: JSON.stringify({ new_password }),
|
|
431
|
+
}),
|
|
427
432
|
authInvite: (email: string, role: string) =>
|
|
428
433
|
request<any>("/auth/invite", { method: "POST", body: JSON.stringify({ email, role }) }),
|
|
429
434
|
authListUsers: () => request<any[]>("/auth/users"),
|
|
@@ -440,6 +445,17 @@ export const api = {
|
|
|
440
445
|
getSettings: () => request<Record<string, string>>("/auth/settings"),
|
|
441
446
|
updateSettings: (data: Record<string, string>) =>
|
|
442
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" }),
|
|
443
459
|
|
|
444
460
|
getDemoSpend: () =>
|
|
445
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
|