fed8b2eed7
Backend release / release (push) Waiting to run
Bandit Security Scan / bandit_scan (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / manifest (push) Blocked by required conditions
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / manifest (push) Blocked by required conditions
Python linting / ruff (push) Waiting to run
Run python tests with pytest / Run tests and count coverage (3.12) (push) Waiting to run
React Widget Build / build (push) Waiting to run
153 lines
6.9 KiB
Python
153 lines
6.9 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 application.core.settings import settings
|
|
from application.sandbox.artifacts_capture import QuotaExceeded, persist_new_artifact
|
|
from application.storage.db.repositories.artifacts import ArtifactsRepository
|
|
from application.storage.db.repositories.attachments import AttachmentsRepository
|
|
from application.storage.db.session import db_readonly
|
|
from application.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"])
|