Files
Alex 574f96341e refactor: rename the application package to docsgpt
The backend import package is now docsgpt, the name it will carry on PyPI;
application was far too generic to install into anyone's site-packages.
git mv plus a mechanical rewrite of every import, dotted string and path
reference: 734 Python files, the compose files, Dockerfile, workflows, docs,
setup scripts, devcontainer, k8s manifests, vscode config, pytest and coverage
config, .gitignore. Behaviour is unchanged.

Kept for one release:
- A top-level application package whose meta-path finder resolves
  application.x.y to the already-imported docsgpt.x.y object, so old imports
  and entry points (celery -A application.app.celery,
  uvicorn application.asgi:asgi_app) keep working with a FutureWarning.
- Celery registers every application.* task name as an alias of its
  docsgpt.* task on start-up, so messages queued by the previous release still
  run. The redbeat key prefix moves to redbeat:docsgpt:v2: so schedule entries
  the previous release wrote are left unread instead of firing twice.

The backend image builds from the repository root (docker build -f
docsgpt/Dockerfile .) so it can ship the alias package; a root .dockerignore
allow-lists docsgpt/ and application/ and keeps caches, local data, .env
files, the sample index files and the Dockerfile out. Compose and the image
workflows point at the new context.
2026-09-07 10:20:43 +01:00

125 lines
4.9 KiB
Python

"""Repository for the ``stack_logs`` table.
Covers the single operation the legacy Mongo code performs:
1. ``insert_one`` in logging.py ``_log_to_mongodb`` — append-only debug/error
activity log. The Mongo collection is ``stack_logs``; the Mongo variable
inside ``_log_to_mongodb`` is misleadingly named ``user_logs_collection``.
"""
from __future__ import annotations
import json
import uuid
from datetime import datetime
from typing import Optional
from docsgpt.storage.db.redaction import redact_secrets
from docsgpt.storage.db.serialization import PGNativeJSONEncoder
from docsgpt.utils import strip_null_bytes
from sqlalchemy import Connection, text
# Longest string kept inside ``stacks``. The scalar columns are truncated
# by the caller, but stacks used to go in whole — one uncapped tool
# result (634k tokens, 07-17) rode into the activity log through here.
_STACKS_STRING_MAX_LEN = 10000
def _bound_strings(value):
"""Recursively truncate strings in ``value`` to ``_STACKS_STRING_MAX_LEN``."""
if isinstance(value, str):
if len(value) <= _STACKS_STRING_MAX_LEN:
return value
return value[:_STACKS_STRING_MAX_LEN] + "...[truncated]"
if isinstance(value, dict):
return {k: _bound_strings(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [_bound_strings(item) for item in value]
return value
class StackLogsRepository:
"""Postgres-backed replacement for Mongo ``stack_logs`` collection."""
def __init__(self, conn: Connection) -> None:
self._conn = conn
def insert(
self,
*,
activity_id: str,
endpoint: Optional[str] = None,
level: Optional[str] = None,
user_id: Optional[str] = None,
api_key: Optional[str] = None,
agent_id: Optional[str] = None,
query: Optional[str] = None,
stacks: Optional[list] = None,
timestamp: Optional[datetime] = None,
) -> None:
# ``agent_id`` is a UUID column. Parse it with ``uuid.UUID`` and coerce
# anything that isn't a valid UUID (e.g. a 24-hex legacy Mongo ObjectId)
# to NULL so a stray/legacy id never reaches ``CAST(... AS uuid)`` and
# breaks the activity-log write.
agent_id_uuid: Optional[str] = None
if agent_id:
try:
agent_id_uuid = str(uuid.UUID(str(agent_id)))
except (ValueError, AttributeError, TypeError):
agent_id_uuid = None
self._conn.execute(
text(
"""
INSERT INTO stack_logs (activity_id, endpoint, level, user_id, api_key, agent_id, query, stacks, timestamp)
VALUES (
:activity_id, :endpoint, :level, :user_id, :api_key,
CAST(:agent_id AS uuid), :query,
CAST(:stacks AS jsonb),
COALESCE(:timestamp, now())
)
"""
),
{
"activity_id": activity_id,
"endpoint": strip_null_bytes(endpoint),
"level": strip_null_bytes(level),
"user_id": strip_null_bytes(user_id),
"api_key": strip_null_bytes(api_key),
"agent_id": agent_id_uuid,
"query": strip_null_bytes(query),
"stacks": json.dumps(
redact_secrets(
_bound_strings(strip_null_bytes(stacks or []))
),
cls=PGNativeJSONEncoder,
),
"timestamp": timestamp,
},
)
def reassign_api_key(self, *, old_key: str, new_key: str) -> int:
"""Re-point historical rows from ``old_key`` to ``new_key``.
Since migration 0026 ``stack_logs`` has an ``agent_id`` column and the
analytics ``webhook_where`` / ``system_where`` clauses match
``agent_id`` first with ``api_key`` as a fallback, so most rows already
survive a key rotation via ``agent_id``. This rewrite still runs to keep
the ``api_key`` column consistent and to re-attach any rows whose
``agent_id`` is NULL (e.g. a log written without agent context).
Matched by ``api_key`` only — deliberately NOT scoped by ``user_id``:
``agents.key`` is globally unique, so a key maps to exactly one agent,
and rows are stamped with the *caller's* user_id (which is not the
owner for webhook / external-api-key traffic). Scoping by owner would
skip precisely the rows this rewrite exists to preserve.
Returns the number of rows updated.
"""
if not old_key or not new_key:
return 0
result = self._conn.execute(
text("UPDATE stack_logs SET api_key = :new_key WHERE api_key = :old_key"),
{"old_key": old_key, "new_key": new_key},
)
return result.rowcount