mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-08 23:33:16 +08:00
feat(persistence): PostgreSQL assistant store, chat persist fixes, gateway scripts
- Add SQLAlchemy Core repos, pg adapter/compat, assistant_store factory, Alembic bootstrap and migration/cutover scripts. - Harden chat_message writes (NUL scrub for PG), turn_uuid on attempt failure, WS turn_runner fallbacks and gateway executed_turn_uuid init. - start_gateway: log paths, PS7 stderr handling via cmd, background stdout/stderr redirect; runtime assistant_runtime_log_dir export. - Ops: clear_all_chat_sessions with PG-only --postgresql and env-gated wipe; clear_postgres_chat_sessions.ps1. - Tests: SA repos, pg compat, persist fallback, smoke env isolation; CI and docs touch-ups. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2b32d11f43
commit
d14e9d3596
103 changed files with 7574 additions and 1641 deletions
57
svc/config/database.py
Normal file
57
svc/config/database.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"""Assistant database backend selection (SQLite default, PostgreSQL opt-in via env)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def assistant_db_backend() -> str:
|
||||
"""Return ``sqlite`` (default) or ``postgresql``."""
|
||||
raw = (
|
||||
os.getenv("AIA_ASSISTANT_DB_BACKEND")
|
||||
or os.getenv("OPS_ASSISTANT_DB_BACKEND")
|
||||
or "sqlite"
|
||||
).strip().lower()
|
||||
if raw in ("sqlite", ""):
|
||||
return "sqlite"
|
||||
if raw in ("pg", "postgres", "postgresql"):
|
||||
return "postgresql"
|
||||
raise ValueError(
|
||||
f"Invalid assistant DB backend {raw!r}. "
|
||||
"Use sqlite (default) or postgresql (aliases: pg, postgres)."
|
||||
)
|
||||
|
||||
|
||||
def assistant_sqlalchemy_url() -> str:
|
||||
"""SQLAlchemy URL for the assistant store (sqlite or postgresql+psycopg)."""
|
||||
if assistant_db_backend() == "postgresql":
|
||||
raw = assistant_postgres_dsn()
|
||||
if raw.startswith("postgresql+") or raw.startswith("postgres+"):
|
||||
return raw
|
||||
if raw.startswith("postgresql://") or raw.startswith("postgres://"):
|
||||
return "postgresql+psycopg://" + raw.split("://", 1)[1]
|
||||
return raw
|
||||
from svc.config.paths import db_path
|
||||
|
||||
p = db_path().replace("\\", "/")
|
||||
return f"sqlite+pysqlite:///{p}"
|
||||
|
||||
|
||||
def assistant_postgres_dsn() -> str:
|
||||
"""PostgreSQL connection URI for the assistant store (psycopg/libpq format)."""
|
||||
url = (
|
||||
os.getenv("AIA_ASSISTANT_DATABASE_URL")
|
||||
or os.getenv("OPS_ASSISTANT_DATABASE_URL")
|
||||
or os.getenv("AIA_ASSISTANT_PG_DSN")
|
||||
or os.getenv("OPS_ASSISTANT_PG_DSN")
|
||||
or ""
|
||||
).strip()
|
||||
if not url:
|
||||
raise ValueError(
|
||||
"PostgreSQL backend requires AIA_ASSISTANT_DATABASE_URL (or OPS_ASSISTANT_DATABASE_URL) "
|
||||
"to a libpq connection string, e.g. postgresql://user:pass@127.0.0.1:5432/oclaw"
|
||||
)
|
||||
return url
|
||||
|
||||
|
||||
__all__ = ["assistant_db_backend", "assistant_postgres_dsn", "assistant_sqlalchemy_url"]
|
||||
|
|
@ -13,6 +13,7 @@ from datetime import datetime, timedelta, timezone
|
|||
from typing import Any
|
||||
|
||||
from svc.llm.tool_schema import MIN_OPENAI_FUNCTION_PARAMETERS, complete_openai_tools_wire_parameters
|
||||
from svc.persistence.assistant_store import get_assistant_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -429,7 +430,7 @@ def prepare_openai_tools_for_llm_api(
|
|||
from svc.config.paths import db_path
|
||||
from svc.persistence.sqlite_store import SqliteStore
|
||||
|
||||
store = SqliteStore(db_path())
|
||||
store = get_assistant_store()
|
||||
# Role-scoped policies if configured; otherwise fall back to global.
|
||||
policies = load_tool_policies_dict_for_role(store, role=str(role or "").strip().lower() or None)
|
||||
admin = load_merged_admin_config(store)
|
||||
|
|
|
|||
|
|
@ -5,11 +5,18 @@ import logging
|
|||
import os
|
||||
import re
|
||||
import uuid
|
||||
from typing import Any, Optional
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Optional
|
||||
|
||||
from svc.llm.tool_schema import complete_openai_tools_wire_parameters
|
||||
from svc.llm.transports.base import ChatModel, LLMResponse, LLMToolCall, normalize_image_b64_payload, coerce_thought_signature_for_storage
|
||||
from svc.llm.transports.base import (
|
||||
ChatModel,
|
||||
LLMResponse,
|
||||
LLMToolCall,
|
||||
coerce_thought_signature_for_storage,
|
||||
normalize_image_b64_payload,
|
||||
)
|
||||
from svc.persistence.assistant_store import get_assistant_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -551,12 +558,10 @@ class OpenAIChatModel(ChatModel):
|
|||
kwargs["extra_body"] = extra_body
|
||||
if use_tools:
|
||||
try:
|
||||
from svc.config.paths import db_path
|
||||
from svc.persistence.sqlite_store import SqliteStore
|
||||
from runtime.tools.exposure_plan import build_llm_tools_plan
|
||||
|
||||
plan = build_llm_tools_plan(
|
||||
store=SqliteStore(db_path()),
|
||||
store=get_assistant_store(),
|
||||
role="",
|
||||
base_url=self.base_url,
|
||||
max_json_bytes=_default_max_openai_tools_json_bytes(self.base_url),
|
||||
|
|
|
|||
57
svc/persistence/assistant_store.py
Normal file
57
svc/persistence/assistant_store.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"""Single entry point for the assistant persistence layer (SQLite or PostgreSQL)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from svc.config.database import assistant_db_backend, assistant_postgres_dsn
|
||||
from svc.persistence.assistant_store_protocol import AssistantStoreProtocol
|
||||
|
||||
_singleton: AssistantStoreProtocol | None = None
|
||||
_singleton_key: str | None = None
|
||||
|
||||
|
||||
def reset_assistant_store_singleton() -> None:
|
||||
"""Drop the cached :func:`get_assistant_store` instance (tests / engine URL changes)."""
|
||||
global _singleton, _singleton_key
|
||||
_singleton = None
|
||||
_singleton_key = None
|
||||
|
||||
|
||||
def get_assistant_store() -> AssistantStoreProtocol:
|
||||
"""Return the process-wide assistant store implementation.
|
||||
|
||||
- Default: SQLite at :func:`svc.config.paths.db_path`.
|
||||
- ``AIA_ASSISTANT_DB_BACKEND=postgresql`` + DSN: same :class:`~svc.persistence.sqlite_store.SqliteStore`
|
||||
API over PostgreSQL (schema via Alembic / ``postgresql_bootstrap.sql``).
|
||||
|
||||
The store is **cached per process** for a stable (backend, connection key) so ``SqliteStore.__init__``
|
||||
does not re-run PostgreSQL bootstrap / orphan pruning on every HTTP or WS call (which could race
|
||||
with in-flight writes and make messages disappear after tool rounds).
|
||||
|
||||
Tests should keep constructing ``SqliteStore(path)`` with an explicit file path; production code
|
||||
should prefer this factory for ``db_path()``-backed instances.
|
||||
|
||||
Return type is :class:`~svc.persistence.assistant_store_protocol.AssistantStoreProtocol`; the
|
||||
concrete class is :class:`~svc.persistence.sqlite_store.SqliteStore` for both backends.
|
||||
"""
|
||||
global _singleton, _singleton_key
|
||||
from svc.config.paths import db_path
|
||||
from svc.persistence.sqlite_store import SqliteStore
|
||||
|
||||
if assistant_db_backend() == "postgresql":
|
||||
key = f"postgresql::{assistant_postgres_dsn()}"
|
||||
else:
|
||||
key = f"sqlite::{Path(db_path()).resolve()}"
|
||||
|
||||
if _singleton is not None and _singleton_key == key:
|
||||
return _singleton
|
||||
if assistant_db_backend() == "postgresql":
|
||||
_singleton = SqliteStore(None, postgres_url=assistant_postgres_dsn())
|
||||
else:
|
||||
_singleton = SqliteStore(db_path())
|
||||
_singleton_key = key
|
||||
return _singleton
|
||||
|
||||
|
||||
__all__ = ["get_assistant_store", "reset_assistant_store_singleton"]
|
||||
192
svc/persistence/assistant_store_protocol.py
Normal file
192
svc/persistence/assistant_store_protocol.py
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
"""Typing protocol for assistant persistence (implemented by SqliteStore).
|
||||
|
||||
When adding or renaming public methods on :class:`~svc.persistence.sqlite_store.SqliteStore`,
|
||||
update this protocol (e.g. re-run a small ``inspect.signature`` generator over ``SqliteStore``)
|
||||
so static checkers stay aligned with :func:`~svc.persistence.assistant_store.get_assistant_store`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional, Protocol
|
||||
|
||||
from svc.persistence.sqlite_store import (
|
||||
ChatMessage,
|
||||
ChatSession,
|
||||
OclawRun,
|
||||
OclawTask,
|
||||
SessionMessagesMeta,
|
||||
SessionsListMeta,
|
||||
)
|
||||
|
||||
|
||||
class AssistantStoreProtocol(Protocol):
|
||||
"""Structural contract for :func:`~svc.persistence.assistant_store.get_assistant_store`."""
|
||||
|
||||
_use_pg: bool
|
||||
db_path: str
|
||||
def add_admin_audit_log(self, *, actor_tenant_id: 'str', actor_user_id: 'str', action: 'str', target_type: 'str', target_id: 'str', status: 'str', detail: 'dict[str, Any] | None' = None) -> 'None': ...
|
||||
def add_agent_audit_log(self, *, session_id: 'str', specialist: 'str', task_kind: 'str', action: 'str', payload: 'dict[str, Any]', status: 'str', reason: 'str', duration_ms: 'int' = 0) -> 'None': ...
|
||||
def add_agent_eval_log(self, *, session_id: 'str', specialist: 'str', task_kind: 'str', success: 'bool', latency_ms: 'int', cost_hint: 'float' = 0.0, notes: 'str' = '') -> 'None': ...
|
||||
def add_mcp_installation_log(self, *, server_id: 'str', status: 'str', error_code: 'str' = '', detail: 'dict[str, Any] | None' = None, install_command: 'str' = '') -> 'None': ...
|
||||
def add_memory_hit_log(self, *, tenant_id: 'str', user_id: 'str', session_id: 'str | None', memory_id: 'str | None', query_text: 'str', score: 'float', source: 'str', timestamp: 'str | None' = None) -> 'None': ...
|
||||
def add_message(self, session_id: 'str', role: 'str', content: 'str', tool_calls: 'Any | None' = None, attachments: 'Any | None' = None, turn_uuid: 'str | None' = None, event_type: 'str | None' = None, event_payload: 'Any | None' = None, timestamp: 'str | None' = None) -> 'ChatMessage': ...
|
||||
def add_tool_log(self, session_id: 'str', tool_name: 'str', args: 'dict[str, Any]', result: 'Any', specialist: 'str | None' = None, timestamp: 'str | None' = None, duration_ms: 'int | None' = None) -> 'None': ...
|
||||
def add_trace_event(self, *, session_id: 'str', trace_id: 'str', span_id: 'str', parent_span_id: 'str | None', event_type: 'str', payload: 'dict[str, Any]') -> 'None': ...
|
||||
def add_trace_events_batch(self, events: 'list[dict[str, Any]]') -> 'None': ...
|
||||
def attachment_acl_allows_tenant(self, *, tenant_id: 'str', attachment_id: 'str') -> 'bool': ...
|
||||
def attachment_acl_allows_user(self, *, tenant_id: 'str', user_id: 'str', attachment_id: 'str') -> 'bool': ...
|
||||
def attachment_referenced_by_user(self, *, tenant_id: 'str', user_id: 'str', attachment_id: 'str', scan_limit: 'int' = 2000) -> 'bool': ...
|
||||
def attachment_referenced_in_tenant(self, *, tenant_id: 'str', attachment_id: 'str', scan_limit: 'int' = 4000) -> 'bool': ...
|
||||
def backfill_attachment_acl_from_messages(self, *, tenant_id: 'str', limit_messages: 'int' = 50000) -> 'dict[str, Any]': ...
|
||||
def backfill_orphan_chat_sessions_for_user(self, *, tenant_id: 'str', user_id: 'str') -> 'int': ...
|
||||
def backfill_ui_session_owner_from_channel_v2(self) -> 'int': ...
|
||||
def backfill_user_channel_account_names(self, *, channel: 'str' = 'wecom') -> 'int': ...
|
||||
def clear_llm_profile_secret(self, profile_id: 'str') -> 'None': ...
|
||||
def clear_low_confidence_memory(self, *, max_confidence: 'float') -> 'int': ...
|
||||
def consume_bind_code(self, *, code: 'str', channel: 'str', external_user_id: 'str', display_name: 'str | None' = None) -> 'dict[str, Any] | None': ...
|
||||
def count_admin_audit_logs(self, *, tenant_id: 'str | None' = None, action: 'str | None' = None, actor_user_id: 'str | None' = None, status: 'str | None' = None) -> 'int': ...
|
||||
def count_messages(self, session_id: 'str') -> 'int': ...
|
||||
def count_sessions(self) -> 'int': ...
|
||||
def create_auth_session(self, *, session_token_hash: 'str', tenant_id: 'str', user_id: 'str', role: 'str', expires_at: 'str') -> 'None': ...
|
||||
def create_bind_code(self, *, tenant_id: 'str', role: 'str', code: 'str') -> 'dict[str, Any]': ...
|
||||
def create_llm_profile(self, name: 'str', mode: 'str' = 'openai', model: 'str | None' = None, base_url: 'str | None' = None, *, owner_user_id: 'str | None' = None) -> 'str': ...
|
||||
def create_session(self, title: 'str') -> 'ChatSession': ...
|
||||
def create_session_for_user(self, *, title: 'str', tenant_id: 'str', user_id: 'str') -> 'ChatSession': ...
|
||||
def create_tenant(self, name: 'str') -> 'dict[str, Any]': ...
|
||||
def create_user(self, *, tenant_id: 'str', display_name: 'str', role: 'str') -> 'dict[str, Any]': ...
|
||||
def create_user_account(self, *, tenant_id: 'str', username: 'str', display_name: 'str', role: 'str', password_hash: 'str', is_active: 'bool' = True) -> 'dict[str, Any]': ...
|
||||
def delete_llm_profile(self, profile_id: 'str') -> 'None': ...
|
||||
def delete_mcp_server(self, *, server_id: 'str') -> 'dict[str, int]': ...
|
||||
def delete_memory_item(self, *, memory_id: 'str') -> 'int': ...
|
||||
def delete_message(self, *, session_id: 'str', message_id: 'int') -> 'bool': ...
|
||||
def delete_session(self, session_id: 'str') -> 'None': ...
|
||||
def delete_session_for_user(self, *, session_id: 'str', tenant_id: 'str', user_id: 'str') -> 'bool': ...
|
||||
def delete_session_in_tenant(self, *, session_id: 'str', tenant_id: 'str') -> 'bool': ...
|
||||
def delete_setting(self, key: 'str') -> 'None': ...
|
||||
def delete_tenant(self, *, tenant_id: 'str') -> 'int': ...
|
||||
def delete_user_account(self, *, tenant_id: 'str', user_id: 'str') -> 'int': ...
|
||||
def delete_user_channel_account(self, *, tenant_id: 'str', user_id: 'str', channel: 'str', account_id: 'str') -> 'int': ...
|
||||
def delete_user_permission(self, *, tenant_id: 'str', user_id: 'str', permission: 'str') -> 'int': ...
|
||||
def ensure_default_session(self) -> 'ChatSession': ...
|
||||
def ensure_memory_tables(self) -> 'None': ...
|
||||
def ensure_personal_llm_clone_from_global(self, user_id: 'str', username: 'str | None') -> 'None': ...
|
||||
def ensure_ui_session_owner(self, *, session_id: 'str', tenant_id: 'str', user_id: 'str') -> 'None': ...
|
||||
def find_user_by_channel_account(self, *, channel: 'str', account_id: 'str') -> 'dict[str, Any] | None': ...
|
||||
def fork_session(self, source_session_id: 'str', up_to_message_id: 'int', title: 'str') -> 'ChatSession': ...
|
||||
def get_auth_session(self, *, session_token_hash: 'str') -> 'dict[str, Any] | None': ...
|
||||
def get_knowledge_chunks(self, *, chunk_ids: 'list[str]') -> 'list[dict[str, Any]]': ...
|
||||
def get_last_message_id(self, session_id: 'str') -> 'int | None': ...
|
||||
def get_llm_profile(self, profile_id: 'str') -> 'Optional[dict[str, Any]]': ...
|
||||
def get_llm_profile_secret(self, profile_id: 'str') -> 'Optional[str]': ...
|
||||
def get_messages(self, session_id: 'str', limit: 'int' = 200) -> 'list[ChatMessage]': ...
|
||||
def get_messages_after_id(self, *, session_id: 'str', after_id: 'int', limit: 'int' = 200) -> 'list[ChatMessage]': ...
|
||||
def get_or_create_channel_session(self, *, tenant_id: 'str', channel: 'str', external_chat_id: 'str', external_user_id: 'str', session_title: 'str') -> 'str': ...
|
||||
def get_or_create_channel_session_v2(self, *, tenant_id: 'str', channel: 'str', account_id: 'str', external_chat_id: 'str', external_user_id: 'str', session_title: 'str') -> 'str': ...
|
||||
def get_secret(self, key: 'str') -> 'Optional[str]': ...
|
||||
def get_session(self, session_id: 'str') -> 'Optional[ChatSession]': ...
|
||||
def get_session_for_user(self, *, session_id: 'str', tenant_id: 'str', user_id: 'str') -> 'Optional[ChatSession]': ...
|
||||
def get_session_in_tenant(self, *, session_id: 'str', tenant_id: 'str') -> 'Optional[ChatSession]': ...
|
||||
def get_session_messages_meta(self, session_id: 'str') -> 'SessionMessagesMeta': ...
|
||||
def get_sessions_list_meta(self) -> 'SessionsListMeta': ...
|
||||
def get_sessions_list_meta_for_tenant(self, *, tenant_id: 'str') -> 'SessionsListMeta': ...
|
||||
def get_sessions_list_meta_for_user(self, *, tenant_id: 'str', user_id: 'str') -> 'SessionsListMeta': ...
|
||||
def get_setting(self, key: 'str') -> 'Optional[str]': ...
|
||||
def get_tool_logs(self, session_id: 'str', limit: 'int' = 200) -> 'list[dict[str, Any]]': ...
|
||||
def get_turn_time_window(self, *, session_id: 'str', trace_id: 'str') -> 'tuple[str | None, str | None]': ...
|
||||
def get_ui_session_owner(self, *, session_id: 'str') -> 'dict[str, Any] | None': ...
|
||||
def get_user_by_id(self, *, tenant_id: 'str', user_id: 'str') -> 'dict[str, Any] | None': ...
|
||||
def get_user_by_username(self, *, tenant_id: 'str', username: 'str') -> 'dict[str, Any] | None': ...
|
||||
def get_user_by_username_global(self, *, username: 'str') -> 'dict[str, Any] | None': ...
|
||||
def get_user_workspace_path_allowlist(self, *, tenant_id: 'str', user_id: 'str') -> 'dict[str, Any] | None': ...
|
||||
def grant_llm_profile_to_tenant(self, *, tenant_id: 'str', profile_id: 'str', created_by_user_id: 'str | None' = None) -> 'str': ...
|
||||
def grant_llm_profile_to_user(self, *, tenant_id: 'str', profile_id: 'str', user_id: 'str', created_by_user_id: 'str | None' = None) -> 'str': ...
|
||||
def legacy_secret_stats(self) -> 'dict[str, Any]': ...
|
||||
def link_attachment_acl(self, *, tenant_id: 'str', user_id: 'str', session_id: 'str', attachment_id: 'str', source: 'str') -> 'None': ...
|
||||
def list_admin_audit_logs(self, *, tenant_id: 'str | None' = None, action: 'str | None' = None, actor_user_id: 'str | None' = None, status: 'str | None' = None, limit: 'int' = 200, offset: 'int' = 0) -> 'list[dict[str, Any]]': ...
|
||||
def list_admin_sessions(self, *, tenant_id: 'str', user_id: 'str | None' = None, q: 'str | None' = None, active_only: 'bool' = False, active_window_minutes: 'int' = 30, limit: 'int' = 100, offset: 'int' = 0) -> 'tuple[int, list[dict[str, Any]]]': ...
|
||||
def list_admin_user_stats(self, *, tenant_id: 'str', q: 'str | None' = None, active_window_minutes: 'int' = 30, limit: 'int' = 100, offset: 'int' = 0) -> 'tuple[int, list[dict[str, Any]], dict[str, Any]]': ...
|
||||
def list_agent_audit_logs(self, *, limit: 'int' = 200, session_id: 'str | None' = None) -> 'list[dict[str, Any]]': ...
|
||||
def list_agent_eval_logs(self, *, limit: 'int' = 200) -> 'list[dict[str, Any]]': ...
|
||||
def list_bind_codes(self, *, tenant_id: 'str | None' = None, limit: 'int' = 200) -> 'list[dict[str, Any]]': ...
|
||||
def list_channel_identities(self, *, tenant_id: 'str | None' = None, channel: 'str | None' = None, limit: 'int' = 300) -> 'list[dict[str, Any]]': ...
|
||||
def list_channel_identities_v2(self, *, tenant_id: 'str | None' = None, channel: 'str | None' = None, account_id: 'str | None' = None, user_id: 'str | None' = None, limit: 'int' = 300) -> 'list[dict[str, Any]]': ...
|
||||
def list_knowledge_embeddings(self, *, model: 'str', limit: 'int' = 5000) -> 'list[dict[str, Any]]': ...
|
||||
def list_llm_profile_grants_for_profile(self, tenant_id: 'str', profile_id: 'str') -> 'list[dict[str, Any]]': ...
|
||||
def list_llm_profiles(self, *, visible_only: 'bool' = False, viewer_user_id: 'str | None' = None, viewer_username: 'str | None' = None, viewer_tenant_id: 'str | None' = None) -> 'list[dict[str, Any]]': ...
|
||||
def list_mcp_install_failure_summary(self, *, limit: 'int' = 20) -> 'list[dict[str, Any]]': ...
|
||||
def list_mcp_installation_logs(self, *, server_id: 'str | None' = None, limit: 'int' = 100) -> 'list[dict[str, Any]]': ...
|
||||
def list_mcp_server_health(self) -> 'list[dict[str, Any]]': ...
|
||||
def list_mcp_server_tools(self, *, server_id: 'str') -> 'list[dict[str, Any]]': ...
|
||||
def list_mcp_servers(self, *, enabled_only: 'bool' = False) -> 'list[dict[str, Any]]': ...
|
||||
def list_mcp_tool_aggregate_usage(self) -> 'dict[str, dict[str, Any]]': ...
|
||||
def list_mcp_tool_call_logs(self, *, server_id: 'str | None' = None, limit: 'int' = 200) -> 'list[dict[str, Any]]': ...
|
||||
def list_mcp_tool_usage_summary(self, *, limit: 'int' = 200) -> 'list[dict[str, Any]]': ...
|
||||
def list_memory_hit_logs(self, *, tenant_id: 'str | None' = None, user_id: 'str | None' = None, limit: 'int' = 100) -> 'list[dict[str, Any]]': ...
|
||||
def list_memory_items(self, *, tenant_id: 'str | None' = None, user_id: 'str | None' = None, limit: 'int' = 100, offset: 'int' = 0) -> 'list[dict[str, Any]]': ...
|
||||
def list_messages_in_time_window(self, *, session_id: 'str', start_ts: 'str | None', end_ts: 'str | None', limit: 'int' = 500) -> 'list[dict[str, Any]]': ...
|
||||
def list_session_tool_health(self, *, session_id: 'str | None' = None, limit: 'int' = 80) -> 'list[dict[str, Any]]': ...
|
||||
def list_sessions(self, limit: 'int | None' = None, offset: 'int' = 0) -> 'list[ChatSession]': ...
|
||||
def list_sessions_for_tenant(self, *, tenant_id: 'str', limit: 'int | None' = None, offset: 'int' = 0) -> 'list[ChatSession]': ...
|
||||
def list_sessions_for_user(self, *, tenant_id: 'str', user_id: 'str', limit: 'int | None' = None, offset: 'int' = 0) -> 'list[ChatSession]': ...
|
||||
def list_tenants(self, *, limit: 'int' = 200) -> 'list[dict[str, Any]]': ...
|
||||
def list_tool_plugins(self) -> 'list[dict[str, Any]]': ...
|
||||
def list_trace_events(self, *, session_id: 'str', limit: 'int' = 300) -> 'list[dict[str, Any]]': ...
|
||||
def list_trace_events_for_trace(self, *, session_id: 'str', trace_id: 'str', limit: 'int' = 500) -> 'list[dict[str, Any]]': ...
|
||||
def list_user_channel_accounts(self, *, tenant_id: 'str', user_id: 'str', channel: 'str' = 'wecom', include_inactive: 'bool' = True) -> 'list[dict[str, Any]]': ...
|
||||
def list_user_permissions(self, *, tenant_id: 'str', user_id: 'str', role: 'str | None' = None) -> 'list[str]': ...
|
||||
def list_user_workspace_extra_roots_union(self) -> 'list[str]': ...
|
||||
def list_users(self, *, tenant_id: 'str', limit: 'int' = 500, offset: 'int' = 0, q: 'str | None' = None, include_inactive: 'bool' = True) -> 'list[dict[str, Any]]': ...
|
||||
def migrate_secrets_to_fernet(self) -> 'dict[str, int]': ...
|
||||
def move_tool_logs_to_session(self, *, from_session_id: 'str', to_session_id: 'str') -> 'int': ...
|
||||
def oclaw_attempt_append(self, *, run_id: 'str', tenant_id: 'str', session_id: 'str', attempt_no: 'int', status: 'str', reason: 'str' = '', payload: 'dict[str, Any] | None' = None) -> 'int': ...
|
||||
def oclaw_attempt_list(self, *, run_id: 'str', limit: 'int' = 30) -> 'list[dict[str, Any]]': ...
|
||||
def oclaw_run_get(self, *, run_id: 'str', tenant_id: 'str | None' = None) -> 'OclawRun | None': ...
|
||||
def oclaw_run_list(self, *, tenant_id: 'str', session_id: 'str | None' = None, status: 'str | None' = None, limit: 'int' = 50) -> 'list[OclawRun]': ...
|
||||
def oclaw_run_upsert(self, *, run_id: 'str', tenant_id: 'str', session_id: 'str', status: 'str', payload: 'dict[str, Any] | None' = None) -> 'bool': ...
|
||||
def oclaw_task_claim(self, *, worker_id: 'str', lease_seconds: 'int' = 90, task_type: 'str | None' = None) -> 'OclawTask | None': ...
|
||||
def oclaw_task_create(self, *, tenant_id: 'str', session_id: 'str', task_type: 'str' = 'async_turn', payload: 'dict[str, Any] | None' = None) -> 'OclawTask': ...
|
||||
def oclaw_task_fail(self, *, task_id: 'str', error: 'str', result: 'dict[str, Any] | None' = None) -> 'bool': ...
|
||||
def oclaw_task_finish(self, *, task_id: 'str', result: 'dict[str, Any] | None' = None) -> 'bool': ...
|
||||
def oclaw_task_get(self, *, task_id: 'str', tenant_id: 'str | None' = None) -> 'OclawTask | None': ...
|
||||
def oclaw_task_list(self, *, status: 'str | None' = None, limit: 'int' = 50, tenant_id: 'str | None' = None, session_id: 'str | None' = None) -> 'list[OclawTask]': ...
|
||||
def rename_session(self, session_id: 'str', title: 'str') -> 'None': ...
|
||||
def replace_mcp_server_tools(self, *, server_id: 'str', tools: 'list[dict[str, Any]]') -> 'None': ...
|
||||
def resolve_user_by_channel_identity(self, *, channel: 'str', external_user_id: 'str') -> 'dict[str, Any] | None': ...
|
||||
def resolve_user_by_channel_identity_v2(self, *, channel: 'str', account_id: 'str', external_user_id: 'str') -> 'dict[str, Any] | None': ...
|
||||
def revoke_all_auth_sessions(self) -> 'int': ...
|
||||
def revoke_auth_session(self, *, session_token_hash: 'str') -> 'int': ...
|
||||
def revoke_llm_profile_grant(self, *, tenant_id: 'str', profile_id: 'str', user_id: 'str') -> 'int': ...
|
||||
def revoke_llm_profile_tenant_grant(self, *, tenant_id: 'str', profile_id: 'str') -> 'int': ...
|
||||
def search_knowledge(self, *, query: 'str', limit: 'int' = 3) -> 'list[dict[str, Any]]': ...
|
||||
def search_memory_vectors(self, *, query_vector: 'list[float]', model: 'str', tenant_id: 'str', user_id: 'str', limit: 'int' = 5) -> 'list[dict[str, Any]]': ...
|
||||
def set_llm_profile_secret(self, profile_id: 'str', plain_text: 'str') -> 'None': ...
|
||||
def set_mcp_server_enabled(self, *, server_id: 'str', enabled: 'bool') -> 'int': ...
|
||||
def set_mcp_server_health(self, *, server_id: 'str', status: 'str', detail: 'dict[str, Any] | None' = None) -> 'None': ...
|
||||
def set_secret(self, key: 'str', plain_text: 'str') -> 'None': ...
|
||||
def set_setting(self, key: 'str', value: 'str') -> 'None': ...
|
||||
def sync_attachment_acl_from_chat_message_attachments(self, *, session_id: 'str', role: 'str', event_type: 'str | None', attachments: 'Any') -> 'None': ...
|
||||
def tenant_has_llm_profile_grant(self, tenant_id: 'str', profile_id: 'str') -> 'bool': ...
|
||||
def todo_assign(self, *, tenant_id: 'str', todo_id: 'str', assignee_user_id: 'str') -> 'bool': ...
|
||||
def todo_create(self, *, tenant_id: 'str', owner_user_id: 'str', title: 'str', due_at: 'str | None' = None, assignee_user_id: 'str | None' = None) -> 'dict[str, Any]': ...
|
||||
def todo_list(self, *, tenant_id: 'str', assignee_user_id: 'str | None' = None, status: 'str | None' = 'open', limit: 'int' = 50) -> 'list[dict[str, Any]]': ...
|
||||
def todo_set_status(self, *, tenant_id: 'str', todo_id: 'str', status: 'str') -> 'bool': ...
|
||||
def touch_auth_session(self, *, session_token_hash: 'str') -> 'None': ...
|
||||
def trim_messages(self, session_id: 'str', keep_last: 'int') -> 'None': ...
|
||||
def update_llm_profile(self, profile_id: 'str', name: 'str', mode: 'str', model: 'str | None', base_url: 'str | None', *, thinking_mode_enabled: 'bool | None' = None, reasoning_effort: 'str | None' = None) -> 'None': ...
|
||||
def update_message_content(self, *, session_id: 'str', message_id: 'int', content: 'str', event_payload: 'Any | None' = None) -> 'bool': ...
|
||||
def update_user_account(self, *, tenant_id: 'str', user_id: 'str', display_name: 'str | None' = None, role: 'str | None' = None, is_active: 'bool | None' = None, password_hash: 'str | None' = None, avatar_attachment_id: 'str | None' = None) -> 'bool': ...
|
||||
def upsert_channel_identity(self, *, tenant_id: 'str', channel: 'str', external_user_id: 'str', user_id: 'str') -> 'None': ...
|
||||
def upsert_channel_identity_v2(self, *, tenant_id: 'str', channel: 'str', account_id: 'str', external_user_id: 'str', user_id: 'str') -> 'None': ...
|
||||
def upsert_knowledge_chunk(self, *, chunk_id: 'str', source: 'str', content: 'str', metadata: 'dict[str, Any] | None' = None) -> 'None': ...
|
||||
def upsert_knowledge_embedding(self, *, chunk_id: 'str', model: 'str', vector: 'list[float]') -> 'None': ...
|
||||
def upsert_mcp_server(self, *, server_id: 'str', source_type: 'str', source_ref: 'str', version: 'str' = '', entry_command: 'str' = '', entry_args: 'list[str] | None' = None, env_schema: 'dict[str, Any] | None' = None, required_permissions: 'list[str] | None' = None, risk_level: 'str' = 'high', timeout_s: 'float' = 30.0, enabled: 'bool' = False) -> 'None': ...
|
||||
def upsert_memory_item(self, *, memory_id: 'str', tenant_id: 'str', user_id: 'str', session_id: 'str', memory_type: 'str', content: 'str', confidence: 'float', source: 'str', metadata: 'dict[str, Any] | None' = None, created_at: 'str | None' = None, updated_at: 'str | None' = None, expires_at: 'str | None' = None) -> 'None': ...
|
||||
def upsert_memory_vector(self, *, memory_id: 'str', model: 'str', vector: 'list[float]', updated_at: 'str | None' = None) -> 'None': ...
|
||||
def upsert_tool_plugin(self, *, plugin_name: 'str', plugin_version: 'str', entry_point: 'str', enabled: 'bool' = True) -> 'None': ...
|
||||
def upsert_user_channel_account(self, *, tenant_id: 'str', user_id: 'str', channel: 'str', account_id: 'str', name: 'str | None' = None, config: 'dict[str, Any] | None' = None, is_active: 'bool' = True) -> 'None': ...
|
||||
def upsert_user_permission(self, *, tenant_id: 'str', user_id: 'str', permission: 'str') -> 'None': ...
|
||||
def upsert_user_workspace_path_allowlist(self, *, tenant_id: 'str', user_id: 'str', extra_roots: 'str', allow_any_path: 'bool', allow_high_risk_public_tools: 'bool' = False) -> 'None': ...
|
||||
def user_has_llm_profile_grant(self, tenant_id: 'str', user_id: 'str', profile_id: 'str') -> 'bool': ...
|
||||
|
||||
|
||||
__all__ = ["AssistantStoreProtocol"]
|
||||
43
svc/persistence/db/__init__.py
Normal file
43
svc/persistence/db/__init__.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
"""Persistence DB helpers."""
|
||||
|
||||
from svc.persistence.db.engine import (
|
||||
clear_assistant_engine_cache,
|
||||
engine_for_sqlite_file,
|
||||
get_assistant_engine,
|
||||
)
|
||||
from svc.persistence.db.tables import (
|
||||
app_setting,
|
||||
app_user,
|
||||
auth_session,
|
||||
bind_code,
|
||||
channel_identity,
|
||||
channel_identity_v2,
|
||||
channel_session_v2,
|
||||
chat_message,
|
||||
chat_session,
|
||||
metadata,
|
||||
tenant,
|
||||
tool_log,
|
||||
trace_event,
|
||||
ui_session_owner,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"app_user",
|
||||
"app_setting",
|
||||
"auth_session",
|
||||
"bind_code",
|
||||
"channel_identity",
|
||||
"channel_identity_v2",
|
||||
"channel_session_v2",
|
||||
"chat_message",
|
||||
"chat_session",
|
||||
"clear_assistant_engine_cache",
|
||||
"engine_for_sqlite_file",
|
||||
"get_assistant_engine",
|
||||
"metadata",
|
||||
"tenant",
|
||||
"tool_log",
|
||||
"trace_event",
|
||||
"ui_session_owner",
|
||||
]
|
||||
72
svc/persistence/db/engine.py
Normal file
72
svc/persistence/db/engine.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
"""SQLAlchemy Engine factory for assistant DB (SQLite or PostgreSQL)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from svc.config.database import assistant_sqlalchemy_url
|
||||
|
||||
|
||||
def _sqlite_sa_url_from_os_path(path: str) -> str:
|
||||
"""Build the same ``sqlite+pysqlite:///...`` URL shape as :func:`svc.config.database.assistant_sqlalchemy_url`."""
|
||||
p = Path(path).resolve().as_posix()
|
||||
return f"sqlite+pysqlite:///{p}"
|
||||
|
||||
|
||||
def _register_sqlite_pragmas(eng: Engine) -> None:
|
||||
@event.listens_for(eng, "connect")
|
||||
def _sqlite_pragmas(dbapi_conn: Any, _record: Any) -> None:
|
||||
cur = dbapi_conn.cursor()
|
||||
cur.execute("PRAGMA foreign_keys = ON;")
|
||||
cur.execute("PRAGMA journal_mode = WAL;")
|
||||
cur.execute("PRAGMA synchronous = NORMAL;")
|
||||
cur.execute("PRAGMA busy_timeout = 30000;")
|
||||
cur.close()
|
||||
|
||||
|
||||
@lru_cache(maxsize=64)
|
||||
def _engine_for_url(url: str) -> Engine:
|
||||
"""One engine per URL (process-wide)."""
|
||||
pool_kw: dict[str, Any] = {}
|
||||
if url.startswith("sqlite"):
|
||||
pool_kw["poolclass"] = NullPool
|
||||
else:
|
||||
pool_kw["pool_pre_ping"] = True
|
||||
eng = create_engine(url, future=True, **pool_kw)
|
||||
if url.startswith("sqlite"):
|
||||
_register_sqlite_pragmas(eng)
|
||||
return eng
|
||||
|
||||
|
||||
def get_assistant_engine() -> Engine:
|
||||
"""Engine for the current env-selected assistant DB (Alembic, ``get_assistant_store`` SQLite path)."""
|
||||
return _engine_for_url(assistant_sqlalchemy_url())
|
||||
|
||||
|
||||
def engine_for_sqlite_file(path: str) -> Engine:
|
||||
"""Engine for a specific SQLite file (e.g. ``SqliteStore('/tmp/x.sqlite')`` without env ``DB_PATH``)."""
|
||||
return _engine_for_url(_sqlite_sa_url_from_os_path(path))
|
||||
|
||||
|
||||
def clear_assistant_engine_cache() -> None:
|
||||
"""Drop cached engines (e.g. tests that delete temp DB files must call this before removing the directory)."""
|
||||
_engine_for_url.cache_clear()
|
||||
try:
|
||||
from svc.persistence.assistant_store import reset_assistant_store_singleton
|
||||
|
||||
reset_assistant_store_singleton()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
__all__ = [
|
||||
"clear_assistant_engine_cache",
|
||||
"engine_for_sqlite_file",
|
||||
"get_assistant_engine",
|
||||
]
|
||||
171
svc/persistence/db/tables.py
Normal file
171
svc/persistence/db/tables.py
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
"""SQLAlchemy Core table objects for incremental persistence migration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import BigInteger, Column, ForeignKey, Integer, MetaData, Table, Text
|
||||
|
||||
metadata = MetaData()
|
||||
|
||||
tenant = Table(
|
||||
"tenant",
|
||||
metadata,
|
||||
Column("id", Text, primary_key=True),
|
||||
Column("name", Text, nullable=False),
|
||||
Column("created_at", Text, nullable=False),
|
||||
)
|
||||
|
||||
bind_code = Table(
|
||||
"bind_code",
|
||||
metadata,
|
||||
Column("code", Text, primary_key=True),
|
||||
Column("tenant_id", Text, nullable=False),
|
||||
Column("role", Text, nullable=False),
|
||||
Column("created_at", Text, nullable=False),
|
||||
Column("used_at", Text, nullable=True),
|
||||
Column("used_by_external_user_id", Text, nullable=True),
|
||||
)
|
||||
|
||||
app_user = Table(
|
||||
"app_user",
|
||||
metadata,
|
||||
Column("id", Text, primary_key=True),
|
||||
Column("tenant_id", Text, nullable=False),
|
||||
Column("username", Text, nullable=True),
|
||||
Column("display_name", Text, nullable=False),
|
||||
Column("role", Text, nullable=False),
|
||||
Column("password_hash", Text, nullable=True),
|
||||
Column("is_active", Integer, nullable=False, server_default="1"),
|
||||
Column("created_at", Text, nullable=False),
|
||||
Column("avatar_attachment_id", Text, nullable=True),
|
||||
)
|
||||
|
||||
app_setting = Table(
|
||||
"app_setting",
|
||||
metadata,
|
||||
Column("key", Text, primary_key=True),
|
||||
Column("value", Text, nullable=False),
|
||||
Column("is_secret", Integer, nullable=False, server_default="0"),
|
||||
Column("updated_at", Text, nullable=False),
|
||||
)
|
||||
|
||||
auth_session = Table(
|
||||
"auth_session",
|
||||
metadata,
|
||||
Column("session_token_hash", Text, primary_key=True),
|
||||
Column("tenant_id", Text, nullable=False),
|
||||
Column("user_id", Text, nullable=False),
|
||||
Column("role", Text, nullable=False),
|
||||
Column("created_at", Text, nullable=False),
|
||||
Column("expires_at", Text, nullable=False),
|
||||
Column("last_seen_at", Text, nullable=False),
|
||||
Column("revoked_at", Text, nullable=True),
|
||||
)
|
||||
|
||||
chat_session = Table(
|
||||
"chat_session",
|
||||
metadata,
|
||||
Column("id", Text, primary_key=True),
|
||||
Column("title", Text, nullable=False),
|
||||
Column("created_at", Text, nullable=False),
|
||||
Column("last_message_at", Text, nullable=True),
|
||||
)
|
||||
|
||||
channel_identity_v2 = Table(
|
||||
"channel_identity_v2",
|
||||
metadata,
|
||||
Column("tenant_id", Text, primary_key=True),
|
||||
Column("channel", Text, primary_key=True),
|
||||
Column("account_id", Text, primary_key=True),
|
||||
Column("external_user_id", Text, primary_key=True),
|
||||
Column("user_id", Text, nullable=False),
|
||||
Column("created_at", Text, nullable=False),
|
||||
)
|
||||
|
||||
channel_identity = Table(
|
||||
"channel_identity",
|
||||
metadata,
|
||||
Column("tenant_id", Text, primary_key=True),
|
||||
Column("channel", Text, primary_key=True),
|
||||
Column("external_user_id", Text, primary_key=True),
|
||||
Column("user_id", Text, nullable=False),
|
||||
Column("created_at", Text, nullable=False),
|
||||
)
|
||||
|
||||
channel_session_v2 = Table(
|
||||
"channel_session_v2",
|
||||
metadata,
|
||||
Column("tenant_id", Text, primary_key=True),
|
||||
Column("channel", Text, primary_key=True),
|
||||
Column("account_id", Text, primary_key=True),
|
||||
Column("external_chat_id", Text, primary_key=True),
|
||||
Column("external_user_id", Text, primary_key=True),
|
||||
Column("session_id", Text, nullable=False),
|
||||
Column("created_at", Text, nullable=False),
|
||||
)
|
||||
|
||||
ui_session_owner = Table(
|
||||
"ui_session_owner",
|
||||
metadata,
|
||||
Column("session_id", Text, primary_key=True),
|
||||
Column("tenant_id", Text, nullable=False),
|
||||
Column("user_id", Text, nullable=False),
|
||||
Column("created_at", Text, nullable=False),
|
||||
)
|
||||
|
||||
chat_message = Table(
|
||||
"chat_message",
|
||||
metadata,
|
||||
Column("id", BigInteger, primary_key=True, autoincrement=True),
|
||||
Column("session_id", Text, ForeignKey("chat_session.id", ondelete="CASCADE"), nullable=False),
|
||||
Column("role", Text, nullable=False),
|
||||
Column("content", Text, nullable=False),
|
||||
Column("tool_calls", Text, nullable=True),
|
||||
Column("attachments", Text, nullable=True),
|
||||
Column("turn_uuid", Text, nullable=True),
|
||||
Column("event_type", Text, nullable=True),
|
||||
Column("event_payload", Text, nullable=True),
|
||||
Column("timestamp", Text, nullable=False),
|
||||
)
|
||||
|
||||
tool_log = Table(
|
||||
"tool_log",
|
||||
metadata,
|
||||
Column("id", BigInteger, primary_key=True, autoincrement=True),
|
||||
Column("session_id", Text, ForeignKey("chat_session.id", ondelete="CASCADE"), nullable=False),
|
||||
Column("tool_name", Text, nullable=False),
|
||||
Column("specialist", Text, nullable=False, server_default=""),
|
||||
Column("args", Text, nullable=False),
|
||||
Column("result", Text, nullable=False),
|
||||
Column("timestamp", Text, nullable=False),
|
||||
Column("duration_ms", Integer, nullable=True),
|
||||
)
|
||||
|
||||
trace_event = Table(
|
||||
"trace_event",
|
||||
metadata,
|
||||
Column("id", BigInteger, primary_key=True, autoincrement=True),
|
||||
Column("session_id", Text, nullable=False),
|
||||
Column("trace_id", Text, nullable=False),
|
||||
Column("span_id", Text, nullable=False),
|
||||
Column("parent_span_id", Text, nullable=True),
|
||||
Column("event_type", Text, nullable=False),
|
||||
Column("payload", Text, nullable=False, server_default="{}"),
|
||||
Column("timestamp", Text, nullable=False),
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"app_user",
|
||||
"app_setting",
|
||||
"auth_session",
|
||||
"bind_code",
|
||||
"channel_identity",
|
||||
"channel_identity_v2",
|
||||
"channel_session_v2",
|
||||
"chat_message",
|
||||
"chat_session",
|
||||
"metadata",
|
||||
"tenant",
|
||||
"tool_log",
|
||||
"trace_event",
|
||||
"ui_session_owner",
|
||||
]
|
||||
486
svc/persistence/ddl/postgresql_bootstrap.sql
Normal file
486
svc/persistence/ddl/postgresql_bootstrap.sql
Normal file
|
|
@ -0,0 +1,486 @@
|
|||
-- Generated for PostgreSQL assistant store (from SQLite schema)
|
||||
SET client_min_messages TO WARNING;
|
||||
CREATE TABLE IF NOT EXISTS chat_session (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
last_message_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tenant (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_user (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
username TEXT,
|
||||
display_name TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
password_hash TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL, avatar_attachment_id TEXT,
|
||||
FOREIGN KEY(tenant_id) REFERENCES tenant(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_identity (
|
||||
tenant_id TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
external_user_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (tenant_id, channel, external_user_id),
|
||||
FOREIGN KEY(tenant_id) REFERENCES tenant(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(user_id) REFERENCES app_user(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_identity_v2 (
|
||||
tenant_id TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
account_id TEXT NOT NULL,
|
||||
external_user_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (tenant_id, channel, account_id, external_user_id),
|
||||
FOREIGN KEY(tenant_id) REFERENCES tenant(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(user_id) REFERENCES app_user(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bind_code (
|
||||
code TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
used_at TEXT,
|
||||
used_by_external_user_id TEXT,
|
||||
FOREIGN KEY(tenant_id) REFERENCES tenant(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_session (
|
||||
tenant_id TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
external_chat_id TEXT NOT NULL,
|
||||
external_user_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (tenant_id, channel, external_chat_id, external_user_id),
|
||||
FOREIGN KEY(tenant_id) REFERENCES tenant(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(session_id) REFERENCES chat_session(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_session_v2 (
|
||||
tenant_id TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
account_id TEXT NOT NULL,
|
||||
external_chat_id TEXT NOT NULL,
|
||||
external_user_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (tenant_id, channel, account_id, external_chat_id, external_user_id),
|
||||
FOREIGN KEY(tenant_id) REFERENCES tenant(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(session_id) REFERENCES chat_session(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_channel_account (
|
||||
tenant_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
account_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
config TEXT NOT NULL DEFAULT '{}',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (tenant_id, user_id, channel, account_id),
|
||||
FOREIGN KEY(tenant_id) REFERENCES tenant(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(user_id) REFERENCES app_user(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS todo_item (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
owner_user_id TEXT NOT NULL,
|
||||
assignee_user_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
due_at TEXT,
|
||||
status TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(tenant_id) REFERENCES tenant(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(owner_user_id) REFERENCES app_user(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(assignee_user_id) REFERENCES app_user(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chat_message (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
tool_calls TEXT,
|
||||
attachments TEXT,
|
||||
turn_uuid TEXT,
|
||||
event_type TEXT,
|
||||
event_payload TEXT,
|
||||
timestamp TEXT NOT NULL,
|
||||
FOREIGN KEY(session_id) REFERENCES chat_session(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ui_session_owner (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(session_id) REFERENCES chat_session(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(tenant_id) REFERENCES tenant(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(user_id) REFERENCES app_user(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_workspace_path_allowlist (
|
||||
tenant_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
extra_roots TEXT NOT NULL DEFAULT '',
|
||||
allow_any_path INTEGER NOT NULL DEFAULT 0,
|
||||
allow_high_risk_public_tools INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (tenant_id, user_id),
|
||||
FOREIGN KEY(tenant_id) REFERENCES tenant(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(user_id) REFERENCES app_user(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_session (
|
||||
session_token_hash TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
last_seen_at TEXT NOT NULL,
|
||||
revoked_at TEXT,
|
||||
FOREIGN KEY(tenant_id) REFERENCES tenant(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(user_id) REFERENCES app_user(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS role_permission (
|
||||
role TEXT NOT NULL,
|
||||
permission TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (role, permission)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_permission (
|
||||
tenant_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
permission TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (tenant_id, user_id, permission),
|
||||
FOREIGN KEY(tenant_id) REFERENCES tenant(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(user_id) REFERENCES app_user(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_audit_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
actor_tenant_id TEXT NOT NULL,
|
||||
actor_user_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
detail TEXT NOT NULL DEFAULT '{}',
|
||||
timestamp TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attachment_acl (
|
||||
attachment_id TEXT NOT NULL,
|
||||
tenant_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (attachment_id, tenant_id, user_id, session_id, source),
|
||||
FOREIGN KEY(session_id) REFERENCES chat_session(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(tenant_id) REFERENCES tenant(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(user_id) REFERENCES app_user(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tool_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
tool_name TEXT NOT NULL,
|
||||
specialist TEXT NOT NULL DEFAULT '',
|
||||
args TEXT NOT NULL,
|
||||
result TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
duration_ms INTEGER,
|
||||
FOREIGN KEY(session_id) REFERENCES chat_session(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tool_plugin (
|
||||
plugin_name TEXT NOT NULL,
|
||||
plugin_version TEXT NOT NULL,
|
||||
entry_point TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (plugin_name, entry_point)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mcp_server_registry (
|
||||
server_id TEXT PRIMARY KEY,
|
||||
source_type TEXT NOT NULL,
|
||||
source_ref TEXT NOT NULL,
|
||||
version TEXT NOT NULL DEFAULT '',
|
||||
entry_command TEXT NOT NULL DEFAULT '',
|
||||
entry_args TEXT NOT NULL DEFAULT '[]',
|
||||
env_schema TEXT NOT NULL DEFAULT '{}',
|
||||
required_permissions TEXT NOT NULL DEFAULT '[]',
|
||||
risk_level TEXT NOT NULL DEFAULT 'high',
|
||||
timeout_s DOUBLE PRECISION NOT NULL DEFAULT 30,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mcp_server_installation (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
server_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
error_code TEXT NOT NULL DEFAULT '',
|
||||
detail TEXT NOT NULL DEFAULT '{}',
|
||||
install_command TEXT NOT NULL DEFAULT '',
|
||||
timestamp TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mcp_server_health (
|
||||
server_id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL,
|
||||
detail TEXT NOT NULL DEFAULT '{}',
|
||||
checked_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mcp_server_tool (
|
||||
server_id TEXT NOT NULL,
|
||||
tool_name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
parameters TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (server_id, tool_name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_setting (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
is_secret INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS knowledge_chunk (
|
||||
chunk_id TEXT PRIMARY KEY,
|
||||
source TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
metadata TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS knowledge_embedding (
|
||||
chunk_id TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
dim INTEGER NOT NULL,
|
||||
vector_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (chunk_id, model),
|
||||
FOREIGN KEY(chunk_id) REFERENCES knowledge_chunk(chunk_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS memory_item (
|
||||
memory_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
memory_type TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
confidence DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
source TEXT NOT NULL DEFAULT 'memory',
|
||||
metadata TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
expires_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS memory_vector (
|
||||
memory_id TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
dim INTEGER NOT NULL,
|
||||
vector_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (memory_id, model),
|
||||
FOREIGN KEY(memory_id) REFERENCES memory_item(memory_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS memory_hit_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
session_id TEXT,
|
||||
memory_id TEXT,
|
||||
query_text TEXT NOT NULL,
|
||||
score DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
source TEXT NOT NULL DEFAULT 'memory',
|
||||
timestamp TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oclaw_task (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
task_type TEXT NOT NULL DEFAULT 'async_turn',
|
||||
status TEXT NOT NULL,
|
||||
payload TEXT NOT NULL DEFAULT '{}',
|
||||
result TEXT NOT NULL DEFAULT '{}',
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||
claimed_by TEXT,
|
||||
lease_expires_at TEXT,
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
finished_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oclaw_run (
|
||||
run_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
payload TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oclaw_attempt (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
run_id TEXT NOT NULL,
|
||||
tenant_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
attempt_no INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
payload TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_audit_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
specialist TEXT NOT NULL,
|
||||
task_kind TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
timestamp TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_eval_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
specialist TEXT NOT NULL,
|
||||
task_kind TEXT NOT NULL,
|
||||
success INTEGER NOT NULL,
|
||||
latency_ms INTEGER NOT NULL,
|
||||
cost_hint DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
timestamp TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS trace_event (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
trace_id TEXT NOT NULL,
|
||||
span_id TEXT NOT NULL,
|
||||
parent_span_id TEXT,
|
||||
event_type TEXT NOT NULL,
|
||||
payload TEXT NOT NULL DEFAULT '{}',
|
||||
timestamp TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS llm_profile (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
mode TEXT NOT NULL,
|
||||
model TEXT,
|
||||
base_url TEXT,
|
||||
api_key TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
, is_builtin INTEGER NOT NULL DEFAULT 0, hide_in_ui INTEGER NOT NULL DEFAULT 0, owner_user_id TEXT, thinking_mode_enabled INTEGER NOT NULL DEFAULT 0, reasoning_effort TEXT);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS llm_profile_user_grant (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
profile_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
created_by_user_id TEXT,
|
||||
UNIQUE(tenant_id, profile_id, user_id),
|
||||
FOREIGN KEY(profile_id) REFERENCES llm_profile(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(user_id) REFERENCES app_user(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS llm_profile_tenant_grant (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
profile_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
created_by_user_id TEXT,
|
||||
UNIQUE(tenant_id, profile_id),
|
||||
FOREIGN KEY(profile_id) REFERENCES llm_profile(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_session_activity ON chat_session(COALESCE(last_message_at, created_at) DESC, created_at DESC);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_app_user_tenant_username ON app_user(tenant_id, username);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_channel_account_channel_account ON user_channel_account(channel, account_id, is_active);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_message_session_turn_uuid ON chat_message(session_id, turn_uuid);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ui_session_owner_tenant_user ON ui_session_owner(tenant_id, user_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ui_session_owner_tenant_user_session ON ui_session_owner(tenant_id, user_id, session_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ui_session_owner_tenant_session ON ui_session_owner(tenant_id, session_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_session_user_expires ON auth_session(user_id, expires_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_admin_audit_actor_ts ON admin_audit_log(actor_user_id, timestamp DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_attachment_acl_tenant_attachment ON attachment_acl(tenant_id, attachment_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_attachment_acl_user_attachment ON attachment_acl(tenant_id, user_id, attachment_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_attachment_acl_session_attachment ON attachment_acl(session_id, attachment_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_item_tenant_user_updated ON memory_item(tenant_id, user_id, updated_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_hit_log_tenant_user_ts ON memory_hit_log(tenant_id, user_id, timestamp DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_item_session_updated ON memory_item(session_id, updated_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_oclaw_task_status_updated ON oclaw_task(status, updated_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_oclaw_task_tenant_session ON oclaw_task(tenant_id, session_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_oclaw_run_tenant_session ON oclaw_run(tenant_id, session_id, updated_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_oclaw_attempt_run_no ON oclaw_attempt(run_id, attempt_no);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_chunk_source_updated ON knowledge_chunk(source, updated_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_trace_event_session_id_id ON trace_event(session_id, id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_llm_profile_grant_user ON llm_profile_user_grant(tenant_id, user_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_llm_profile_grant_profile ON llm_profile_user_grant(tenant_id, profile_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_llm_profile_tenant_grant ON llm_profile_tenant_grant(tenant_id, profile_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_message_session_id_id ON chat_message(session_id, id);
|
||||
68
svc/persistence/pg_adapter.py
Normal file
68
svc/persistence/pg_adapter.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
"""psycopg connection surface compatible with sqlite3 usage in SqliteStore."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterable, Sequence
|
||||
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
from svc.persistence.pg_compat import adapt_sql_for_postgres
|
||||
|
||||
|
||||
def normalize_psycopg_conninfo(url: str) -> str:
|
||||
"""Strip SQLAlchemy driver suffix so :func:`psycopg.connect` accepts the URI."""
|
||||
u = str(url or "").strip()
|
||||
for prefix in (
|
||||
"postgresql+psycopg://",
|
||||
"postgresql+psycopg2://",
|
||||
"postgres+psycopg://",
|
||||
"postgres+psycopg2://",
|
||||
):
|
||||
if u.startswith(prefix):
|
||||
rest = u.split("://", 1)[1]
|
||||
return "postgresql://" + rest
|
||||
return u
|
||||
|
||||
|
||||
class PgCursorShim:
|
||||
def __init__(self, raw: Any) -> None:
|
||||
self._raw = raw
|
||||
|
||||
def fetchone(self) -> Any:
|
||||
return self._raw.fetchone()
|
||||
|
||||
def fetchall(self) -> list[Any]:
|
||||
return self._raw.fetchall()
|
||||
|
||||
@property
|
||||
def lastrowid(self) -> int:
|
||||
return 0
|
||||
|
||||
@property
|
||||
def rowcount(self) -> int:
|
||||
return int(self._raw.rowcount or 0)
|
||||
|
||||
def __iter__(self) -> Iterable[Any]:
|
||||
return iter(self._raw)
|
||||
|
||||
|
||||
class PgConnShim:
|
||||
def __init__(self, raw: psycopg.Connection) -> None:
|
||||
self._raw = raw
|
||||
|
||||
def execute(self, sql: str, params: Sequence[Any] | None = None) -> PgCursorShim:
|
||||
adapted = adapt_sql_for_postgres(sql)
|
||||
cur = self._raw.execute(adapted, params or ())
|
||||
return PgCursorShim(cur)
|
||||
|
||||
def executemany(self, sql: str, seq_of_params: Sequence[Sequence[Any]]) -> None:
|
||||
adapted = adapt_sql_for_postgres(sql)
|
||||
self._raw.executemany(adapted, seq_of_params)
|
||||
|
||||
|
||||
def connect_postgres(url: str) -> psycopg.Connection:
|
||||
return psycopg.connect(normalize_psycopg_conninfo(url), row_factory=dict_row)
|
||||
|
||||
|
||||
__all__ = ["PgConnShim", "PgCursorShim", "connect_postgres", "normalize_psycopg_conninfo"]
|
||||
230
svc/persistence/pg_compat.py
Normal file
230
svc/persistence/pg_compat.py
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
"""Translate SQLite-oriented SQL to PostgreSQL for psycopg execution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
def scrub_nul_bytes_from_text(s: str | None) -> str | None:
|
||||
"""PostgreSQL ``TEXT`` / ``VARCHAR`` reject U+0000; SQLite allows it.
|
||||
|
||||
Strip NULs from any string bound for PG text columns so assistant/tool rows
|
||||
persist instead of failing the whole ``INSERT`` after the user row succeeded.
|
||||
"""
|
||||
if s is None:
|
||||
return None
|
||||
if "\x00" not in s:
|
||||
return s
|
||||
return s.replace("\x00", "")
|
||||
|
||||
|
||||
def scrub_nul_bytes_from_jsonable(obj: Any) -> Any:
|
||||
"""Recursively remove NUL from strings inside dict/list before ``json.dumps``.
|
||||
|
||||
``json.dumps`` encodes embedded NUL as the six-character ``\\u0000`` escape; a
|
||||
plain ``TEXT`` scrub on the serialized JSON would not remove the decoded NUL
|
||||
after reload, and PostgreSQL still rejects a true NUL inside string values.
|
||||
"""
|
||||
if isinstance(obj, str):
|
||||
return obj.replace("\x00", "") if "\x00" in obj else obj
|
||||
if isinstance(obj, dict):
|
||||
return {k: scrub_nul_bytes_from_jsonable(v) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [scrub_nul_bytes_from_jsonable(v) for v in obj]
|
||||
if isinstance(obj, tuple):
|
||||
return tuple(scrub_nul_bytes_from_jsonable(v) for v in obj)
|
||||
return obj
|
||||
|
||||
|
||||
def qmarks_to_percent(sql: str) -> str:
|
||||
"""Replace ``?`` placeholders outside single-quoted strings with ``%s`` (psycopg)."""
|
||||
out: list[str] = []
|
||||
i = 0
|
||||
in_single = False
|
||||
while i < len(sql):
|
||||
ch = sql[i]
|
||||
if ch == "'" and (i == 0 or sql[i - 1] != "\\"):
|
||||
in_single = not in_single
|
||||
out.append(ch)
|
||||
i += 1
|
||||
continue
|
||||
if ch == "?" and not in_single:
|
||||
out.append("%s")
|
||||
else:
|
||||
out.append(ch)
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def rewrite_sqlite_extensions_for_postgres(sql: str) -> str:
|
||||
"""Rewrite SQLite-only INSERT forms to PostgreSQL-compatible SQL (still uses ``?``)."""
|
||||
s = sql
|
||||
repls: list[tuple[str, str]] = [
|
||||
(
|
||||
"""INSERT OR REPLACE INTO ui_session_owner(session_id, tenant_id, user_id, created_at)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
"""INSERT INTO ui_session_owner(session_id, tenant_id, user_id, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (session_id) DO UPDATE SET
|
||||
tenant_id = EXCLUDED.tenant_id,
|
||||
user_id = EXCLUDED.user_id,
|
||||
created_at = EXCLUDED.created_at""",
|
||||
),
|
||||
(
|
||||
"""INSERT OR IGNORE INTO ui_session_owner(session_id, tenant_id, user_id, created_at)
|
||||
SELECT DISTINCT cs.session_id, cs.tenant_id, ci.user_id, ?
|
||||
FROM channel_session_v2 cs
|
||||
JOIN channel_identity_v2 ci
|
||||
ON ci.tenant_id = cs.tenant_id
|
||||
AND ci.channel = cs.channel
|
||||
AND ci.account_id = cs.account_id
|
||||
AND ci.external_user_id = cs.external_user_id
|
||||
WHERE cs.session_id IS NOT NULL AND cs.session_id != ''""",
|
||||
"""INSERT INTO ui_session_owner(session_id, tenant_id, user_id, created_at)
|
||||
SELECT DISTINCT cs.session_id, cs.tenant_id, ci.user_id, ?
|
||||
FROM channel_session_v2 cs
|
||||
JOIN channel_identity_v2 ci
|
||||
ON ci.tenant_id = cs.tenant_id
|
||||
AND ci.channel = cs.channel
|
||||
AND ci.account_id = cs.account_id
|
||||
AND ci.external_user_id = cs.external_user_id
|
||||
WHERE cs.session_id IS NOT NULL AND cs.session_id != ''
|
||||
ON CONFLICT (session_id) DO NOTHING""",
|
||||
),
|
||||
(
|
||||
"""INSERT OR IGNORE INTO ui_session_owner(session_id, tenant_id, user_id, created_at)
|
||||
SELECT s.id, ?, ?, COALESCE(s.created_at, ?)
|
||||
FROM chat_session s
|
||||
WHERE NOT EXISTS (SELECT 1 FROM ui_session_owner o WHERE o.session_id = s.id)""",
|
||||
"""INSERT INTO ui_session_owner(session_id, tenant_id, user_id, created_at)
|
||||
SELECT s.id, ?, ?, COALESCE(s.created_at, ?)
|
||||
FROM chat_session s
|
||||
WHERE NOT EXISTS (SELECT 1 FROM ui_session_owner o WHERE o.session_id = s.id)
|
||||
ON CONFLICT (session_id) DO NOTHING""",
|
||||
),
|
||||
(
|
||||
"""INSERT OR IGNORE INTO ui_session_owner(session_id, tenant_id, user_id, created_at)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
"""INSERT INTO ui_session_owner(session_id, tenant_id, user_id, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (session_id) DO NOTHING""",
|
||||
),
|
||||
(
|
||||
"""INSERT OR IGNORE INTO llm_profile
|
||||
(id, name, mode, model, base_url, api_key, updated_at, is_builtin, hide_in_ui, owner_user_id)
|
||||
VALUES (?, ?, 'ollama', ?, ?, NULL, ?, 1, 0, NULL)""",
|
||||
"""INSERT INTO llm_profile
|
||||
(id, name, mode, model, base_url, api_key, updated_at, is_builtin, hide_in_ui, owner_user_id)
|
||||
VALUES (?, ?, 'ollama', ?, ?, NULL, ?, 1, 0, NULL)
|
||||
ON CONFLICT (id) DO NOTHING""",
|
||||
),
|
||||
(
|
||||
"""INSERT OR IGNORE INTO llm_profile
|
||||
(id, name, mode, model, base_url, api_key, updated_at, is_builtin, hide_in_ui, owner_user_id)
|
||||
VALUES (?, ?, 'rule', NULL, NULL, NULL, ?, 1, 1, NULL)""",
|
||||
"""INSERT INTO llm_profile
|
||||
(id, name, mode, model, base_url, api_key, updated_at, is_builtin, hide_in_ui, owner_user_id)
|
||||
VALUES (?, ?, 'rule', NULL, NULL, NULL, ?, 1, 1, NULL)
|
||||
ON CONFLICT (id) DO NOTHING""",
|
||||
),
|
||||
(
|
||||
"""INSERT OR IGNORE INTO role_permission(role, permission, created_at)
|
||||
VALUES (?, ?, ?)""",
|
||||
"""INSERT INTO role_permission(role, permission, created_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT (role, permission) DO NOTHING""",
|
||||
),
|
||||
(
|
||||
"""INSERT OR IGNORE INTO attachment_acl
|
||||
(attachment_id, tenant_id, user_id, session_id, source, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
"""INSERT INTO attachment_acl
|
||||
(attachment_id, tenant_id, user_id, session_id, source, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (attachment_id, tenant_id, user_id, session_id, source) DO NOTHING""",
|
||||
),
|
||||
(
|
||||
"""INSERT OR IGNORE INTO attachment_acl
|
||||
(attachment_id, tenant_id, user_id, session_id, source, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
"""INSERT INTO attachment_acl
|
||||
(attachment_id, tenant_id, user_id, session_id, source, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (attachment_id, tenant_id, user_id, session_id, source) DO NOTHING""",
|
||||
),
|
||||
(
|
||||
"""INSERT OR IGNORE INTO llm_profile_user_grant
|
||||
(id, tenant_id, profile_id, user_id, created_at, created_by_user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
"""INSERT INTO llm_profile_user_grant
|
||||
(id, tenant_id, profile_id, user_id, created_at, created_by_user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (tenant_id, profile_id, user_id) DO NOTHING""",
|
||||
),
|
||||
(
|
||||
"""INSERT OR IGNORE INTO llm_profile_tenant_grant
|
||||
(id, tenant_id, profile_id, created_at, created_by_user_id)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
"""INSERT INTO llm_profile_tenant_grant
|
||||
(id, tenant_id, profile_id, created_at, created_by_user_id)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (tenant_id, profile_id) DO NOTHING""",
|
||||
),
|
||||
(
|
||||
"""INSERT OR IGNORE INTO user_permission (tenant_id, user_id, permission, created_at)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
"""INSERT INTO user_permission (tenant_id, user_id, permission, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (tenant_id, user_id, permission) DO NOTHING""",
|
||||
),
|
||||
(
|
||||
"""INSERT OR IGNORE INTO channel_session
|
||||
(tenant_id, channel, external_chat_id, external_user_id, session_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
"""INSERT INTO channel_session
|
||||
(tenant_id, channel, external_chat_id, external_user_id, session_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (tenant_id, channel, external_chat_id, external_user_id) DO NOTHING""",
|
||||
),
|
||||
(
|
||||
"""INSERT OR IGNORE INTO channel_session_v2
|
||||
(tenant_id, channel, account_id, external_chat_id, external_user_id, session_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
"""INSERT INTO channel_session_v2
|
||||
(tenant_id, channel, account_id, external_chat_id, external_user_id, session_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (tenant_id, channel, account_id, external_chat_id, external_user_id) DO NOTHING""",
|
||||
),
|
||||
]
|
||||
for old, new in repls:
|
||||
if old in s:
|
||||
s = s.replace(old, new, 1)
|
||||
if "INSERT OR IGNORE INTO" in s:
|
||||
s = re.sub(
|
||||
r"INSERT\s+OR\s+IGNORE\s+INTO\s+(\w+)\s+",
|
||||
r"INSERT INTO \1 ",
|
||||
s,
|
||||
count=1,
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
if "ON CONFLICT" not in s.upper():
|
||||
s = s.rstrip() + "\nON CONFLICT DO NOTHING"
|
||||
if "INSERT OR REPLACE INTO" in s.upper():
|
||||
raise ValueError(
|
||||
"unsupported INSERT OR REPLACE for PostgreSQL; extend svc.persistence.pg_compat"
|
||||
)
|
||||
return s
|
||||
|
||||
|
||||
def adapt_sql_for_postgres(sql: str) -> str:
|
||||
return qmarks_to_percent(rewrite_sqlite_extensions_for_postgres(sql))
|
||||
|
||||
|
||||
__all__ = [
|
||||
"adapt_sql_for_postgres",
|
||||
"qmarks_to_percent",
|
||||
"rewrite_sqlite_extensions_for_postgres",
|
||||
"scrub_nul_bytes_from_jsonable",
|
||||
"scrub_nul_bytes_from_text",
|
||||
]
|
||||
30
svc/persistence/sa_repos/__init__.py
Normal file
30
svc/persistence/sa_repos/__init__.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"""SQLAlchemy-backed repository slices (incremental migration off raw SQL)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from svc.persistence.sa_repos.admin_user_stats import AdminUserStatsSaRepository
|
||||
from svc.persistence.sa_repos.app_settings import AppSettingsSaRepository
|
||||
from svc.persistence.sa_repos.app_users import AppUsersSaRepository
|
||||
from svc.persistence.sa_repos.auth_sessions import AuthSessionsSaRepository
|
||||
from svc.persistence.sa_repos.chat_messages import ChatMessagesSaRepository
|
||||
from svc.persistence.sa_repos.chat_sessions import ChatSessionsSaRepository
|
||||
from svc.persistence.sa_repos.session_tool_health import SessionToolHealthSaRepository
|
||||
from svc.persistence.sa_repos.tenant_bind_code import BindCodeSaRepository, TenantSaRepository
|
||||
from svc.persistence.sa_repos.tool_log_queries import ToolLogQueriesSaRepository
|
||||
from svc.persistence.sa_repos.trace_events import TraceEventsSaRepository
|
||||
from svc.persistence.sa_repos.ui_session_owner import UiSessionOwnerSaRepository
|
||||
|
||||
__all__ = [
|
||||
"AdminUserStatsSaRepository",
|
||||
"AppSettingsSaRepository",
|
||||
"AppUsersSaRepository",
|
||||
"AuthSessionsSaRepository",
|
||||
"BindCodeSaRepository",
|
||||
"ChatMessagesSaRepository",
|
||||
"ChatSessionsSaRepository",
|
||||
"SessionToolHealthSaRepository",
|
||||
"TenantSaRepository",
|
||||
"ToolLogQueriesSaRepository",
|
||||
"TraceEventsSaRepository",
|
||||
"UiSessionOwnerSaRepository",
|
||||
]
|
||||
167
svc/persistence/sa_repos/admin_user_stats.py
Normal file
167
svc/persistence/sa_repos/admin_user_stats.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
"""Admin tenant user stats (list_admin_user_stats) via SQLAlchemy Core."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import and_, case, distinct, func, literal, or_, select
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.sql import bindparam
|
||||
|
||||
from svc.persistence.db.tables import (
|
||||
app_user,
|
||||
auth_session,
|
||||
chat_session,
|
||||
trace_event,
|
||||
ui_session_owner,
|
||||
)
|
||||
|
||||
|
||||
class AdminUserStatsSaRepository:
|
||||
"""Aggregates for ``SqliteStore.list_admin_user_stats``."""
|
||||
|
||||
__slots__ = ("_engine",)
|
||||
|
||||
def __init__(self, engine: Engine) -> None:
|
||||
self._engine = engine
|
||||
|
||||
def fetch(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
search_lower: str | None,
|
||||
cutoff_iso: str,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> dict[str, Any]:
|
||||
tid = str(tenant_id or "").strip()
|
||||
lim = max(1, min(int(limit), 500))
|
||||
off = max(0, int(offset))
|
||||
q_text = str(search_lower or "").strip().lower() or None
|
||||
cutoff = str(cutoff_iso)
|
||||
|
||||
conds: list[Any] = [app_user.c.tenant_id == tid]
|
||||
if q_text:
|
||||
like = f"%{q_text}%"
|
||||
conds.append(
|
||||
or_(
|
||||
func.lower(func.coalesce(app_user.c.username, literal(""))).like(like),
|
||||
func.lower(func.coalesce(app_user.c.display_name, literal(""))).like(like),
|
||||
)
|
||||
)
|
||||
wh = and_(*conds)
|
||||
|
||||
user_stmt = (
|
||||
select(
|
||||
app_user.c.id.label("user_id"),
|
||||
app_user.c.username,
|
||||
func.coalesce(app_user.c.display_name, literal("")).label("display_name"),
|
||||
app_user.c.role,
|
||||
app_user.c.is_active,
|
||||
)
|
||||
.where(wh)
|
||||
.order_by(app_user.c.username.asc())
|
||||
.limit(lim)
|
||||
.offset(off)
|
||||
)
|
||||
cnt_stmt = select(func.count()).select_from(app_user).where(wh)
|
||||
|
||||
sess_join = chat_session.join(
|
||||
ui_session_owner,
|
||||
ui_session_owner.c.session_id == chat_session.c.id,
|
||||
)
|
||||
active_ts = func.coalesce(chat_session.c.last_message_at, chat_session.c.created_at)
|
||||
total_active_sess_stmt = (
|
||||
select(func.count(distinct(chat_session.c.id)))
|
||||
.select_from(sess_join)
|
||||
.where(ui_session_owner.c.tenant_id == tid, active_ts >= literal(cutoff))
|
||||
)
|
||||
total_active_logins_stmt = (
|
||||
select(func.count())
|
||||
.select_from(auth_session)
|
||||
.where(
|
||||
auth_session.c.tenant_id == tid,
|
||||
auth_session.c.revoked_at.is_(None),
|
||||
auth_session.c.expires_at > literal(cutoff),
|
||||
auth_session.c.last_seen_at >= literal(cutoff),
|
||||
)
|
||||
)
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
total_users = int(conn.execute(cnt_stmt).scalar_one() or 0)
|
||||
user_rows = [dict(r) for r in conn.execute(user_stmt).mappings().all()]
|
||||
total_active_sessions = int(conn.execute(total_active_sess_stmt).scalar_one() or 0)
|
||||
total_active_logins = int(conn.execute(total_active_logins_stmt).scalar_one() or 0)
|
||||
|
||||
uids = [str(r["user_id"] or "").strip() for r in user_rows if str(r.get("user_id") or "").strip()]
|
||||
trace_rows: list[dict[str, Any]] = []
|
||||
own_count_rows: list[dict[str, Any]] = []
|
||||
active_sess_rows: list[dict[str, Any]] = []
|
||||
login_rows: list[dict[str, Any]] = []
|
||||
|
||||
if uids:
|
||||
uids_param = bindparam("uids", expanding=True)
|
||||
trace_stmt = (
|
||||
select(ui_session_owner.c.user_id, trace_event.c.payload)
|
||||
.select_from(
|
||||
trace_event.join(
|
||||
ui_session_owner,
|
||||
ui_session_owner.c.session_id == trace_event.c.session_id,
|
||||
)
|
||||
)
|
||||
.where(ui_session_owner.c.tenant_id == tid, ui_session_owner.c.user_id.in_(uids_param))
|
||||
)
|
||||
own_cnt_stmt = (
|
||||
select(ui_session_owner.c.user_id, func.count().label("c"))
|
||||
.where(ui_session_owner.c.tenant_id == tid, ui_session_owner.c.user_id.in_(uids_param))
|
||||
.group_by(ui_session_owner.c.user_id)
|
||||
)
|
||||
active_case = case(
|
||||
(active_ts >= literal(cutoff), chat_session.c.id),
|
||||
else_=None,
|
||||
)
|
||||
active_sess_stmt = (
|
||||
select(
|
||||
ui_session_owner.c.user_id,
|
||||
func.count(distinct(active_case)).label("active_30m"),
|
||||
func.max(active_ts).label("last_message_at"),
|
||||
)
|
||||
.select_from(sess_join)
|
||||
.where(ui_session_owner.c.tenant_id == tid, ui_session_owner.c.user_id.in_(uids_param))
|
||||
.group_by(ui_session_owner.c.user_id)
|
||||
)
|
||||
login_stmt = (
|
||||
select(
|
||||
auth_session.c.user_id,
|
||||
func.count().label("c"),
|
||||
func.max(auth_session.c.last_seen_at).label("last_seen_at"),
|
||||
)
|
||||
.where(
|
||||
auth_session.c.tenant_id == tid,
|
||||
auth_session.c.user_id.in_(uids_param),
|
||||
auth_session.c.revoked_at.is_(None),
|
||||
auth_session.c.expires_at > literal(cutoff),
|
||||
auth_session.c.last_seen_at >= literal(cutoff),
|
||||
)
|
||||
.group_by(auth_session.c.user_id)
|
||||
)
|
||||
bind = {"uids": uids}
|
||||
with self._engine.connect() as conn:
|
||||
trace_rows = [dict(r) for r in conn.execute(trace_stmt, bind).mappings().all()]
|
||||
own_count_rows = [dict(r) for r in conn.execute(own_cnt_stmt, bind).mappings().all()]
|
||||
active_sess_rows = [dict(r) for r in conn.execute(active_sess_stmt, bind).mappings().all()]
|
||||
login_rows = [dict(r) for r in conn.execute(login_stmt, bind).mappings().all()]
|
||||
|
||||
return {
|
||||
"total_users": total_users,
|
||||
"user_rows": user_rows,
|
||||
"total_active_sessions_30m": total_active_sessions,
|
||||
"total_active_logins_30m": total_active_logins,
|
||||
"trace_rows": trace_rows,
|
||||
"sessions_count_rows": own_count_rows,
|
||||
"active_sess_rows": active_sess_rows,
|
||||
"login_rows": login_rows,
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["AdminUserStatsSaRepository"]
|
||||
141
svc/persistence/sa_repos/app_settings.py
Normal file
141
svc/persistence/sa_repos/app_settings.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
"""app_setting access via SQLAlchemy Core (SQLite + PostgreSQL)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
from sqlalchemy.engine import Connection, Engine
|
||||
|
||||
from svc.persistence.db.tables import app_setting
|
||||
|
||||
|
||||
class AppSettingsSaRepository:
|
||||
"""Phase-1 SA migration: ``app_setting`` reads/writes only."""
|
||||
|
||||
__slots__ = ("_engine",)
|
||||
|
||||
def __init__(self, engine: Engine) -> None:
|
||||
self._engine = engine
|
||||
|
||||
def _dialect(self, conn: Connection) -> str:
|
||||
return conn.engine.dialect.name
|
||||
|
||||
def _upsert(
|
||||
self,
|
||||
conn: Connection,
|
||||
*,
|
||||
key: str,
|
||||
value: str,
|
||||
is_secret: int,
|
||||
updated_at: str,
|
||||
) -> None:
|
||||
dialect = self._dialect(conn)
|
||||
if dialect == "sqlite":
|
||||
ins = sqlite_insert(app_setting).values(
|
||||
key=key,
|
||||
value=value,
|
||||
is_secret=is_secret,
|
||||
updated_at=updated_at,
|
||||
)
|
||||
stmt = ins.on_conflict_do_update(
|
||||
index_elements=[app_setting.c.key],
|
||||
set_={
|
||||
"value": ins.excluded.value,
|
||||
"is_secret": ins.excluded.is_secret,
|
||||
"updated_at": ins.excluded.updated_at,
|
||||
},
|
||||
)
|
||||
elif dialect == "postgresql":
|
||||
ins = pg_insert(app_setting).values(
|
||||
key=key,
|
||||
value=value,
|
||||
is_secret=is_secret,
|
||||
updated_at=updated_at,
|
||||
)
|
||||
stmt = ins.on_conflict_do_update(
|
||||
index_elements=[app_setting.c.key],
|
||||
set_={
|
||||
"value": ins.excluded.value,
|
||||
"is_secret": ins.excluded.is_secret,
|
||||
"updated_at": ins.excluded.updated_at,
|
||||
},
|
||||
)
|
||||
else:
|
||||
raise RuntimeError(f"unsupported SQLAlchemy dialect for app_setting: {dialect!r}")
|
||||
conn.execute(stmt)
|
||||
|
||||
def upsert_plain(self, *, key: str, value: str, updated_at: str) -> None:
|
||||
with self._engine.begin() as conn:
|
||||
self._upsert(conn, key=key, value=value, is_secret=0, updated_at=updated_at)
|
||||
|
||||
def upsert_secret(self, *, key: str, encoded_value: str, updated_at: str) -> None:
|
||||
with self._engine.begin() as conn:
|
||||
self._upsert(conn, key=key, value=encoded_value, is_secret=1, updated_at=updated_at)
|
||||
|
||||
def fetch_row(self, *, key: str) -> tuple[str, int] | None:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
select(app_setting.c.value, app_setting.c.is_secret).where(app_setting.c.key == key)
|
||||
).one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
return (str(row[0]), int(row[1]))
|
||||
|
||||
def delete_key(self, *, key: str) -> None:
|
||||
with self._engine.begin() as conn:
|
||||
conn.execute(delete(app_setting).where(app_setting.c.key == key))
|
||||
|
||||
def migrate_b64_secrets(
|
||||
self,
|
||||
*,
|
||||
ts: str,
|
||||
decode_secret: Callable[[str], str],
|
||||
encode_secret: Callable[[str], str],
|
||||
predicate_new_encoding: Callable[[str], bool],
|
||||
) -> int:
|
||||
"""Re-encode legacy ``b64:`` rows; idempotent. Returns rows updated."""
|
||||
migrated = 0
|
||||
with self._engine.begin() as conn:
|
||||
rows = conn.execute(
|
||||
select(app_setting.c.key, app_setting.c.value).where(
|
||||
app_setting.c.is_secret == 1,
|
||||
app_setting.c.value.like("b64:%"),
|
||||
)
|
||||
).all()
|
||||
for k, v in rows:
|
||||
key = str(k or "")
|
||||
val = str(v or "")
|
||||
if not key:
|
||||
continue
|
||||
try:
|
||||
plain = decode_secret(val)
|
||||
except Exception:
|
||||
continue
|
||||
enc = encode_secret(plain)
|
||||
if enc != val and predicate_new_encoding(enc):
|
||||
conn.execute(
|
||||
update(app_setting)
|
||||
.where(
|
||||
app_setting.c.key == key,
|
||||
app_setting.c.is_secret == 1,
|
||||
)
|
||||
.values(value=enc, updated_at=ts)
|
||||
)
|
||||
migrated += 1
|
||||
return migrated
|
||||
|
||||
def count_legacy_b64_secrets(self) -> int:
|
||||
with self._engine.connect() as conn:
|
||||
n = conn.execute(
|
||||
select(func.count()).select_from(app_setting).where(
|
||||
app_setting.c.is_secret == 1,
|
||||
app_setting.c.value.like("b64:%"),
|
||||
)
|
||||
).scalar_one()
|
||||
return int(n or 0)
|
||||
|
||||
|
||||
__all__ = ["AppSettingsSaRepository"]
|
||||
296
svc/persistence/sa_repos/app_users.py
Normal file
296
svc/persistence/sa_repos/app_users.py
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
"""app_user CRUD slices via SQLAlchemy Core."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping
|
||||
|
||||
from sqlalchemy import case, delete, exists, func, insert, literal, or_, select, union, update
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from svc.persistence.db.tables import app_user, channel_identity, channel_identity_v2
|
||||
|
||||
|
||||
def _user_row_to_public_dict(r: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"id": r["id"],
|
||||
"tenant_id": r["tenant_id"],
|
||||
"username": r["username"],
|
||||
"display_name": r["display_name"],
|
||||
"role": r["role"],
|
||||
"is_active": bool(int(r["is_active"] or 0)),
|
||||
"created_at": r["created_at"],
|
||||
"password_hash": r["password_hash"],
|
||||
"avatar_attachment_id": str(r["avatar_attachment_id"] or "").strip() or None,
|
||||
}
|
||||
|
||||
|
||||
class AppUsersSaRepository:
|
||||
__slots__ = ("_engine",)
|
||||
|
||||
def __init__(self, engine: Engine) -> None:
|
||||
self._engine = engine
|
||||
|
||||
def count_by_tenant_username(self, *, tenant_id: str, username: str) -> int:
|
||||
tid, un = str(tenant_id), str(username)
|
||||
with self._engine.connect() as conn:
|
||||
n = conn.execute(
|
||||
select(func.count())
|
||||
.select_from(app_user)
|
||||
.where(app_user.c.tenant_id == tid, app_user.c.username == un)
|
||||
).scalar_one()
|
||||
return int(n or 0)
|
||||
|
||||
def insert_user(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
tenant_id: str,
|
||||
username: str,
|
||||
display_name: str,
|
||||
role: str,
|
||||
password_hash: str,
|
||||
is_active: int,
|
||||
created_at: str,
|
||||
avatar_attachment_id: str | None = None,
|
||||
) -> None:
|
||||
with self._engine.begin() as conn:
|
||||
conn.execute(
|
||||
insert(app_user).values(
|
||||
id=str(user_id),
|
||||
tenant_id=str(tenant_id),
|
||||
username=str(username),
|
||||
display_name=str(display_name),
|
||||
role=str(role),
|
||||
password_hash=str(password_hash or ""),
|
||||
is_active=int(is_active),
|
||||
created_at=str(created_at),
|
||||
avatar_attachment_id=avatar_attachment_id,
|
||||
)
|
||||
)
|
||||
|
||||
def _select_user_columns(self):
|
||||
return select(
|
||||
app_user.c.id,
|
||||
app_user.c.tenant_id,
|
||||
app_user.c.username,
|
||||
app_user.c.display_name,
|
||||
app_user.c.role,
|
||||
func.coalesce(app_user.c.is_active, literal(1)).label("is_active"),
|
||||
app_user.c.created_at,
|
||||
func.coalesce(app_user.c.password_hash, literal("")).label("password_hash"),
|
||||
func.coalesce(app_user.c.avatar_attachment_id, literal("")).label("avatar_attachment_id"),
|
||||
)
|
||||
|
||||
def fetch_by_tenant_and_id(self, *, tenant_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
tid, uid = str(tenant_id), str(user_id)
|
||||
stmt = self._select_user_columns().where(app_user.c.tenant_id == tid, app_user.c.id == uid).limit(1)
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(stmt).mappings().first()
|
||||
return _user_row_to_public_dict(row) if row else None
|
||||
|
||||
def fetch_by_tenant_and_username(self, *, tenant_id: str, username: str) -> dict[str, Any] | None:
|
||||
tid, un = str(tenant_id), str(username)
|
||||
stmt = (
|
||||
self._select_user_columns()
|
||||
.where(app_user.c.tenant_id == tid, app_user.c.username == un)
|
||||
.limit(1)
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(stmt).mappings().first()
|
||||
return _user_row_to_public_dict(row) if row else None
|
||||
|
||||
def fetch_first_by_username_global(self, *, username: str) -> dict[str, Any] | None:
|
||||
un = str(username)
|
||||
stmt = (
|
||||
self._select_user_columns()
|
||||
.where(app_user.c.username == un)
|
||||
.order_by(app_user.c.created_at.asc())
|
||||
.limit(1)
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(stmt).mappings().first()
|
||||
return _user_row_to_public_dict(row) if row else None
|
||||
|
||||
def list_users_for_tenant(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
limit: int,
|
||||
offset: int,
|
||||
q: str | None,
|
||||
include_inactive: bool,
|
||||
) -> list[dict[str, Any]]:
|
||||
tid = str(tenant_id)
|
||||
lim = max(1, int(limit))
|
||||
off = max(0, int(offset))
|
||||
|
||||
has_password = case(
|
||||
(func.trim(func.coalesce(app_user.c.password_hash, literal(""))) != literal(""), 1),
|
||||
else_=0,
|
||||
).label("has_password")
|
||||
|
||||
wecom_linked = or_(
|
||||
exists(
|
||||
select(literal(1))
|
||||
.select_from(channel_identity_v2)
|
||||
.where(
|
||||
channel_identity_v2.c.tenant_id == app_user.c.tenant_id,
|
||||
channel_identity_v2.c.user_id == app_user.c.id,
|
||||
channel_identity_v2.c.channel == literal("wecom"),
|
||||
)
|
||||
),
|
||||
exists(
|
||||
select(literal(1))
|
||||
.select_from(channel_identity)
|
||||
.where(
|
||||
channel_identity.c.tenant_id == app_user.c.tenant_id,
|
||||
channel_identity.c.user_id == app_user.c.id,
|
||||
channel_identity.c.channel == literal("wecom"),
|
||||
)
|
||||
),
|
||||
).label("wecom_linked")
|
||||
|
||||
channel_linked = or_(
|
||||
exists(
|
||||
select(literal(1))
|
||||
.select_from(channel_identity_v2)
|
||||
.where(
|
||||
channel_identity_v2.c.tenant_id == app_user.c.tenant_id,
|
||||
channel_identity_v2.c.user_id == app_user.c.id,
|
||||
)
|
||||
),
|
||||
exists(
|
||||
select(literal(1))
|
||||
.select_from(channel_identity)
|
||||
.where(
|
||||
channel_identity.c.tenant_id == app_user.c.tenant_id,
|
||||
channel_identity.c.user_id == app_user.c.id,
|
||||
)
|
||||
),
|
||||
).label("channel_linked")
|
||||
|
||||
eid_ci = func.trim(func.coalesce(channel_identity.c.external_user_id, literal("")))
|
||||
sq1 = (
|
||||
select(eid_ci.label("eid"))
|
||||
.where(
|
||||
channel_identity.c.tenant_id == app_user.c.tenant_id,
|
||||
channel_identity.c.user_id == app_user.c.id,
|
||||
channel_identity.c.channel == literal("wecom"),
|
||||
eid_ci != literal(""),
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
eid_v2 = func.trim(func.coalesce(channel_identity_v2.c.external_user_id, literal("")))
|
||||
sq2 = (
|
||||
select(eid_v2.label("eid"))
|
||||
.where(
|
||||
channel_identity_v2.c.tenant_id == app_user.c.tenant_id,
|
||||
channel_identity_v2.c.user_id == app_user.c.id,
|
||||
channel_identity_v2.c.channel == literal("wecom"),
|
||||
eid_v2 != literal(""),
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
u_sub = union(sq1, sq2).subquery()
|
||||
if self._engine.dialect.name == "postgresql":
|
||||
wecom_ids_expr = select(func.string_agg(u_sub.c.eid, literal(", "))).scalar_subquery()
|
||||
else:
|
||||
wecom_ids_expr = select(func.group_concat(u_sub.c.eid, literal(", "))).scalar_subquery()
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
app_user.c.id,
|
||||
app_user.c.tenant_id,
|
||||
app_user.c.username,
|
||||
app_user.c.display_name,
|
||||
app_user.c.role,
|
||||
func.coalesce(app_user.c.is_active, literal(1)).label("is_active"),
|
||||
app_user.c.created_at,
|
||||
has_password,
|
||||
wecom_linked,
|
||||
channel_linked,
|
||||
wecom_ids_expr.label("wecom_external_user_ids"),
|
||||
)
|
||||
.where(app_user.c.tenant_id == tid)
|
||||
)
|
||||
token = str(q or "").strip()
|
||||
if token:
|
||||
key = f"%{token.lower()}%"
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
func.lower(app_user.c.display_name).like(key),
|
||||
func.lower(func.coalesce(app_user.c.username, literal(""))).like(key),
|
||||
app_user.c.id.like(f"%{token[:32]}%"),
|
||||
)
|
||||
)
|
||||
if not include_inactive:
|
||||
stmt = stmt.where(func.coalesce(app_user.c.is_active, literal(1)) == literal(1))
|
||||
stmt = stmt.order_by(app_user.c.created_at.desc()).limit(lim).offset(off)
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(stmt).mappings().all()
|
||||
out: list[dict[str, Any]] = []
|
||||
for r in rows:
|
||||
has_pw = bool(int(r["has_password"] or 0))
|
||||
uname = str(r["username"] or "")
|
||||
can_chat = bool(has_pw)
|
||||
wl = r["wecom_linked"]
|
||||
cl = r["channel_linked"]
|
||||
out.append(
|
||||
{
|
||||
"id": r["id"],
|
||||
"tenant_id": r["tenant_id"],
|
||||
"username": r["username"],
|
||||
"display_name": r["display_name"],
|
||||
"role": r["role"],
|
||||
"is_active": bool(int(r["is_active"] or 0)),
|
||||
"created_at": r["created_at"],
|
||||
"has_password": has_pw,
|
||||
"wecom_linked": bool(int(wl or 0)),
|
||||
"channel_linked": bool(int(cl or 0)),
|
||||
"can_chat_login": can_chat,
|
||||
"wecom_external_user_ids": str(r["wecom_external_user_ids"] or "").strip(),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
def update_user_account(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
user_id: str,
|
||||
display_name: str | None = None,
|
||||
role: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
password_hash: str | None = None,
|
||||
avatar_attachment_id: str | None = None,
|
||||
) -> bool:
|
||||
tid, uid = str(tenant_id), str(user_id)
|
||||
vals: dict[str, Any] = {}
|
||||
if display_name is not None:
|
||||
vals["display_name"] = str(display_name).strip() or "User"
|
||||
if role is not None:
|
||||
vals["role"] = str(role).strip() or "member"
|
||||
if is_active is not None:
|
||||
vals["is_active"] = 1 if is_active else 0
|
||||
if password_hash is not None:
|
||||
vals["password_hash"] = str(password_hash)
|
||||
if avatar_attachment_id is not None:
|
||||
aid = str(avatar_attachment_id).strip()
|
||||
vals["avatar_attachment_id"] = aid if aid else None
|
||||
if not vals:
|
||||
return False
|
||||
with self._engine.begin() as conn:
|
||||
res = conn.execute(
|
||||
update(app_user).where(app_user.c.tenant_id == tid, app_user.c.id == uid).values(**vals)
|
||||
)
|
||||
return bool(int(res.rowcount or 0) > 0)
|
||||
|
||||
def delete_user_account(self, *, tenant_id: str, user_id: str) -> int:
|
||||
tid, uid = str(tenant_id), str(user_id)
|
||||
with self._engine.begin() as conn:
|
||||
res = conn.execute(delete(app_user).where(app_user.c.tenant_id == tid, app_user.c.id == uid))
|
||||
return int(res.rowcount or 0)
|
||||
|
||||
|
||||
__all__ = ["AppUsersSaRepository"]
|
||||
102
svc/persistence/sa_repos/auth_sessions.py
Normal file
102
svc/persistence/sa_repos/auth_sessions.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"""auth_session access via SQLAlchemy Core (SQLite + PostgreSQL)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import insert, select, update
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from svc.persistence.db.tables import auth_session
|
||||
|
||||
|
||||
class AuthSessionsSaRepository:
|
||||
"""Phase-2 SA migration: admin login session rows."""
|
||||
|
||||
__slots__ = ("_engine",)
|
||||
|
||||
def __init__(self, engine: Engine) -> None:
|
||||
self._engine = engine
|
||||
|
||||
def insert_session(
|
||||
self,
|
||||
*,
|
||||
session_token_hash: str,
|
||||
tenant_id: str,
|
||||
user_id: str,
|
||||
role: str,
|
||||
created_at: str,
|
||||
expires_at: str,
|
||||
last_seen_at: str,
|
||||
) -> None:
|
||||
with self._engine.begin() as conn:
|
||||
conn.execute(
|
||||
insert(auth_session).values(
|
||||
session_token_hash=str(session_token_hash),
|
||||
tenant_id=str(tenant_id),
|
||||
user_id=str(user_id),
|
||||
role=str(role),
|
||||
created_at=str(created_at),
|
||||
expires_at=str(expires_at),
|
||||
last_seen_at=str(last_seen_at),
|
||||
revoked_at=None,
|
||||
)
|
||||
)
|
||||
|
||||
def revoke_one(self, *, session_token_hash: str, revoked_at: str) -> int:
|
||||
with self._engine.begin() as conn:
|
||||
res = conn.execute(
|
||||
update(auth_session)
|
||||
.where(
|
||||
auth_session.c.session_token_hash == str(session_token_hash),
|
||||
auth_session.c.revoked_at.is_(None),
|
||||
)
|
||||
.values(revoked_at=str(revoked_at))
|
||||
)
|
||||
n = res.rowcount
|
||||
if n is None or n < 0:
|
||||
return 0
|
||||
return int(n)
|
||||
|
||||
def revoke_all_active(self, *, revoked_at: str) -> int:
|
||||
with self._engine.begin() as conn:
|
||||
res = conn.execute(
|
||||
update(auth_session)
|
||||
.where(auth_session.c.revoked_at.is_(None))
|
||||
.values(revoked_at=str(revoked_at))
|
||||
)
|
||||
n = res.rowcount
|
||||
if n is None or n < 0:
|
||||
return 0
|
||||
return int(n)
|
||||
|
||||
def fetch_by_hash(self, *, session_token_hash: str) -> dict[str, Any] | None:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
select(
|
||||
auth_session.c.session_token_hash,
|
||||
auth_session.c.tenant_id,
|
||||
auth_session.c.user_id,
|
||||
auth_session.c.role,
|
||||
auth_session.c.created_at,
|
||||
auth_session.c.expires_at,
|
||||
auth_session.c.last_seen_at,
|
||||
auth_session.c.revoked_at,
|
||||
)
|
||||
.where(auth_session.c.session_token_hash == str(session_token_hash))
|
||||
.limit(1)
|
||||
).mappings().first()
|
||||
if row is None:
|
||||
return None
|
||||
return dict(row)
|
||||
|
||||
def touch(self, *, session_token_hash: str, last_seen_at: str) -> None:
|
||||
with self._engine.begin() as conn:
|
||||
conn.execute(
|
||||
update(auth_session)
|
||||
.where(auth_session.c.session_token_hash == str(session_token_hash))
|
||||
.values(last_seen_at=str(last_seen_at))
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["AuthSessionsSaRepository"]
|
||||
481
svc/persistence/sa_repos/chat_messages.py
Normal file
481
svc/persistence/sa_repos/chat_messages.py
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
"""chat_message access via SQLAlchemy Core."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Mapping
|
||||
|
||||
from sqlalchemy import delete, exists, func, insert, literal, select, update
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from svc.persistence.db.tables import chat_message, chat_session
|
||||
from svc.persistence.sqlite_store import (
|
||||
ChatMessage,
|
||||
SessionMessagesMeta,
|
||||
_tool_row_assistant_message_id,
|
||||
_trim_messages_start_index,
|
||||
utc_now_iso,
|
||||
)
|
||||
|
||||
|
||||
def _sql_text_required(v: Any) -> str:
|
||||
if v is None:
|
||||
return ""
|
||||
if isinstance(v, (bytes, bytearray, memoryview)):
|
||||
try:
|
||||
return bytes(v).decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
return ""
|
||||
return str(v)
|
||||
|
||||
|
||||
def _sql_text_optional_plain(v: Any) -> str | None:
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, (bytes, bytearray, memoryview)):
|
||||
try:
|
||||
s = bytes(v).decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
return None
|
||||
s = s.strip()
|
||||
return s if s else None
|
||||
s = str(v).strip()
|
||||
return s if s else None
|
||||
|
||||
|
||||
def _sql_text_optional_jsonish(v: Any) -> str | None:
|
||||
"""Normalize TEXT/JSON columns across SQLite + PostgreSQL drivers (bytes/memoryview/dict)."""
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, (bytes, bytearray, memoryview)):
|
||||
try:
|
||||
s = bytes(v).decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
return None
|
||||
return s if s.strip() else None
|
||||
if isinstance(v, (dict, list)):
|
||||
return json.dumps(v, ensure_ascii=False, default=str)
|
||||
s = str(v).strip()
|
||||
return s if s else None
|
||||
|
||||
|
||||
def _row_to_chat_message(r: Mapping[str, Any]) -> ChatMessage:
|
||||
return ChatMessage(
|
||||
id=int(r["id"]),
|
||||
session_id=str(r["session_id"]),
|
||||
role=str(r["role"]),
|
||||
content=_sql_text_required(r.get("content")),
|
||||
tool_calls=_sql_text_optional_jsonish(r.get("tool_calls")),
|
||||
attachments=_sql_text_optional_jsonish(r.get("attachments")),
|
||||
turn_uuid=_sql_text_optional_plain(r.get("turn_uuid")),
|
||||
event_type=_sql_text_optional_plain(r.get("event_type")),
|
||||
event_payload=_sql_text_optional_jsonish(r.get("event_payload")),
|
||||
timestamp=str(r["timestamp"]),
|
||||
)
|
||||
|
||||
|
||||
class ChatMessagesSaRepository:
|
||||
"""Phase-4 SA migration: chat_message CRUD + session last_message_at touch."""
|
||||
|
||||
__slots__ = ("_engine",)
|
||||
|
||||
def __init__(self, engine: Engine) -> None:
|
||||
self._engine = engine
|
||||
|
||||
def insert_message_and_touch_session(
|
||||
self,
|
||||
*,
|
||||
session_id: str,
|
||||
role: str,
|
||||
content: str,
|
||||
tool_calls: str | None,
|
||||
attachments: str | None,
|
||||
turn_uuid: str | None,
|
||||
event_type: str | None,
|
||||
event_payload: str | None,
|
||||
timestamp: str,
|
||||
) -> int:
|
||||
sid = str(session_id if session_id is not None else "")
|
||||
with self._engine.begin() as conn:
|
||||
stmt = (
|
||||
insert(chat_message)
|
||||
.values(
|
||||
session_id=sid,
|
||||
role=str(role),
|
||||
content=str(content),
|
||||
tool_calls=tool_calls,
|
||||
attachments=attachments,
|
||||
turn_uuid=turn_uuid,
|
||||
event_type=event_type,
|
||||
event_payload=event_payload,
|
||||
timestamp=str(timestamp),
|
||||
)
|
||||
.returning(chat_message.c.id)
|
||||
)
|
||||
msg_id = int(conn.execute(stmt).scalar_one())
|
||||
conn.execute(
|
||||
update(chat_session)
|
||||
.where(chat_session.c.id == sid)
|
||||
.values(last_message_at=str(timestamp))
|
||||
)
|
||||
return msg_id
|
||||
|
||||
def delete_message_and_refresh_session(self, *, session_id: str, message_id: int) -> bool:
|
||||
sid = str(session_id or "").strip()
|
||||
mid = int(message_id or 0)
|
||||
if not sid or mid <= 0:
|
||||
return False
|
||||
with self._engine.begin() as conn:
|
||||
res = conn.execute(
|
||||
delete(chat_message).where(
|
||||
chat_message.c.session_id == sid,
|
||||
chat_message.c.id == mid,
|
||||
)
|
||||
)
|
||||
if int(res.rowcount or 0) <= 0:
|
||||
return False
|
||||
last_ts = conn.execute(
|
||||
select(func.max(chat_message.c.timestamp)).where(chat_message.c.session_id == sid)
|
||||
).scalar_one_or_none()
|
||||
last_s = str(last_ts or "").strip() or None
|
||||
conn.execute(
|
||||
update(chat_session)
|
||||
.where(chat_session.c.id == sid)
|
||||
.values(last_message_at=last_s)
|
||||
)
|
||||
return True
|
||||
|
||||
def update_message_content(
|
||||
self,
|
||||
*,
|
||||
session_id: str,
|
||||
message_id: int,
|
||||
content: str,
|
||||
event_payload_text: str | None,
|
||||
) -> bool:
|
||||
sid = str(session_id or "").strip()
|
||||
mid = int(message_id or 0)
|
||||
if not sid or mid <= 0:
|
||||
return False
|
||||
with self._engine.begin() as conn:
|
||||
res = conn.execute(
|
||||
update(chat_message)
|
||||
.where(chat_message.c.session_id == sid, chat_message.c.id == mid)
|
||||
.values(
|
||||
content=str(content or ""),
|
||||
event_payload=func.coalesce(literal(event_payload_text), chat_message.c.event_payload),
|
||||
)
|
||||
)
|
||||
return int(res.rowcount or 0) > 0
|
||||
|
||||
def get_messages_recent_asc(self, *, session_id: str, limit: int) -> list[ChatMessage]:
|
||||
if limit <= 0:
|
||||
return []
|
||||
sid = str(session_id or "").strip()
|
||||
if not sid:
|
||||
return []
|
||||
lim = max(1, min(int(limit), 2000))
|
||||
ids_sq = (
|
||||
select(chat_message.c.id)
|
||||
.where(chat_message.c.session_id == sid)
|
||||
.order_by(chat_message.c.id.desc())
|
||||
.limit(lim)
|
||||
.scalar_subquery()
|
||||
)
|
||||
stmt = (
|
||||
select(
|
||||
chat_message.c.id,
|
||||
chat_message.c.session_id,
|
||||
chat_message.c.role,
|
||||
chat_message.c.content,
|
||||
chat_message.c.tool_calls,
|
||||
chat_message.c.attachments,
|
||||
chat_message.c.turn_uuid,
|
||||
chat_message.c.event_type,
|
||||
chat_message.c.event_payload,
|
||||
chat_message.c.timestamp,
|
||||
)
|
||||
.where(chat_message.c.session_id == sid, chat_message.c.id.in_(ids_sq))
|
||||
.order_by(chat_message.c.id.asc())
|
||||
)
|
||||
prepended: set[int] = set()
|
||||
with self._engine.connect() as conn:
|
||||
rows: list[dict[str, Any]] = [dict(r) for r in conn.execute(stmt).mappings().all()]
|
||||
while rows:
|
||||
first = rows[0]
|
||||
if str(first.get("role") or "") != "tool":
|
||||
break
|
||||
aid = _tool_row_assistant_message_id(first.get("tool_calls"))
|
||||
if aid is None:
|
||||
break
|
||||
first_id = int(first["id"])
|
||||
if aid >= first_id:
|
||||
break
|
||||
if any(int(r["id"]) == int(aid) for r in rows):
|
||||
break
|
||||
if int(aid) in prepended:
|
||||
break
|
||||
arow = conn.execute(
|
||||
select(
|
||||
chat_message.c.id,
|
||||
chat_message.c.session_id,
|
||||
chat_message.c.role,
|
||||
chat_message.c.content,
|
||||
chat_message.c.tool_calls,
|
||||
chat_message.c.attachments,
|
||||
chat_message.c.turn_uuid,
|
||||
chat_message.c.event_type,
|
||||
chat_message.c.event_payload,
|
||||
chat_message.c.timestamp,
|
||||
)
|
||||
.where(chat_message.c.session_id == sid, chat_message.c.id == int(aid))
|
||||
.limit(1)
|
||||
).mappings().first()
|
||||
if not arow:
|
||||
break
|
||||
prepended.add(int(aid))
|
||||
rows.insert(0, dict(arow))
|
||||
return [_row_to_chat_message(r) for r in rows]
|
||||
|
||||
def get_messages_after_id(
|
||||
self, *, session_id: str, after_id: int, limit: int
|
||||
) -> list[ChatMessage]:
|
||||
sid = str(session_id or "").strip()
|
||||
if not sid:
|
||||
return []
|
||||
aid = int(after_id or 0)
|
||||
lim = max(1, min(int(limit), 2000))
|
||||
stmt = (
|
||||
select(
|
||||
chat_message.c.id,
|
||||
chat_message.c.session_id,
|
||||
chat_message.c.role,
|
||||
chat_message.c.content,
|
||||
chat_message.c.tool_calls,
|
||||
chat_message.c.attachments,
|
||||
chat_message.c.turn_uuid,
|
||||
chat_message.c.event_type,
|
||||
chat_message.c.event_payload,
|
||||
chat_message.c.timestamp,
|
||||
)
|
||||
.where(chat_message.c.session_id == sid, chat_message.c.id > aid)
|
||||
.order_by(chat_message.c.id.asc())
|
||||
.limit(lim)
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(stmt).mappings().all()
|
||||
return [_row_to_chat_message(dict(r)) for r in rows]
|
||||
|
||||
def count_messages(self, *, session_id: str) -> int:
|
||||
key = str(session_id if session_id is not None else "")
|
||||
with self._engine.connect() as conn:
|
||||
n = conn.execute(
|
||||
select(func.count()).select_from(chat_message).where(chat_message.c.session_id == key)
|
||||
).scalar_one()
|
||||
return int(n or 0)
|
||||
|
||||
def session_messages_meta(self, *, session_id: str) -> SessionMessagesMeta:
|
||||
key = str(session_id if session_id is not None else "")
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
select(
|
||||
func.count().label("c"),
|
||||
func.max(chat_message.c.id).label("last_id"),
|
||||
func.max(chat_message.c.timestamp).label("last_ts"),
|
||||
)
|
||||
.where(chat_message.c.session_id == key)
|
||||
).mappings().first()
|
||||
return SessionMessagesMeta(
|
||||
session_id=session_id,
|
||||
message_count=int(row["c"] or 0) if row else 0,
|
||||
last_message_id=int(row["last_id"]) if row and row.get("last_id") is not None else None,
|
||||
last_message_at=str(row["last_ts"]) if row and row.get("last_ts") is not None else None,
|
||||
)
|
||||
|
||||
def last_message_id(self, *, session_id: str) -> int | None:
|
||||
key = str(session_id if session_id is not None else "")
|
||||
with self._engine.connect() as conn:
|
||||
m = conn.execute(
|
||||
select(func.max(chat_message.c.id)).where(chat_message.c.session_id == key)
|
||||
).scalar_one_or_none()
|
||||
if m is None:
|
||||
return None
|
||||
return int(m)
|
||||
|
||||
def list_messages_in_time_window(
|
||||
self, *, session_id: str, start_ts: str, end_ts: str, limit: int
|
||||
) -> list[dict[str, Any]]:
|
||||
sid = str(session_id or "").strip()
|
||||
start = str(start_ts or "").strip()
|
||||
end = str(end_ts or "").strip()
|
||||
if not sid or not start or not end:
|
||||
return []
|
||||
lim = max(1, min(int(limit), 2000))
|
||||
stmt = (
|
||||
select(
|
||||
chat_message.c.id,
|
||||
chat_message.c.session_id,
|
||||
chat_message.c.role,
|
||||
chat_message.c.content,
|
||||
chat_message.c.tool_calls,
|
||||
chat_message.c.attachments,
|
||||
chat_message.c.turn_uuid,
|
||||
chat_message.c.event_type,
|
||||
chat_message.c.event_payload,
|
||||
chat_message.c.timestamp,
|
||||
)
|
||||
.where(
|
||||
chat_message.c.session_id == sid,
|
||||
chat_message.c.timestamp >= start,
|
||||
chat_message.c.timestamp <= end,
|
||||
)
|
||||
.order_by(chat_message.c.id.asc())
|
||||
.limit(lim)
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(stmt).mappings().all()
|
||||
out: list[dict[str, Any]] = []
|
||||
for r in rows:
|
||||
out.append(
|
||||
{
|
||||
"id": int(r["id"] or 0),
|
||||
"session_id": str(r["session_id"] or ""),
|
||||
"role": str(r["role"] or ""),
|
||||
"content": str(r["content"] or ""),
|
||||
"tool_calls": r["tool_calls"],
|
||||
"attachments": r["attachments"],
|
||||
"turn_uuid": str(r["turn_uuid"] or ""),
|
||||
"event_type": str(r["event_type"] or ""),
|
||||
"event_payload": r["event_payload"],
|
||||
"timestamp": str(r["timestamp"] or ""),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
def delete_messages_where_session_missing(self) -> int:
|
||||
"""Delete ``chat_message`` rows whose ``session_id`` is not in ``chat_session`` (housekeeping)."""
|
||||
sess_exists = exists(
|
||||
select(1).select_from(chat_session).where(chat_session.c.id == chat_message.c.session_id)
|
||||
)
|
||||
with self._engine.begin() as conn:
|
||||
res = conn.execute(delete(chat_message).where(~sess_exists))
|
||||
return int(res.rowcount or 0)
|
||||
|
||||
def fork_assert_anchor(self, *, source_session_id: str, up_to_message_id: int) -> None:
|
||||
"""Raise ``ValueError`` unless ``up_to_message_id`` exists in ``source_session_id``."""
|
||||
src = str(source_session_id if source_session_id is not None else "")
|
||||
cap = int(up_to_message_id or 0)
|
||||
if not src or cap <= 0:
|
||||
raise ValueError("message not in session")
|
||||
with self._engine.connect() as conn:
|
||||
chk = conn.execute(
|
||||
select(chat_message.c.id)
|
||||
.where(chat_message.c.session_id == src, chat_message.c.id == cap)
|
||||
.limit(1)
|
||||
).first()
|
||||
if not chk:
|
||||
raise ValueError("message not in session")
|
||||
|
||||
def fork_copy_messages_to_session(
|
||||
self,
|
||||
*,
|
||||
source_session_id: str,
|
||||
up_to_message_id: int,
|
||||
new_session_id: str,
|
||||
) -> None:
|
||||
"""Copy messages with ``id <= up_to_message_id`` into ``new_session_id``; remap tool assistant ids."""
|
||||
src = str(source_session_id if source_session_id is not None else "")
|
||||
new_sid = str(new_session_id if new_session_id is not None else "")
|
||||
cap = int(up_to_message_id or 0)
|
||||
if not src or not new_sid or cap <= 0:
|
||||
raise ValueError("message not in session")
|
||||
with self._engine.begin() as conn:
|
||||
rows_list = list(
|
||||
conn.execute(
|
||||
select(
|
||||
chat_message.c.id,
|
||||
chat_message.c.role,
|
||||
chat_message.c.content,
|
||||
chat_message.c.tool_calls,
|
||||
chat_message.c.attachments,
|
||||
chat_message.c.turn_uuid,
|
||||
chat_message.c.event_type,
|
||||
chat_message.c.event_payload,
|
||||
chat_message.c.timestamp,
|
||||
)
|
||||
.where(chat_message.c.session_id == src, chat_message.c.id <= cap)
|
||||
.order_by(chat_message.c.id.asc())
|
||||
).mappings().all()
|
||||
)
|
||||
if not rows_list:
|
||||
raise ValueError("message not in session")
|
||||
id_map: dict[int, int] = {}
|
||||
for r in rows_list:
|
||||
old_id = int(r["id"])
|
||||
role = str(r["role"])
|
||||
tool_calls_text = r["tool_calls"]
|
||||
if role == "tool" and tool_calls_text:
|
||||
try:
|
||||
meta = json.loads(str(tool_calls_text))
|
||||
if isinstance(meta, dict):
|
||||
aid = meta.get("assistant_message_id")
|
||||
if aid is not None:
|
||||
new_aid = id_map.get(int(aid))
|
||||
if new_aid is not None:
|
||||
meta = {**meta, "assistant_message_id": new_aid}
|
||||
tool_calls_text = json.dumps(meta, ensure_ascii=False)
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
pass
|
||||
stmt = (
|
||||
insert(chat_message)
|
||||
.values(
|
||||
session_id=new_sid,
|
||||
role=role,
|
||||
content=r["content"],
|
||||
tool_calls=tool_calls_text,
|
||||
attachments=r["attachments"],
|
||||
turn_uuid=r["turn_uuid"],
|
||||
event_type=r["event_type"],
|
||||
event_payload=r["event_payload"],
|
||||
timestamp=r["timestamp"],
|
||||
)
|
||||
.returning(chat_message.c.id)
|
||||
)
|
||||
new_id = int(conn.execute(stmt).scalar_one())
|
||||
id_map[old_id] = new_id
|
||||
last_ts = rows_list[-1]["timestamp"] if rows_list else utc_now_iso()
|
||||
conn.execute(
|
||||
update(chat_session)
|
||||
.where(chat_session.c.id == new_sid)
|
||||
.values(last_message_at=last_ts)
|
||||
)
|
||||
|
||||
def trim_messages_keep_last(self, *, session_id: str, keep_last: int) -> None:
|
||||
"""Delete older messages so at least ``keep_last`` newest rows remain (tool/assistant boundary aware)."""
|
||||
key = str(session_id if session_id is not None else "")
|
||||
kl = int(keep_last)
|
||||
if not key or kl <= 0:
|
||||
return
|
||||
with self._engine.connect() as conn:
|
||||
rows_list = [
|
||||
dict(r)
|
||||
for r in conn.execute(
|
||||
select(chat_message.c.id, chat_message.c.role, chat_message.c.tool_calls)
|
||||
.where(chat_message.c.session_id == key)
|
||||
.order_by(chat_message.c.id.asc())
|
||||
).mappings().all()
|
||||
]
|
||||
start = _trim_messages_start_index(rows_list, kl)
|
||||
if start is None:
|
||||
return
|
||||
min_keep_id = int(rows_list[start]["id"])
|
||||
with self._engine.begin() as conn:
|
||||
conn.execute(
|
||||
delete(chat_message).where(
|
||||
chat_message.c.session_id == key,
|
||||
chat_message.c.id < min_keep_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["ChatMessagesSaRepository"]
|
||||
424
svc/persistence/sa_repos/chat_sessions.py
Normal file
424
svc/persistence/sa_repos/chat_sessions.py
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
"""chat_session (+ ui_session_owner joins) via SQLAlchemy Core."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping
|
||||
|
||||
from sqlalchemy import and_, delete, distinct, func, insert, literal, or_, select, update
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from svc.persistence.db.tables import app_user, chat_message, chat_session, ui_session_owner
|
||||
from svc.persistence.sqlite_store import ChatSession, SessionsListMeta
|
||||
|
||||
|
||||
def _session_from_row(row: Mapping[str, Any]) -> ChatSession:
|
||||
return ChatSession(
|
||||
id=str(row["id"]),
|
||||
title=str(row["title"]),
|
||||
created_at=str(row["created_at"]),
|
||||
last_message_at=row["last_message_at"],
|
||||
)
|
||||
|
||||
|
||||
def _activity_order() -> tuple[Any, Any]:
|
||||
return (
|
||||
func.coalesce(chat_session.c.last_message_at, chat_session.c.created_at).desc(),
|
||||
chat_session.c.created_at.desc(),
|
||||
)
|
||||
|
||||
|
||||
class ChatSessionsSaRepository:
|
||||
"""Phase-3 SA migration: chat session rows and list queries (messages stay raw SQL for now)."""
|
||||
|
||||
__slots__ = ("_engine",)
|
||||
|
||||
def __init__(self, engine: Engine) -> None:
|
||||
self._engine = engine
|
||||
|
||||
def insert_chat_session(self, *, session_id: str, title: str, created_at: str) -> None:
|
||||
with self._engine.begin() as conn:
|
||||
conn.execute(
|
||||
insert(chat_session).values(
|
||||
id=str(session_id),
|
||||
title=str(title),
|
||||
created_at=str(created_at),
|
||||
last_message_at=None,
|
||||
)
|
||||
)
|
||||
|
||||
def fetch_chat_session_by_id(self, *, session_id: str) -> ChatSession | None:
|
||||
sid = str(session_id or "").strip()
|
||||
if not sid:
|
||||
return None
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
select(
|
||||
chat_session.c.id,
|
||||
chat_session.c.title,
|
||||
chat_session.c.created_at,
|
||||
chat_session.c.last_message_at,
|
||||
)
|
||||
.where(chat_session.c.id == sid)
|
||||
.limit(1)
|
||||
).mappings().first()
|
||||
return _session_from_row(row) if row else None
|
||||
|
||||
def fetch_chat_session_for_user(
|
||||
self, *, session_id: str, tenant_id: str, user_id: str
|
||||
) -> ChatSession | None:
|
||||
sid = str(session_id or "").strip()
|
||||
if not sid:
|
||||
return None
|
||||
tid, uid = str(tenant_id), str(user_id)
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
select(
|
||||
chat_session.c.id,
|
||||
chat_session.c.title,
|
||||
chat_session.c.created_at,
|
||||
chat_session.c.last_message_at,
|
||||
)
|
||||
.select_from(
|
||||
chat_session.join(
|
||||
ui_session_owner,
|
||||
ui_session_owner.c.session_id == chat_session.c.id,
|
||||
)
|
||||
)
|
||||
.where(
|
||||
chat_session.c.id == sid,
|
||||
ui_session_owner.c.tenant_id == tid,
|
||||
ui_session_owner.c.user_id == uid,
|
||||
)
|
||||
.limit(1)
|
||||
).mappings().first()
|
||||
return _session_from_row(row) if row else None
|
||||
|
||||
def list_chat_sessions_global(self, *, limit: int | None, offset: int) -> list[ChatSession]:
|
||||
stmt = select(
|
||||
chat_session.c.id,
|
||||
chat_session.c.title,
|
||||
chat_session.c.created_at,
|
||||
chat_session.c.last_message_at,
|
||||
).order_by(*_activity_order())
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(int(limit)).offset(int(offset))
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(stmt).mappings().all()
|
||||
return [_session_from_row(r) for r in rows]
|
||||
|
||||
def list_chat_sessions_for_user(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
user_id: str,
|
||||
limit: int | None,
|
||||
offset: int,
|
||||
) -> list[ChatSession]:
|
||||
tid, uid = str(tenant_id), str(user_id)
|
||||
stmt = (
|
||||
select(
|
||||
chat_session.c.id,
|
||||
chat_session.c.title,
|
||||
chat_session.c.created_at,
|
||||
chat_session.c.last_message_at,
|
||||
)
|
||||
.select_from(
|
||||
chat_session.join(
|
||||
ui_session_owner,
|
||||
ui_session_owner.c.session_id == chat_session.c.id,
|
||||
)
|
||||
)
|
||||
.where(
|
||||
ui_session_owner.c.tenant_id == tid,
|
||||
ui_session_owner.c.user_id == uid,
|
||||
)
|
||||
.order_by(*_activity_order())
|
||||
)
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(int(limit)).offset(int(offset))
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(stmt).mappings().all()
|
||||
return [_session_from_row(r) for r in rows]
|
||||
|
||||
def list_chat_sessions_for_tenant(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
limit: int | None,
|
||||
offset: int,
|
||||
) -> list[ChatSession]:
|
||||
tid = str(tenant_id)
|
||||
stmt = (
|
||||
select(
|
||||
chat_session.c.id,
|
||||
chat_session.c.title,
|
||||
chat_session.c.created_at,
|
||||
chat_session.c.last_message_at,
|
||||
)
|
||||
.select_from(
|
||||
chat_session.join(
|
||||
ui_session_owner,
|
||||
ui_session_owner.c.session_id == chat_session.c.id,
|
||||
)
|
||||
)
|
||||
.where(ui_session_owner.c.tenant_id == tid)
|
||||
.distinct()
|
||||
.order_by(*_activity_order())
|
||||
)
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(int(limit)).offset(int(offset))
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(stmt).mappings().all()
|
||||
return [_session_from_row(r) for r in rows]
|
||||
|
||||
def count_chat_sessions_global(self) -> int:
|
||||
with self._engine.connect() as conn:
|
||||
n = conn.execute(select(func.count()).select_from(chat_session)).scalar_one()
|
||||
return int(n or 0)
|
||||
|
||||
def sessions_list_meta_global(self) -> SessionsListMeta:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
select(
|
||||
func.count().label("c"),
|
||||
func.max(
|
||||
func.coalesce(chat_session.c.last_message_at, chat_session.c.created_at)
|
||||
).label("latest_activity_at"),
|
||||
).select_from(chat_session)
|
||||
).mappings().first()
|
||||
return SessionsListMeta(
|
||||
session_count=int(row["c"] or 0) if row else 0,
|
||||
latest_activity_at=str(row["latest_activity_at"])
|
||||
if row and row.get("latest_activity_at") is not None
|
||||
else None,
|
||||
)
|
||||
|
||||
def sessions_list_meta_for_user(self, *, tenant_id: str, user_id: str) -> SessionsListMeta:
|
||||
tid, uid = str(tenant_id), str(user_id)
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
select(
|
||||
func.count().label("c"),
|
||||
func.max(
|
||||
func.coalesce(chat_session.c.last_message_at, chat_session.c.created_at)
|
||||
).label("latest_activity_at"),
|
||||
)
|
||||
.select_from(
|
||||
chat_session.join(
|
||||
ui_session_owner,
|
||||
ui_session_owner.c.session_id == chat_session.c.id,
|
||||
)
|
||||
)
|
||||
.where(
|
||||
ui_session_owner.c.tenant_id == tid,
|
||||
ui_session_owner.c.user_id == uid,
|
||||
)
|
||||
).mappings().first()
|
||||
return SessionsListMeta(
|
||||
session_count=int(row["c"] or 0) if row else 0,
|
||||
latest_activity_at=str(row["latest_activity_at"])
|
||||
if row and row.get("latest_activity_at") is not None
|
||||
else None,
|
||||
)
|
||||
|
||||
def sessions_list_meta_for_tenant(self, *, tenant_id: str) -> SessionsListMeta:
|
||||
tid = str(tenant_id)
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
select(
|
||||
func.count(distinct(chat_session.c.id)).label("c"),
|
||||
func.max(
|
||||
func.coalesce(chat_session.c.last_message_at, chat_session.c.created_at)
|
||||
).label("latest_activity_at"),
|
||||
)
|
||||
.select_from(
|
||||
chat_session.join(
|
||||
ui_session_owner,
|
||||
ui_session_owner.c.session_id == chat_session.c.id,
|
||||
)
|
||||
)
|
||||
.where(ui_session_owner.c.tenant_id == tid)
|
||||
).mappings().first()
|
||||
return SessionsListMeta(
|
||||
session_count=int(row["c"] or 0) if row else 0,
|
||||
latest_activity_at=str(row["latest_activity_at"])
|
||||
if row and row.get("latest_activity_at") is not None
|
||||
else None,
|
||||
)
|
||||
|
||||
def fetch_chat_session_in_tenant(self, *, session_id: str, tenant_id: str) -> ChatSession | None:
|
||||
sid, tid = str(session_id or "").strip(), str(tenant_id)
|
||||
if not sid:
|
||||
return None
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
select(
|
||||
chat_session.c.id,
|
||||
chat_session.c.title,
|
||||
chat_session.c.created_at,
|
||||
chat_session.c.last_message_at,
|
||||
)
|
||||
.select_from(
|
||||
chat_session.join(
|
||||
ui_session_owner,
|
||||
ui_session_owner.c.session_id == chat_session.c.id,
|
||||
)
|
||||
)
|
||||
.where(
|
||||
chat_session.c.id == sid,
|
||||
ui_session_owner.c.tenant_id == tid,
|
||||
)
|
||||
.limit(1)
|
||||
).mappings().first()
|
||||
return _session_from_row(row) if row else None
|
||||
|
||||
def list_admin_sessions(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
user_id: str | None,
|
||||
search_lower: str | None,
|
||||
active_only: bool,
|
||||
active_cutoff_iso: str,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> tuple[int, list[dict[str, Any]]]:
|
||||
"""Tenant-scoped admin session browser (parity with raw ``list_admin_sessions`` SQL)."""
|
||||
tid = str(tenant_id or "").strip()
|
||||
if not tid:
|
||||
return 0, []
|
||||
uid = str(user_id or "").strip() or None
|
||||
q_text = str(search_lower or "").strip().lower() or None
|
||||
lim = max(1, min(int(limit), 500))
|
||||
off = max(0, int(offset))
|
||||
|
||||
msg_cnt = (
|
||||
select(func.count())
|
||||
.select_from(chat_message)
|
||||
.where(chat_message.c.session_id == chat_session.c.id)
|
||||
.scalar_subquery()
|
||||
)
|
||||
joins = chat_session.join(
|
||||
ui_session_owner,
|
||||
ui_session_owner.c.session_id == chat_session.c.id,
|
||||
).outerjoin(
|
||||
app_user,
|
||||
(app_user.c.tenant_id == ui_session_owner.c.tenant_id)
|
||||
& (app_user.c.id == ui_session_owner.c.user_id),
|
||||
)
|
||||
conds: list[Any] = [ui_session_owner.c.tenant_id == tid]
|
||||
if uid:
|
||||
conds.append(ui_session_owner.c.user_id == uid)
|
||||
if q_text:
|
||||
like = f"%{q_text}%"
|
||||
conds.append(
|
||||
or_(
|
||||
func.lower(func.coalesce(app_user.c.username, literal(""))).like(like),
|
||||
func.lower(func.coalesce(app_user.c.display_name, literal(""))).like(like),
|
||||
func.lower(func.coalesce(chat_session.c.title, literal(""))).like(like),
|
||||
)
|
||||
)
|
||||
if active_only:
|
||||
conds.append(
|
||||
func.coalesce(chat_session.c.last_message_at, chat_session.c.created_at) >= active_cutoff_iso
|
||||
)
|
||||
wh = and_(*conds)
|
||||
|
||||
data_stmt = (
|
||||
select(
|
||||
chat_session.c.id.label("session_id"),
|
||||
chat_session.c.title,
|
||||
chat_session.c.created_at,
|
||||
chat_session.c.last_message_at,
|
||||
ui_session_owner.c.user_id,
|
||||
func.coalesce(app_user.c.username, literal("")).label("username"),
|
||||
func.coalesce(app_user.c.display_name, literal("")).label("display_name"),
|
||||
msg_cnt.label("message_count"),
|
||||
)
|
||||
.select_from(joins)
|
||||
.where(wh)
|
||||
.order_by(
|
||||
func.coalesce(chat_session.c.last_message_at, chat_session.c.created_at).desc(),
|
||||
chat_session.c.created_at.desc(),
|
||||
)
|
||||
.limit(lim)
|
||||
.offset(off)
|
||||
)
|
||||
cnt_stmt = select(func.count()).select_from(joins).where(wh)
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
total = int(conn.execute(cnt_stmt).scalar_one() or 0)
|
||||
rows = conn.execute(data_stmt).mappings().all()
|
||||
out: list[dict[str, Any]] = []
|
||||
for r in rows:
|
||||
out.append(
|
||||
{
|
||||
"session_id": str(r["session_id"] or ""),
|
||||
"title": str(r["title"] or ""),
|
||||
"created_at": str(r["created_at"] or ""),
|
||||
"last_message_at": str(r["last_message_at"] or ""),
|
||||
"user_id": str(r["user_id"] or ""),
|
||||
"username": str(r["username"] or ""),
|
||||
"display_name": str(r["display_name"] or ""),
|
||||
"message_count": int(r["message_count"] or 0),
|
||||
}
|
||||
)
|
||||
return total, out
|
||||
|
||||
def rename_chat_session(self, *, session_id: str, title: str) -> None:
|
||||
with self._engine.begin() as conn:
|
||||
conn.execute(
|
||||
update(chat_session)
|
||||
.where(chat_session.c.id == str(session_id))
|
||||
.values(title=str(title))
|
||||
)
|
||||
|
||||
def delete_chat_session_by_id(self, *, session_id: str) -> None:
|
||||
sid = str(session_id)
|
||||
with self._engine.begin() as conn:
|
||||
conn.execute(delete(chat_session).where(chat_session.c.id == sid))
|
||||
|
||||
def try_delete_chat_session_for_tenant(self, *, session_id: str, tenant_id: str) -> bool:
|
||||
sid, tid = str(session_id or "").strip(), str(tenant_id)
|
||||
if not sid:
|
||||
return False
|
||||
with self._engine.begin() as conn:
|
||||
chk = conn.execute(
|
||||
select(1)
|
||||
.select_from(ui_session_owner)
|
||||
.where(
|
||||
ui_session_owner.c.session_id == sid,
|
||||
ui_session_owner.c.tenant_id == tid,
|
||||
)
|
||||
.limit(1)
|
||||
).first()
|
||||
if not chk:
|
||||
return False
|
||||
conn.execute(delete(chat_session).where(chat_session.c.id == sid))
|
||||
return True
|
||||
|
||||
def try_delete_chat_session_for_user(
|
||||
self, *, session_id: str, tenant_id: str, user_id: str
|
||||
) -> bool:
|
||||
sid = str(session_id or "").strip()
|
||||
if not sid:
|
||||
return False
|
||||
tid, uid = str(tenant_id), str(user_id)
|
||||
with self._engine.begin() as conn:
|
||||
chk = conn.execute(
|
||||
select(1)
|
||||
.select_from(ui_session_owner)
|
||||
.where(
|
||||
ui_session_owner.c.session_id == sid,
|
||||
ui_session_owner.c.tenant_id == tid,
|
||||
ui_session_owner.c.user_id == uid,
|
||||
)
|
||||
.limit(1)
|
||||
).first()
|
||||
if not chk:
|
||||
return False
|
||||
conn.execute(delete(chat_session).where(chat_session.c.id == sid))
|
||||
return True
|
||||
|
||||
|
||||
__all__ = ["ChatSessionsSaRepository"]
|
||||
77
svc/persistence/sa_repos/session_tool_health.py
Normal file
77
svc/persistence/sa_repos/session_tool_health.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""Session tool health listing (admin) via SQLAlchemy Core."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import case, func, literal, select
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from svc.persistence.db.tables import chat_message, chat_session, tool_log
|
||||
|
||||
|
||||
class SessionToolHealthSaRepository:
|
||||
"""``list_session_tool_health`` aggregates."""
|
||||
|
||||
__slots__ = ("_engine",)
|
||||
|
||||
def __init__(self, engine: Engine) -> None:
|
||||
self._engine = engine
|
||||
|
||||
def list_session_tool_health(
|
||||
self, *, session_id: str | None, limit: int
|
||||
) -> list[dict[str, Any]]:
|
||||
lim = max(1, int(limit))
|
||||
msg_sq = (
|
||||
select(
|
||||
chat_message.c.session_id,
|
||||
func.sum(case((chat_message.c.role == literal("user"), 1), else_=0)).label("user_count"),
|
||||
func.sum(case((chat_message.c.role == literal("assistant"), 1), else_=0)).label(
|
||||
"assistant_count"
|
||||
),
|
||||
)
|
||||
.group_by(chat_message.c.session_id)
|
||||
.subquery()
|
||||
)
|
||||
tl_sq = (
|
||||
select(
|
||||
tool_log.c.session_id,
|
||||
func.count(1).label("tool_count"),
|
||||
func.sum(case((tool_log.c.tool_name.like("mcp__%"), 1), else_=0)).label(
|
||||
"mcp_tool_count"
|
||||
),
|
||||
func.max(tool_log.c.timestamp).label("last_tool_at"),
|
||||
)
|
||||
.group_by(tool_log.c.session_id)
|
||||
.subquery()
|
||||
)
|
||||
stmt = (
|
||||
select(
|
||||
chat_session.c.id.label("session_id"),
|
||||
chat_session.c.title,
|
||||
chat_session.c.last_message_at,
|
||||
func.coalesce(msg_sq.c.user_count, literal(0)).label("user_count"),
|
||||
func.coalesce(msg_sq.c.assistant_count, literal(0)).label("assistant_count"),
|
||||
func.coalesce(tl_sq.c.tool_count, literal(0)).label("tool_count"),
|
||||
func.coalesce(tl_sq.c.mcp_tool_count, literal(0)).label("mcp_tool_count"),
|
||||
func.coalesce(tl_sq.c.last_tool_at, literal("")).label("last_tool_at"),
|
||||
)
|
||||
.select_from(
|
||||
chat_session.outerjoin(msg_sq, msg_sq.c.session_id == chat_session.c.id).outerjoin(
|
||||
tl_sq, tl_sq.c.session_id == chat_session.c.id
|
||||
)
|
||||
)
|
||||
.order_by(
|
||||
func.coalesce(chat_session.c.last_message_at, chat_session.c.created_at).desc(),
|
||||
)
|
||||
.limit(lim)
|
||||
)
|
||||
sid = str(session_id or "").strip()
|
||||
if sid:
|
||||
stmt = stmt.where(chat_session.c.id == sid)
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(stmt).mappings().all()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
__all__ = ["SessionToolHealthSaRepository"]
|
||||
116
svc/persistence/sa_repos/tenant_bind_code.py
Normal file
116
svc/persistence/sa_repos/tenant_bind_code.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
"""tenant + bind_code via SQLAlchemy Core."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping
|
||||
|
||||
from sqlalchemy import delete, insert, select, update
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from svc.persistence.db.tables import bind_code, tenant
|
||||
|
||||
|
||||
class TenantSaRepository:
|
||||
__slots__ = ("_engine",)
|
||||
|
||||
def __init__(self, engine: Engine) -> None:
|
||||
self._engine = engine
|
||||
|
||||
def insert_tenant(self, *, tenant_id: str, name: str, created_at: str) -> None:
|
||||
with self._engine.begin() as conn:
|
||||
conn.execute(
|
||||
insert(tenant).values(
|
||||
id=str(tenant_id),
|
||||
name=str(name),
|
||||
created_at=str(created_at),
|
||||
)
|
||||
)
|
||||
|
||||
def delete_tenant(self, *, tenant_id: str) -> int:
|
||||
tid = str(tenant_id or "").strip()
|
||||
if not tid:
|
||||
return 0
|
||||
with self._engine.begin() as conn:
|
||||
res = conn.execute(delete(tenant).where(tenant.c.id == tid))
|
||||
return int(res.rowcount or 0)
|
||||
|
||||
def list_tenants(self, *, limit: int) -> list[dict[str, Any]]:
|
||||
lim = max(1, int(limit))
|
||||
stmt = (
|
||||
select(tenant.c.id, tenant.c.name, tenant.c.created_at)
|
||||
.order_by(tenant.c.created_at.desc())
|
||||
.limit(lim)
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
return [dict(r) for r in conn.execute(stmt).mappings().all()]
|
||||
|
||||
|
||||
class BindCodeSaRepository:
|
||||
__slots__ = ("_engine",)
|
||||
|
||||
def __init__(self, engine: Engine) -> None:
|
||||
self._engine = engine
|
||||
|
||||
def insert_bind_code(
|
||||
self, *, code: str, tenant_id: str, role: str, created_at: str
|
||||
) -> None:
|
||||
with self._engine.begin() as conn:
|
||||
conn.execute(
|
||||
insert(bind_code).values(
|
||||
code=str(code),
|
||||
tenant_id=str(tenant_id),
|
||||
role=str(role),
|
||||
created_at=str(created_at),
|
||||
used_at=None,
|
||||
used_by_external_user_id=None,
|
||||
)
|
||||
)
|
||||
|
||||
def fetch_by_code(self, *, code: str) -> Mapping[str, Any] | None:
|
||||
c = str(code or "").strip()
|
||||
if not c:
|
||||
return None
|
||||
stmt = (
|
||||
select(
|
||||
bind_code.c.code,
|
||||
bind_code.c.tenant_id,
|
||||
bind_code.c.role,
|
||||
bind_code.c.created_at,
|
||||
bind_code.c.used_at,
|
||||
bind_code.c.used_by_external_user_id,
|
||||
)
|
||||
.where(bind_code.c.code == c)
|
||||
.limit(1)
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
return conn.execute(stmt).mappings().first()
|
||||
|
||||
def mark_used(
|
||||
self, *, code: str, used_at: str, used_by_external_user_id: str
|
||||
) -> None:
|
||||
c = str(code or "").strip()
|
||||
with self._engine.begin() as conn:
|
||||
conn.execute(
|
||||
update(bind_code)
|
||||
.where(bind_code.c.code == c)
|
||||
.values(used_at=str(used_at), used_by_external_user_id=str(used_by_external_user_id))
|
||||
)
|
||||
|
||||
def list_bind_codes(self, *, tenant_id: str | None, limit: int) -> list[dict[str, Any]]:
|
||||
lim = max(1, int(limit))
|
||||
stmt = select(
|
||||
bind_code.c.code,
|
||||
bind_code.c.tenant_id,
|
||||
bind_code.c.role,
|
||||
bind_code.c.created_at,
|
||||
bind_code.c.used_at,
|
||||
bind_code.c.used_by_external_user_id,
|
||||
).order_by(bind_code.c.created_at.desc())
|
||||
if tenant_id:
|
||||
stmt = stmt.where(bind_code.c.tenant_id == str(tenant_id))
|
||||
stmt = stmt.limit(lim)
|
||||
with self._engine.connect() as conn:
|
||||
return [dict(r) for r in conn.execute(stmt).mappings().all()]
|
||||
|
||||
|
||||
__all__ = ["BindCodeSaRepository", "TenantSaRepository"]
|
||||
133
svc/persistence/sa_repos/tool_log_queries.py
Normal file
133
svc/persistence/sa_repos/tool_log_queries.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
"""tool_log read paths (MCP summaries, call logs) via SQLAlchemy Core."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import and_, delete, exists, func, insert, select, update
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from svc.persistence.db.tables import chat_session, tool_log
|
||||
|
||||
|
||||
class ToolLogQueriesSaRepository:
|
||||
__slots__ = ("_engine",)
|
||||
|
||||
def __init__(self, engine: Engine) -> None:
|
||||
self._engine = engine
|
||||
|
||||
def insert_tool_log(
|
||||
self,
|
||||
*,
|
||||
session_id: str,
|
||||
tool_name: str,
|
||||
specialist: str,
|
||||
args: str,
|
||||
result: str,
|
||||
timestamp: str,
|
||||
duration_ms: int | None,
|
||||
) -> None:
|
||||
with self._engine.begin() as conn:
|
||||
conn.execute(
|
||||
insert(tool_log).values(
|
||||
session_id=str(session_id),
|
||||
tool_name=str(tool_name),
|
||||
specialist=str(specialist or ""),
|
||||
args=str(args),
|
||||
result=str(result),
|
||||
timestamp=str(timestamp),
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
)
|
||||
|
||||
def list_tool_logs_asc(self, *, session_id: str, limit: int) -> list[dict[str, Any]]:
|
||||
sid = str(session_id or "").strip()
|
||||
lim = max(1, int(limit))
|
||||
stmt = (
|
||||
select(
|
||||
tool_log.c.tool_name,
|
||||
tool_log.c.specialist,
|
||||
tool_log.c.args,
|
||||
tool_log.c.result,
|
||||
tool_log.c.timestamp,
|
||||
tool_log.c.duration_ms,
|
||||
)
|
||||
.where(tool_log.c.session_id == sid)
|
||||
.order_by(tool_log.c.id.asc())
|
||||
.limit(lim)
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
return [dict(r) for r in conn.execute(stmt).mappings().all()]
|
||||
|
||||
def move_tool_logs_between_sessions(self, *, from_session_id: str, to_session_id: str) -> int:
|
||||
src = str(from_session_id or "").strip()
|
||||
dst = str(to_session_id or "").strip()
|
||||
if not src or not dst or src == dst:
|
||||
return 0
|
||||
with self._engine.begin() as conn:
|
||||
res = conn.execute(
|
||||
update(tool_log)
|
||||
.where(tool_log.c.session_id == src)
|
||||
.values(session_id=dst)
|
||||
)
|
||||
return int(res.rowcount or 0)
|
||||
|
||||
def delete_tool_logs_where_session_missing(self) -> int:
|
||||
"""Delete ``tool_log`` rows whose ``session_id`` is not in ``chat_session`` (housekeeping)."""
|
||||
sess_exists = exists(
|
||||
select(1).select_from(chat_session).where(chat_session.c.id == tool_log.c.session_id)
|
||||
)
|
||||
with self._engine.begin() as conn:
|
||||
res = conn.execute(delete(tool_log).where(~sess_exists))
|
||||
return int(res.rowcount or 0)
|
||||
|
||||
def list_mcp_tool_usage_summary(self, *, limit: int) -> list[dict[str, Any]]:
|
||||
lim = max(1, int(limit))
|
||||
n = func.count(1).label("n")
|
||||
last_ts = func.max(tool_log.c.timestamp).label("last_ts")
|
||||
stmt = (
|
||||
select(tool_log.c.tool_name, tool_log.c.specialist, n, last_ts)
|
||||
.where(tool_log.c.tool_name.like("mcp__%"))
|
||||
.group_by(tool_log.c.tool_name, tool_log.c.specialist)
|
||||
.order_by(n.desc(), last_ts.desc())
|
||||
.limit(lim)
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
return [dict(r) for r in conn.execute(stmt).mappings().all()]
|
||||
|
||||
def list_mcp_tool_aggregate_usage(self) -> list[dict[str, Any]]:
|
||||
n = func.count(1).label("n")
|
||||
last_ts = func.max(tool_log.c.timestamp).label("last_ts")
|
||||
stmt = (
|
||||
select(tool_log.c.tool_name, n, last_ts)
|
||||
.where(tool_log.c.tool_name.like("mcp__%"))
|
||||
.group_by(tool_log.c.tool_name)
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
return [dict(r) for r in conn.execute(stmt).mappings().all()]
|
||||
|
||||
def list_mcp_tool_call_logs(self, *, server_id: str | None, limit: int) -> list[dict[str, Any]]:
|
||||
lim = max(1, int(limit))
|
||||
sid = str(server_id or "").strip()
|
||||
conds = [tool_log.c.tool_name.like("mcp__%")]
|
||||
if sid:
|
||||
conds.append(tool_log.c.tool_name.like(f"mcp__{sid}__%"))
|
||||
stmt = (
|
||||
select(
|
||||
tool_log.c.session_id,
|
||||
tool_log.c.tool_name,
|
||||
tool_log.c.specialist,
|
||||
tool_log.c.args,
|
||||
tool_log.c.result,
|
||||
tool_log.c.timestamp,
|
||||
tool_log.c.duration_ms,
|
||||
)
|
||||
.where(and_(*conds))
|
||||
.order_by(tool_log.c.id.desc())
|
||||
.limit(lim)
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
return [dict(r) for r in conn.execute(stmt).mappings().all()]
|
||||
|
||||
|
||||
__all__ = ["ToolLogQueriesSaRepository"]
|
||||
108
svc/persistence/sa_repos/trace_events.py
Normal file
108
svc/persistence/sa_repos/trace_events.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
"""trace_event insert + list queries via SQLAlchemy Core."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import insert, select
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from svc.persistence.db.tables import trace_event
|
||||
|
||||
|
||||
class TraceEventsSaRepository:
|
||||
__slots__ = ("_engine",)
|
||||
|
||||
def __init__(self, engine: Engine) -> None:
|
||||
self._engine = engine
|
||||
|
||||
def insert_one(
|
||||
self,
|
||||
*,
|
||||
session_id: str,
|
||||
trace_id: str,
|
||||
span_id: str,
|
||||
parent_span_id: str | None,
|
||||
event_type: str,
|
||||
payload: str,
|
||||
timestamp: str,
|
||||
) -> None:
|
||||
with self._engine.begin() as conn:
|
||||
conn.execute(
|
||||
insert(trace_event).values(
|
||||
session_id=str(session_id),
|
||||
trace_id=str(trace_id),
|
||||
span_id=str(span_id),
|
||||
parent_span_id=parent_span_id,
|
||||
event_type=str(event_type),
|
||||
payload=str(payload),
|
||||
timestamp=str(timestamp),
|
||||
)
|
||||
)
|
||||
|
||||
def insert_many(self, rows: list[dict[str, Any]]) -> None:
|
||||
if not rows:
|
||||
return
|
||||
with self._engine.begin() as conn:
|
||||
conn.execute(insert(trace_event), rows)
|
||||
|
||||
def list_trace_events_desc(self, *, session_id: str, limit: int) -> list[dict[str, Any]]:
|
||||
sid = str(session_id or "").strip()
|
||||
lim = max(1, int(limit))
|
||||
stmt = (
|
||||
select(
|
||||
trace_event.c.trace_id,
|
||||
trace_event.c.span_id,
|
||||
trace_event.c.parent_span_id,
|
||||
trace_event.c.event_type,
|
||||
trace_event.c.payload,
|
||||
trace_event.c.timestamp,
|
||||
)
|
||||
.where(trace_event.c.session_id == sid)
|
||||
.order_by(trace_event.c.id.desc())
|
||||
.limit(lim)
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
return [dict(r) for r in conn.execute(stmt).mappings().all()]
|
||||
|
||||
def list_trace_events_for_trace_asc(
|
||||
self, *, session_id: str, trace_id: str, limit: int
|
||||
) -> list[dict[str, Any]]:
|
||||
sid = str(session_id or "").strip()
|
||||
tid = str(trace_id or "").strip()
|
||||
lim = max(1, int(limit))
|
||||
if not sid or not tid:
|
||||
return []
|
||||
stmt = (
|
||||
select(
|
||||
trace_event.c.trace_id,
|
||||
trace_event.c.span_id,
|
||||
trace_event.c.parent_span_id,
|
||||
trace_event.c.event_type,
|
||||
trace_event.c.payload,
|
||||
trace_event.c.timestamp,
|
||||
)
|
||||
.where(trace_event.c.session_id == sid, trace_event.c.trace_id == tid)
|
||||
.order_by(trace_event.c.id.asc())
|
||||
.limit(lim)
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
return [dict(r) for r in conn.execute(stmt).mappings().all()]
|
||||
|
||||
def list_event_type_timestamp_for_trace(
|
||||
self, *, session_id: str, trace_id: str
|
||||
) -> list[dict[str, Any]]:
|
||||
sid = str(session_id or "").strip()
|
||||
tid = str(trace_id or "").strip()
|
||||
if not sid or not tid:
|
||||
return []
|
||||
stmt = (
|
||||
select(trace_event.c.event_type, trace_event.c.timestamp)
|
||||
.where(trace_event.c.session_id == sid, trace_event.c.trace_id == tid)
|
||||
.order_by(trace_event.c.id.asc())
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
return [dict(r) for r in conn.execute(stmt).mappings().all()]
|
||||
|
||||
|
||||
__all__ = ["TraceEventsSaRepository"]
|
||||
193
svc/persistence/sa_repos/ui_session_owner.py
Normal file
193
svc/persistence/sa_repos/ui_session_owner.py
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
"""ui_session_owner upserts + backfills via SQLAlchemy Core."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping
|
||||
|
||||
from sqlalchemy import and_, exists, func, insert, literal, select
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from svc.persistence.db.tables import (
|
||||
channel_identity_v2,
|
||||
channel_session_v2,
|
||||
chat_session,
|
||||
ui_session_owner,
|
||||
)
|
||||
|
||||
|
||||
class UiSessionOwnerSaRepository:
|
||||
__slots__ = ("_engine",)
|
||||
|
||||
def __init__(self, engine: Engine) -> None:
|
||||
self._engine = engine
|
||||
|
||||
def upsert_replace(
|
||||
self, *, session_id: str, tenant_id: str, user_id: str, created_at: str
|
||||
) -> None:
|
||||
sid = str(session_id or "").strip()
|
||||
vals = {
|
||||
"session_id": sid,
|
||||
"tenant_id": str(tenant_id),
|
||||
"user_id": str(user_id),
|
||||
"created_at": str(created_at),
|
||||
}
|
||||
dialect = self._engine.dialect.name
|
||||
if dialect == "sqlite":
|
||||
from sqlalchemy.dialects.sqlite import insert as dialect_insert
|
||||
else:
|
||||
from sqlalchemy.dialects.postgresql import insert as dialect_insert
|
||||
|
||||
ins = dialect_insert(ui_session_owner).values(**vals)
|
||||
stmt = ins.on_conflict_do_update(
|
||||
index_elements=[ui_session_owner.c.session_id],
|
||||
set_={
|
||||
"tenant_id": ins.excluded.tenant_id,
|
||||
"user_id": ins.excluded.user_id,
|
||||
"created_at": ins.excluded.created_at,
|
||||
},
|
||||
)
|
||||
with self._engine.begin() as conn:
|
||||
conn.execute(stmt)
|
||||
|
||||
def insert_ignore(
|
||||
self, *, session_id: str, tenant_id: str, user_id: str, created_at: str
|
||||
) -> None:
|
||||
sid = str(session_id or "").strip()
|
||||
vals = {
|
||||
"session_id": sid,
|
||||
"tenant_id": str(tenant_id),
|
||||
"user_id": str(user_id),
|
||||
"created_at": str(created_at),
|
||||
}
|
||||
dialect = self._engine.dialect.name
|
||||
if dialect == "sqlite":
|
||||
from sqlalchemy.dialects.sqlite import insert as dialect_insert
|
||||
else:
|
||||
from sqlalchemy.dialects.postgresql import insert as dialect_insert
|
||||
|
||||
ins = dialect_insert(ui_session_owner).values(**vals)
|
||||
stmt = ins.on_conflict_do_nothing(index_elements=[ui_session_owner.c.session_id])
|
||||
with self._engine.begin() as conn:
|
||||
conn.execute(stmt)
|
||||
|
||||
def fetch_by_session_id(self, *, session_id: str) -> Mapping[str, Any] | None:
|
||||
sid = str(session_id or "").strip()
|
||||
if not sid:
|
||||
return None
|
||||
stmt = (
|
||||
select(
|
||||
ui_session_owner.c.tenant_id,
|
||||
ui_session_owner.c.user_id,
|
||||
ui_session_owner.c.created_at,
|
||||
)
|
||||
.where(ui_session_owner.c.session_id == sid)
|
||||
.limit(1)
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(stmt).mappings().first()
|
||||
return row
|
||||
|
||||
def backfill_orphan_sessions_for_user(
|
||||
self, *, tenant_id: str, user_id: str, default_created_at: str
|
||||
) -> int:
|
||||
tid = str(tenant_id)
|
||||
uid = str(user_id)
|
||||
ts = str(default_created_at)
|
||||
owned = exists(
|
||||
select(1).select_from(ui_session_owner).where(ui_session_owner.c.session_id == chat_session.c.id)
|
||||
)
|
||||
sel = (
|
||||
select(
|
||||
chat_session.c.id.label("session_id"),
|
||||
literal(tid).label("tenant_id"),
|
||||
literal(uid).label("user_id"),
|
||||
func.coalesce(chat_session.c.created_at, literal(ts)).label("created_at"),
|
||||
)
|
||||
.where(~owned)
|
||||
)
|
||||
dialect = self._engine.dialect.name
|
||||
with self._engine.begin() as conn:
|
||||
if dialect == "sqlite":
|
||||
stmt = insert(ui_session_owner).prefix_with("OR IGNORE").from_select(
|
||||
[
|
||||
ui_session_owner.c.session_id,
|
||||
ui_session_owner.c.tenant_id,
|
||||
ui_session_owner.c.user_id,
|
||||
ui_session_owner.c.created_at,
|
||||
],
|
||||
sel,
|
||||
)
|
||||
else:
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
stmt = (
|
||||
pg_insert(ui_session_owner)
|
||||
.from_select(
|
||||
[
|
||||
ui_session_owner.c.session_id,
|
||||
ui_session_owner.c.tenant_id,
|
||||
ui_session_owner.c.user_id,
|
||||
ui_session_owner.c.created_at,
|
||||
],
|
||||
sel,
|
||||
)
|
||||
.on_conflict_do_nothing(index_elements=[ui_session_owner.c.session_id])
|
||||
)
|
||||
res = conn.execute(stmt)
|
||||
return int(res.rowcount or 0)
|
||||
|
||||
def backfill_from_channel_v2(self, *, created_at: str) -> int:
|
||||
ts = str(created_at)
|
||||
join_on = and_(
|
||||
channel_identity_v2.c.tenant_id == channel_session_v2.c.tenant_id,
|
||||
channel_identity_v2.c.channel == channel_session_v2.c.channel,
|
||||
channel_identity_v2.c.account_id == channel_session_v2.c.account_id,
|
||||
channel_identity_v2.c.external_user_id == channel_session_v2.c.external_user_id,
|
||||
)
|
||||
sel = (
|
||||
select(
|
||||
channel_session_v2.c.session_id,
|
||||
channel_session_v2.c.tenant_id,
|
||||
channel_identity_v2.c.user_id,
|
||||
literal(ts).label("created_at"),
|
||||
)
|
||||
.distinct()
|
||||
.select_from(channel_session_v2.join(channel_identity_v2, join_on))
|
||||
.where(
|
||||
channel_session_v2.c.session_id.isnot(None),
|
||||
channel_session_v2.c.session_id != "",
|
||||
)
|
||||
)
|
||||
dialect = self._engine.dialect.name
|
||||
with self._engine.begin() as conn:
|
||||
if dialect == "sqlite":
|
||||
stmt = insert(ui_session_owner).prefix_with("OR IGNORE").from_select(
|
||||
[
|
||||
ui_session_owner.c.session_id,
|
||||
ui_session_owner.c.tenant_id,
|
||||
ui_session_owner.c.user_id,
|
||||
ui_session_owner.c.created_at,
|
||||
],
|
||||
sel,
|
||||
)
|
||||
else:
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
stmt = (
|
||||
pg_insert(ui_session_owner)
|
||||
.from_select(
|
||||
[
|
||||
ui_session_owner.c.session_id,
|
||||
ui_session_owner.c.tenant_id,
|
||||
ui_session_owner.c.user_id,
|
||||
ui_session_owner.c.created_at,
|
||||
],
|
||||
sel,
|
||||
)
|
||||
.on_conflict_do_nothing(index_elements=[ui_session_owner.c.session_id])
|
||||
)
|
||||
res = conn.execute(stmt)
|
||||
return int(res.rowcount or 0)
|
||||
|
||||
|
||||
__all__ = ["UiSessionOwnerSaRepository"]
|
||||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue