mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-05 08:14:32 +00:00
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.
153 lines
6.8 KiB
Python
153 lines
6.8 KiB
Python
"""Lazily bridge a chat attachment into a conversation-scoped artifact when a tool references it.
|
|
|
|
A chat attachment lives in the ``attachments`` table (parsed to text for the LLM context); it is
|
|
not an artifact and so cannot be fed to ``code_executor`` / ``read_document`` directly. When one of
|
|
those tools references an attachment by id or filename, this module materializes it into a
|
|
conversation-scoped artifact on demand — only the request's own (already user-scoped) attachments are
|
|
reachable, and an already-bridged attachment is reused so repeated references never burn extra quota.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from docsgpt.core.settings import settings
|
|
from docsgpt.sandbox.artifacts_capture import QuotaExceeded, persist_new_artifact
|
|
from docsgpt.storage.db.repositories.artifacts import ArtifactsRepository
|
|
from docsgpt.storage.db.repositories.attachments import AttachmentsRepository
|
|
from docsgpt.storage.db.session import db_readonly
|
|
from docsgpt.storage.storage_creator import StorageCreator
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class AttachmentBridgeError(Exception):
|
|
"""Raised when a matched attachment cannot be bridged (e.g. quota, unreadable bytes)."""
|
|
|
|
|
|
def _normalize_name(value: Any) -> str:
|
|
"""Lowercase + strip a filename for tolerant matching."""
|
|
return str(value or "").strip().lower()
|
|
|
|
|
|
def match_attachment(
|
|
attachments: Optional[List[Dict[str, Any]]], raw_ref: str, user_id: str
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""Match a model-supplied id/filename against the caller's OWN request attachments; None otherwise.
|
|
|
|
Matching is confined to ``attachments`` (already user-scoped when loaded) so a forged id/name can
|
|
never reach another user's or conversation's attachment. An id match is re-verified against
|
|
``AttachmentsRepository.get_any(id, user_id)`` so only the owner's row is ever bridged. When two
|
|
attachments share a filename the first is chosen; reference by id to disambiguate.
|
|
"""
|
|
if not attachments or not raw_ref:
|
|
return None
|
|
ref = raw_ref.strip()
|
|
if not ref:
|
|
return None
|
|
ref_norm = _normalize_name(ref)
|
|
by_filename: Optional[Dict[str, Any]] = None
|
|
for attachment in attachments:
|
|
if not isinstance(attachment, dict):
|
|
continue
|
|
ids = {
|
|
str(attachment.get(key))
|
|
for key in ("id", "_id", "legacy_mongo_id")
|
|
if attachment.get(key) is not None
|
|
}
|
|
if ref in ids:
|
|
return _verify_owner(attachment, user_id)
|
|
filename = attachment.get("filename")
|
|
if by_filename is None and filename and _normalize_name(filename) == ref_norm:
|
|
by_filename = attachment
|
|
if by_filename is not None:
|
|
return _verify_owner(by_filename, user_id)
|
|
return None
|
|
|
|
|
|
def _verify_owner(attachment: Dict[str, Any], user_id: str) -> Optional[Dict[str, Any]]:
|
|
"""Re-confirm the attachment belongs to ``user_id`` via the user-scoped repo; in-memory dict on hit."""
|
|
attachment_id = attachment.get("id") or attachment.get("_id") or attachment.get("legacy_mongo_id")
|
|
if attachment_id is None:
|
|
return None
|
|
try:
|
|
with db_readonly() as conn:
|
|
owned = AttachmentsRepository(conn).get_any(str(attachment_id), user_id)
|
|
except Exception:
|
|
logger.exception("attachment_bridge: ownership re-check failed")
|
|
return None
|
|
# Prefer the DB row (authoritative upload_path/mime) but only when it confirms ownership.
|
|
return owned if owned is not None else None
|
|
|
|
|
|
def bridge_attachment(
|
|
attachment: Dict[str, Any], *, user_id: str, conversation_id: str
|
|
) -> str:
|
|
"""Return the conversation artifact id for ``attachment``, reusing an existing bridge or creating one.
|
|
|
|
Idempotent (best-effort): an artifact already derived from this attachment in this conversation
|
|
(matched via its version ``produced_by.attachment_id``) is reused, so a second reference never
|
|
consumes a new quota slot. The reuse is a read-then-write across transactions, so two concurrent
|
|
references to the same not-yet-bridged attachment may each create one. Otherwise the attachment
|
|
bytes are read server-side and persisted as a conversation-scoped ``file`` artifact (server-computed
|
|
size/sha256/storage key).
|
|
"""
|
|
attachment_id = str(attachment.get("id") or attachment.get("_id") or attachment.get("legacy_mongo_id"))
|
|
try:
|
|
with db_readonly() as conn:
|
|
existing = ArtifactsRepository(conn).find_bridged_attachment(
|
|
attachment_id, conversation_id=conversation_id
|
|
)
|
|
except Exception:
|
|
logger.exception("attachment_bridge: idempotency lookup failed")
|
|
existing = None
|
|
if existing is not None:
|
|
return str(existing["id"])
|
|
|
|
upload_path = attachment.get("upload_path") or attachment.get("path")
|
|
if not upload_path:
|
|
raise AttachmentBridgeError(f"attachment {attachment_id} has no stored content.")
|
|
filename = attachment.get("filename") or "attachment"
|
|
mime_type = attachment.get("mime_type") or "application/octet-stream"
|
|
# Reject oversize attachments BEFORE buffering them: the authoritative ``size``
|
|
# column lets us avoid pulling a multi-hundred-MB file fully into worker memory,
|
|
# and the bounded read below backstops a missing/lying ``size``.
|
|
max_bytes = int(getattr(settings, "ARTIFACT_MAX_BYTES", 0) or 0)
|
|
declared_size = attachment.get("size")
|
|
if max_bytes and isinstance(declared_size, (int, float)) and declared_size > max_bytes:
|
|
raise AttachmentBridgeError(
|
|
f"attachment {attachment_id} exceeds the {max_bytes}-byte artifact size limit."
|
|
)
|
|
try:
|
|
file_obj = StorageCreator.get_storage().get_file(upload_path)
|
|
try:
|
|
data = file_obj.read(max_bytes + 1) if max_bytes else file_obj.read()
|
|
finally:
|
|
close = getattr(file_obj, "close", None)
|
|
if callable(close):
|
|
close()
|
|
except Exception as exc:
|
|
logger.exception("attachment_bridge: failed to read attachment bytes")
|
|
raise AttachmentBridgeError(f"failed to read attachment {attachment_id}.") from exc
|
|
if max_bytes and len(data) > max_bytes:
|
|
raise AttachmentBridgeError(
|
|
f"attachment {attachment_id} exceeds the {max_bytes}-byte artifact size limit."
|
|
)
|
|
try:
|
|
ref = persist_new_artifact(
|
|
user_id=user_id,
|
|
kind="file",
|
|
data=data,
|
|
filename=filename,
|
|
mime_type=mime_type,
|
|
title=filename,
|
|
conversation_id=conversation_id,
|
|
produced_by={"attachment_id": attachment_id, "source": "chat_attachment"},
|
|
)
|
|
except QuotaExceeded as exc:
|
|
raise AttachmentBridgeError(str(exc)) from exc
|
|
if ref is None:
|
|
raise AttachmentBridgeError(f"failed to bridge attachment {attachment_id}.")
|
|
return str(ref["artifact_id"])
|