generated from kgod/ai-review-template
提交
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
"""`langchain-core` defines the base abstractions for the LangChain ecosystem.
|
||||
|
||||
The interfaces for core components like chat models, LLMs, vector stores, retrievers,
|
||||
and more are defined here. The universal invocation protocol (Runnables) along with
|
||||
a syntax for combining components are also defined here.
|
||||
|
||||
**No third-party integrations are defined here.** The dependencies are kept purposefully
|
||||
very lightweight.
|
||||
"""
|
||||
|
||||
from langchain_core._api import (
|
||||
surface_langchain_beta_warnings,
|
||||
surface_langchain_deprecation_warnings,
|
||||
)
|
||||
from langchain_core.version import VERSION
|
||||
|
||||
__version__ = VERSION
|
||||
|
||||
surface_langchain_deprecation_warnings()
|
||||
surface_langchain_beta_warnings()
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,87 @@
|
||||
"""Helper functions for managing the LangChain API.
|
||||
|
||||
This module is only relevant for LangChain developers, not for users.
|
||||
|
||||
!!! warning
|
||||
|
||||
This module and its submodules are for internal use only. Do not use them in your
|
||||
own code. We may change the API at any time with no warning.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from langchain_core._import_utils import import_attr
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langchain_core._api.beta_decorator import (
|
||||
LangChainBetaWarning,
|
||||
beta,
|
||||
suppress_langchain_beta_warning,
|
||||
surface_langchain_beta_warnings,
|
||||
)
|
||||
from langchain_core._api.deprecation import (
|
||||
LangChainDeprecationWarning,
|
||||
deprecated,
|
||||
suppress_langchain_deprecation_warning,
|
||||
surface_langchain_deprecation_warnings,
|
||||
warn_deprecated,
|
||||
)
|
||||
from langchain_core._api.path import as_import_path, get_relative_path
|
||||
|
||||
__all__ = (
|
||||
"LangChainBetaWarning",
|
||||
"LangChainDeprecationWarning",
|
||||
"as_import_path",
|
||||
"beta",
|
||||
"deprecated",
|
||||
"get_relative_path",
|
||||
"suppress_langchain_beta_warning",
|
||||
"suppress_langchain_deprecation_warning",
|
||||
"surface_langchain_beta_warnings",
|
||||
"surface_langchain_deprecation_warnings",
|
||||
"warn_deprecated",
|
||||
)
|
||||
|
||||
_dynamic_imports = {
|
||||
"LangChainBetaWarning": "beta_decorator",
|
||||
"beta": "beta_decorator",
|
||||
"suppress_langchain_beta_warning": "beta_decorator",
|
||||
"surface_langchain_beta_warnings": "beta_decorator",
|
||||
"as_import_path": "path",
|
||||
"get_relative_path": "path",
|
||||
"LangChainDeprecationWarning": "deprecation",
|
||||
"deprecated": "deprecation",
|
||||
"surface_langchain_deprecation_warnings": "deprecation",
|
||||
"suppress_langchain_deprecation_warning": "deprecation",
|
||||
"warn_deprecated": "deprecation",
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(attr_name: str) -> object:
|
||||
"""Dynamically import and return an attribute from a submodule.
|
||||
|
||||
This function enables lazy loading of API functions from submodules, reducing
|
||||
initial import time and circular dependency issues.
|
||||
|
||||
Args:
|
||||
attr_name: Name of the attribute to import.
|
||||
|
||||
Returns:
|
||||
The imported attribute object.
|
||||
|
||||
Raises:
|
||||
AttributeError: If the attribute is not a valid dynamic import.
|
||||
"""
|
||||
module_name = _dynamic_imports.get(attr_name)
|
||||
result = import_attr(attr_name, module_name, __spec__.parent)
|
||||
globals()[attr_name] = result
|
||||
return result
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
"""Return a list of available attributes for this module.
|
||||
|
||||
Returns:
|
||||
List of attribute names that can be imported from this module.
|
||||
"""
|
||||
return list(__all__)
|
||||
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,253 @@
|
||||
"""Helper functions for marking parts of the LangChain API as beta.
|
||||
|
||||
This module was loosely adapted from matplotlib's [`_api/deprecation.py`](https://github.com/matplotlib/matplotlib/blob/main/lib/matplotlib/_api/deprecation.py)
|
||||
module.
|
||||
|
||||
!!! warning
|
||||
|
||||
This module is for internal use only. Do not use it in your own code. We may change
|
||||
the API at any time with no warning.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import functools
|
||||
import inspect
|
||||
import warnings
|
||||
from collections.abc import Callable, Generator
|
||||
from typing import Any, TypeVar, cast
|
||||
|
||||
from langchain_core._api.internal import is_caller_internal
|
||||
|
||||
|
||||
class LangChainBetaWarning(DeprecationWarning):
|
||||
"""A class for issuing beta warnings for LangChain users."""
|
||||
|
||||
|
||||
# PUBLIC API
|
||||
|
||||
|
||||
T = TypeVar("T", bound=Callable[..., Any] | type)
|
||||
|
||||
|
||||
def beta(
|
||||
*,
|
||||
message: str = "",
|
||||
name: str = "",
|
||||
obj_type: str = "",
|
||||
addendum: str = "",
|
||||
) -> Callable[[T], T]:
|
||||
"""Decorator to mark a function, a class, or a property as beta.
|
||||
|
||||
When marking a classmethod, a staticmethod, or a property, the `@beta` decorator
|
||||
should go *under* `@classmethod` and `@staticmethod` (i.e., `beta` should directly
|
||||
decorate the underlying callable), but *over* `@property`.
|
||||
|
||||
When marking a class `C` intended to be used as a base class in a multiple
|
||||
inheritance hierarchy, `C` *must* define an `__init__` method (if `C` instead
|
||||
inherited its `__init__` from its own base class, then `@beta` would mess up
|
||||
`__init__` inheritance when installing its own (annotation-emitting) `C.__init__`).
|
||||
|
||||
Args:
|
||||
message: Override the default beta message.
|
||||
|
||||
The %(since)s, %(name)s, %(alternative)s, %(obj_type)s, %(addendum)s, and
|
||||
%(removal)s format specifiers will be replaced by the values of the
|
||||
respective arguments passed to this function.
|
||||
name: The name of the beta object.
|
||||
obj_type: The object type being beta.
|
||||
addendum: Additional text appended directly to the final message.
|
||||
|
||||
Returns:
|
||||
A decorator which can be used to mark functions or classes as beta.
|
||||
|
||||
Example:
|
||||
```python
|
||||
@beta
|
||||
def the_function_to_annotate():
|
||||
pass
|
||||
```
|
||||
"""
|
||||
|
||||
def beta(
|
||||
obj: T,
|
||||
*,
|
||||
_obj_type: str = obj_type,
|
||||
_name: str = name,
|
||||
_message: str = message,
|
||||
_addendum: str = addendum,
|
||||
) -> T:
|
||||
"""Implementation of the decorator returned by `beta`."""
|
||||
|
||||
def emit_warning() -> None:
|
||||
"""Emit the warning."""
|
||||
warn_beta(
|
||||
message=_message,
|
||||
name=_name,
|
||||
obj_type=_obj_type,
|
||||
addendum=_addendum,
|
||||
)
|
||||
|
||||
warned = False
|
||||
|
||||
def warning_emitting_wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
"""Wrapper for the original wrapped callable that emits a warning.
|
||||
|
||||
Args:
|
||||
*args: The positional arguments to the function.
|
||||
**kwargs: The keyword arguments to the function.
|
||||
|
||||
Returns:
|
||||
The return value of the function being wrapped.
|
||||
"""
|
||||
nonlocal warned
|
||||
if not warned and not is_caller_internal():
|
||||
warned = True
|
||||
emit_warning()
|
||||
return wrapped(*args, **kwargs)
|
||||
|
||||
async def awarning_emitting_wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
"""Same as warning_emitting_wrapper, but for async functions."""
|
||||
nonlocal warned
|
||||
if not warned and not is_caller_internal():
|
||||
warned = True
|
||||
emit_warning()
|
||||
return await wrapped(*args, **kwargs)
|
||||
|
||||
if isinstance(obj, type):
|
||||
if not _obj_type:
|
||||
_obj_type = "class"
|
||||
wrapped = obj.__init__ # type: ignore[misc]
|
||||
_name = _name or obj.__qualname__
|
||||
old_doc = obj.__doc__
|
||||
|
||||
def finalize(_: Callable[..., Any], new_doc: str, /) -> T:
|
||||
"""Finalize the annotation of a class."""
|
||||
# Can't set new_doc on some extension objects.
|
||||
with contextlib.suppress(AttributeError):
|
||||
obj.__doc__ = new_doc
|
||||
|
||||
def warn_if_direct_instance(
|
||||
self: Any, *args: Any, **kwargs: Any
|
||||
) -> Any:
|
||||
"""Warn that the class is in beta."""
|
||||
nonlocal warned
|
||||
if not warned and type(self) is obj and not is_caller_internal():
|
||||
warned = True
|
||||
emit_warning()
|
||||
return wrapped(self, *args, **kwargs)
|
||||
|
||||
obj.__init__ = functools.wraps(obj.__init__)( # type: ignore[misc]
|
||||
warn_if_direct_instance
|
||||
)
|
||||
return obj
|
||||
|
||||
elif isinstance(obj, property):
|
||||
if not _obj_type:
|
||||
_obj_type = "attribute"
|
||||
wrapped = None
|
||||
_name = _name or obj.fget.__qualname__
|
||||
old_doc = obj.__doc__
|
||||
|
||||
def _fget(instance: Any) -> Any:
|
||||
if instance is not None:
|
||||
emit_warning()
|
||||
return obj.fget(instance)
|
||||
|
||||
def _fset(instance: Any, value: Any) -> None:
|
||||
if instance is not None:
|
||||
emit_warning()
|
||||
obj.fset(instance, value)
|
||||
|
||||
def _fdel(instance: Any) -> None:
|
||||
if instance is not None:
|
||||
emit_warning()
|
||||
obj.fdel(instance)
|
||||
|
||||
def finalize(_: Callable[..., Any], new_doc: str, /) -> Any:
|
||||
"""Finalize the property."""
|
||||
return property(fget=_fget, fset=_fset, fdel=_fdel, doc=new_doc)
|
||||
|
||||
else:
|
||||
_name = _name or obj.__qualname__
|
||||
if not _obj_type:
|
||||
# edge case: when a function is within another function
|
||||
# within a test, this will call it a "method" not a "function"
|
||||
_obj_type = "function" if "." not in _name else "method"
|
||||
wrapped = obj
|
||||
old_doc = wrapped.__doc__
|
||||
|
||||
def finalize(wrapper: Callable[..., Any], new_doc: str, /) -> T:
|
||||
"""Wrap the wrapped function using the wrapper and update the docstring.
|
||||
|
||||
Args:
|
||||
wrapper: The wrapper function.
|
||||
new_doc: The new docstring.
|
||||
|
||||
Returns:
|
||||
The wrapped function.
|
||||
"""
|
||||
wrapper = functools.wraps(wrapped)(wrapper)
|
||||
wrapper.__doc__ = new_doc
|
||||
return cast("T", wrapper)
|
||||
|
||||
old_doc = inspect.cleandoc(old_doc or "").strip("\n") or ""
|
||||
components = [message, addendum]
|
||||
details = " ".join([component.strip() for component in components if component])
|
||||
new_doc = f".. beta::\n {details}\n\n{old_doc}\n"
|
||||
|
||||
if inspect.iscoroutinefunction(obj):
|
||||
return finalize(awarning_emitting_wrapper, new_doc)
|
||||
return finalize(warning_emitting_wrapper, new_doc)
|
||||
|
||||
return beta
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def suppress_langchain_beta_warning() -> Generator[None, None, None]:
|
||||
"""Context manager to suppress `LangChainDeprecationWarning`."""
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", LangChainBetaWarning)
|
||||
yield
|
||||
|
||||
|
||||
def warn_beta(
|
||||
*,
|
||||
message: str = "",
|
||||
name: str = "",
|
||||
obj_type: str = "",
|
||||
addendum: str = "",
|
||||
) -> None:
|
||||
"""Display a standardized beta annotation.
|
||||
|
||||
Args:
|
||||
message: Override the default beta message.
|
||||
|
||||
The %(name)s, %(obj_type)s, %(addendum)s format specifiers will be replaced
|
||||
by the values of the respective arguments passed to this function.
|
||||
name: The name of the annotated object.
|
||||
obj_type: The object type being annotated.
|
||||
addendum: Additional text appended directly to the final message.
|
||||
"""
|
||||
if not message:
|
||||
message = ""
|
||||
|
||||
if obj_type:
|
||||
message += f"The {obj_type} `{name}`"
|
||||
else:
|
||||
message += f"`{name}`"
|
||||
|
||||
message += " is in beta. It is actively being worked on, so the API may change."
|
||||
|
||||
if addendum:
|
||||
message += f" {addendum}"
|
||||
|
||||
warning = LangChainBetaWarning(message)
|
||||
warnings.warn(warning, category=LangChainBetaWarning, stacklevel=4)
|
||||
|
||||
|
||||
def surface_langchain_beta_warnings() -> None:
|
||||
"""Unmute LangChain beta warnings."""
|
||||
warnings.filterwarnings(
|
||||
"default",
|
||||
category=LangChainBetaWarning,
|
||||
)
|
||||
@@ -0,0 +1,617 @@
|
||||
"""Helper functions for deprecating parts of the LangChain API.
|
||||
|
||||
This module was adapted from matplotlib's [`_api/deprecation.py`](https://github.com/matplotlib/matplotlib/blob/main/lib/matplotlib/_api/deprecation.py)
|
||||
module.
|
||||
|
||||
!!! warning
|
||||
|
||||
This module is for internal use only. Do not use it in your own code. We may change
|
||||
the API at any time with no warning.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import functools
|
||||
import inspect
|
||||
import sys
|
||||
import warnings
|
||||
from collections.abc import Callable, Generator
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
ParamSpec,
|
||||
TypeGuard,
|
||||
TypeVar,
|
||||
cast,
|
||||
)
|
||||
|
||||
from pydantic.fields import FieldInfo
|
||||
|
||||
from langchain_core._api.internal import is_caller_internal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pydantic.v1.fields import FieldInfo as FieldInfoV1
|
||||
|
||||
|
||||
def _is_pydantic_v1_field_info(obj: Any) -> TypeGuard["FieldInfoV1"]:
|
||||
"""Check if `obj` is a `pydantic.v1.fields.FieldInfo` without forcing import.
|
||||
|
||||
Importing `pydantic.v1` emits a `UserWarning` on Python 3.14+. Skipping the
|
||||
import entirely when no caller has constructed a v1 `FieldInfo` keeps that
|
||||
warning out of `langchain_core`'s import path. If a caller did construct one,
|
||||
`pydantic.v1.fields` is already in `sys.modules` and isinstance is safe.
|
||||
"""
|
||||
mod = sys.modules.get("pydantic.v1.fields")
|
||||
if mod is None:
|
||||
return False
|
||||
return isinstance(obj, mod.FieldInfo)
|
||||
|
||||
|
||||
def _build_deprecation_message(
|
||||
*,
|
||||
alternative: str = "",
|
||||
alternative_import: str = "",
|
||||
) -> str:
|
||||
"""Build a simple deprecation message for `__deprecated__` attribute.
|
||||
|
||||
Args:
|
||||
alternative: An alternative API name.
|
||||
alternative_import: A fully qualified import path for the alternative.
|
||||
|
||||
Returns:
|
||||
A deprecation message string for IDE/type checker display.
|
||||
"""
|
||||
if alternative_import:
|
||||
return f"Use {alternative_import} instead."
|
||||
if alternative:
|
||||
return f"Use {alternative} instead."
|
||||
return "Deprecated."
|
||||
|
||||
|
||||
class LangChainDeprecationWarning(DeprecationWarning):
|
||||
"""A class for issuing deprecation warnings for LangChain users."""
|
||||
|
||||
|
||||
class LangChainPendingDeprecationWarning(PendingDeprecationWarning):
|
||||
"""A class for issuing deprecation warnings for LangChain users."""
|
||||
|
||||
|
||||
# PUBLIC API
|
||||
|
||||
|
||||
# Bound is `Any` (not `FieldInfoV1`) because importing `pydantic.v1` at module
|
||||
# scope emits a `UserWarning` on Python 3.14+; v1 `FieldInfo` support is handled
|
||||
# at runtime via `_is_pydantic_v1_field_info`.
|
||||
T = TypeVar("T", bound=type | Callable[..., Any] | Any)
|
||||
|
||||
|
||||
def _validate_deprecation_params(
|
||||
removal: str,
|
||||
alternative: str,
|
||||
alternative_import: str,
|
||||
*,
|
||||
pending: bool,
|
||||
) -> None:
|
||||
"""Validate the deprecation parameters."""
|
||||
if pending and removal:
|
||||
msg = "A pending deprecation cannot have a scheduled removal"
|
||||
raise ValueError(msg)
|
||||
if alternative and alternative_import:
|
||||
msg = "Cannot specify both alternative and alternative_import"
|
||||
raise ValueError(msg)
|
||||
|
||||
if alternative_import and "." not in alternative_import:
|
||||
msg = (
|
||||
"alternative_import must be a fully qualified module path. Got "
|
||||
f" {alternative_import}"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def deprecated(
|
||||
since: str,
|
||||
*,
|
||||
message: str = "",
|
||||
name: str = "",
|
||||
alternative: str = "",
|
||||
alternative_import: str = "",
|
||||
pending: bool = False,
|
||||
obj_type: str = "",
|
||||
addendum: str = "",
|
||||
removal: str = "",
|
||||
package: str = "",
|
||||
) -> Callable[[T], T]:
|
||||
"""Decorator to mark a function, a class, or a property as deprecated.
|
||||
|
||||
When deprecating a classmethod, a staticmethod, or a property, the `@deprecated`
|
||||
decorator should go *under* `@classmethod` and `@staticmethod` (i.e., `deprecated`
|
||||
should directly decorate the underlying callable), but *over* `@property`.
|
||||
|
||||
When deprecating a class `C` intended to be used as a base class in a multiple
|
||||
inheritance hierarchy, `C` *must* define an `__init__` method (if `C` instead
|
||||
inherited its `__init__` from its own base class, then `@deprecated` would mess up
|
||||
`__init__` inheritance when installing its own (deprecation-emitting) `C.__init__`).
|
||||
|
||||
Parameters are the same as for `warn_deprecated`, except that *obj_type* defaults to
|
||||
'class' if decorating a class, 'attribute' if decorating a property, and 'function'
|
||||
otherwise.
|
||||
|
||||
Args:
|
||||
since: The release at which this API became deprecated.
|
||||
message: Override the default deprecation message.
|
||||
|
||||
The `%(since)s`, `%(name)s`, `%(alternative)s`, `%(obj_type)s`,
|
||||
`%(addendum)s`, and `%(removal)s` format specifiers will be replaced by the
|
||||
values of the respective arguments passed to this function.
|
||||
name: The name of the deprecated object.
|
||||
alternative: An alternative API that the user may use in place of the deprecated
|
||||
API.
|
||||
|
||||
The deprecation warning will tell the user about this alternative if
|
||||
provided.
|
||||
alternative_import: An alternative import that the user may use instead.
|
||||
pending: If `True`, uses a `PendingDeprecationWarning` instead of a
|
||||
`DeprecationWarning`.
|
||||
|
||||
Cannot be used together with removal.
|
||||
obj_type: The object type being deprecated.
|
||||
addendum: Additional text appended directly to the final message.
|
||||
removal: The expected removal version.
|
||||
|
||||
With the default (an empty string), no removal version is shown in the
|
||||
warning message.
|
||||
|
||||
Cannot be used together with pending.
|
||||
package: The package of the deprecated object.
|
||||
|
||||
Returns:
|
||||
A decorator to mark a function or class as deprecated.
|
||||
|
||||
Example:
|
||||
```python
|
||||
@deprecated("1.4.0")
|
||||
def the_function_to_deprecate():
|
||||
pass
|
||||
```
|
||||
"""
|
||||
_validate_deprecation_params(
|
||||
removal, alternative, alternative_import, pending=pending
|
||||
)
|
||||
|
||||
def deprecate(
|
||||
obj: T,
|
||||
*,
|
||||
_obj_type: str = obj_type,
|
||||
_name: str = name,
|
||||
_message: str = message,
|
||||
_alternative: str = alternative,
|
||||
_alternative_import: str = alternative_import,
|
||||
_pending: bool = pending,
|
||||
_addendum: str = addendum,
|
||||
_package: str = package,
|
||||
) -> T:
|
||||
"""Implementation of the decorator returned by `deprecated`."""
|
||||
|
||||
def emit_warning() -> None:
|
||||
"""Emit the warning."""
|
||||
warn_deprecated(
|
||||
since,
|
||||
message=_message,
|
||||
name=_name,
|
||||
alternative=_alternative,
|
||||
alternative_import=_alternative_import,
|
||||
pending=_pending,
|
||||
obj_type=_obj_type,
|
||||
addendum=_addendum,
|
||||
removal=removal,
|
||||
package=_package,
|
||||
)
|
||||
|
||||
warned = False
|
||||
|
||||
def warning_emitting_wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
"""Wrapper for the original wrapped callable that emits a warning.
|
||||
|
||||
Args:
|
||||
*args: The positional arguments to the function.
|
||||
**kwargs: The keyword arguments to the function.
|
||||
|
||||
Returns:
|
||||
The return value of the function being wrapped.
|
||||
"""
|
||||
nonlocal warned
|
||||
if not warned and not is_caller_internal():
|
||||
warned = True
|
||||
emit_warning()
|
||||
return wrapped(*args, **kwargs)
|
||||
|
||||
async def awarning_emitting_wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
"""Same as warning_emitting_wrapper, but for async functions."""
|
||||
nonlocal warned
|
||||
if not warned and not is_caller_internal():
|
||||
warned = True
|
||||
emit_warning()
|
||||
return await wrapped(*args, **kwargs)
|
||||
|
||||
_package = _package or obj.__module__.split(".")[0].replace("_", "-")
|
||||
|
||||
if isinstance(obj, type):
|
||||
if not _obj_type:
|
||||
_obj_type = "class"
|
||||
wrapped = obj.__init__ # type: ignore[misc]
|
||||
_name = _name or obj.__qualname__
|
||||
old_doc = obj.__doc__
|
||||
|
||||
def finalize(_: Callable[..., Any], new_doc: str, /) -> T:
|
||||
"""Finalize the deprecation of a class."""
|
||||
# Can't set new_doc on some extension objects.
|
||||
with contextlib.suppress(AttributeError):
|
||||
obj.__doc__ = new_doc
|
||||
|
||||
def warn_if_direct_instance(
|
||||
self: Any, *args: Any, **kwargs: Any
|
||||
) -> Any:
|
||||
"""Warn that the class is in beta."""
|
||||
nonlocal warned
|
||||
if not warned and type(self) is obj and not is_caller_internal():
|
||||
warned = True
|
||||
emit_warning()
|
||||
return wrapped(self, *args, **kwargs)
|
||||
|
||||
obj.__init__ = functools.wraps(obj.__init__)( # type: ignore[misc]
|
||||
warn_if_direct_instance
|
||||
)
|
||||
# Set __deprecated__ for PEP 702 (IDE/type checker support)
|
||||
obj.__deprecated__ = _build_deprecation_message( # type: ignore[attr-defined]
|
||||
alternative=alternative,
|
||||
alternative_import=alternative_import,
|
||||
)
|
||||
return obj
|
||||
|
||||
elif _is_pydantic_v1_field_info(obj):
|
||||
wrapped = None
|
||||
if not _obj_type:
|
||||
_obj_type = "attribute"
|
||||
if not _name:
|
||||
msg = f"Field {obj} must have a name to be deprecated."
|
||||
raise ValueError(msg)
|
||||
old_doc = obj.description
|
||||
|
||||
def finalize(_: Callable[..., Any], new_doc: str, /) -> T:
|
||||
from pydantic.v1.fields import FieldInfo as FieldInfoV1 # noqa: PLC0415
|
||||
|
||||
return cast(
|
||||
"T",
|
||||
FieldInfoV1(
|
||||
default=obj.default,
|
||||
default_factory=obj.default_factory,
|
||||
description=new_doc,
|
||||
alias=obj.alias,
|
||||
exclude=obj.exclude,
|
||||
),
|
||||
)
|
||||
|
||||
elif isinstance(obj, FieldInfo):
|
||||
wrapped = None
|
||||
if not _obj_type:
|
||||
_obj_type = "attribute"
|
||||
if not _name:
|
||||
msg = f"Field {obj} must have a name to be deprecated."
|
||||
raise ValueError(msg)
|
||||
old_doc = obj.description
|
||||
|
||||
def finalize(_: Callable[..., Any], new_doc: str, /) -> T:
|
||||
return cast(
|
||||
"T",
|
||||
FieldInfo(
|
||||
default=obj.default,
|
||||
default_factory=obj.default_factory,
|
||||
description=new_doc,
|
||||
alias=obj.alias,
|
||||
exclude=obj.exclude,
|
||||
),
|
||||
)
|
||||
|
||||
elif isinstance(obj, property):
|
||||
if not _obj_type:
|
||||
_obj_type = "attribute"
|
||||
wrapped = None
|
||||
_name = _name or cast("type | Callable", obj.fget).__qualname__
|
||||
old_doc = obj.__doc__
|
||||
|
||||
class _DeprecatedProperty(property):
|
||||
"""A deprecated property."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
fget: Callable[[Any], Any] | None = None,
|
||||
fset: Callable[[Any, Any], None] | None = None,
|
||||
fdel: Callable[[Any], None] | None = None,
|
||||
doc: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(fget, fset, fdel, doc)
|
||||
self.__orig_fget = fget
|
||||
self.__orig_fset = fset
|
||||
self.__orig_fdel = fdel
|
||||
|
||||
def __get__(self, instance: Any, owner: type | None = None) -> Any:
|
||||
if instance is not None or owner is not None:
|
||||
emit_warning()
|
||||
if self.fget is None:
|
||||
return None
|
||||
return self.fget(instance)
|
||||
|
||||
def __set__(self, instance: Any, value: Any) -> None:
|
||||
if instance is not None:
|
||||
emit_warning()
|
||||
if self.fset is not None:
|
||||
self.fset(instance, value)
|
||||
|
||||
def __delete__(self, instance: Any) -> None:
|
||||
if instance is not None:
|
||||
emit_warning()
|
||||
if self.fdel is not None:
|
||||
self.fdel(instance)
|
||||
|
||||
def __set_name__(self, owner: type | None, set_name: str) -> None:
|
||||
nonlocal _name
|
||||
if _name == "<lambda>":
|
||||
_name = set_name
|
||||
|
||||
def finalize(_: Callable[..., Any], new_doc: str, /) -> T:
|
||||
"""Finalize the property."""
|
||||
prop = _DeprecatedProperty(
|
||||
fget=obj.fget, fset=obj.fset, fdel=obj.fdel, doc=new_doc
|
||||
)
|
||||
# Set __deprecated__ for PEP 702 (IDE/type checker support)
|
||||
prop.__deprecated__ = _build_deprecation_message( # type: ignore[attr-defined]
|
||||
alternative=alternative,
|
||||
alternative_import=alternative_import,
|
||||
)
|
||||
return cast("T", prop)
|
||||
|
||||
else:
|
||||
_name = _name or cast("type | Callable", obj).__qualname__
|
||||
if not _obj_type:
|
||||
# edge case: when a function is within another function
|
||||
# within a test, this will call it a "method" not a "function"
|
||||
_obj_type = "function" if "." not in _name else "method"
|
||||
wrapped = obj
|
||||
old_doc = wrapped.__doc__
|
||||
|
||||
def finalize(wrapper: Callable[..., Any], new_doc: str, /) -> T:
|
||||
"""Wrap the wrapped function using the wrapper and update the docstring.
|
||||
|
||||
Args:
|
||||
wrapper: The wrapper function.
|
||||
new_doc: The new docstring.
|
||||
|
||||
Returns:
|
||||
The wrapped function.
|
||||
"""
|
||||
wrapper = functools.wraps(wrapped)(wrapper)
|
||||
wrapper.__doc__ = new_doc
|
||||
# Set __deprecated__ for PEP 702 (IDE/type checker support)
|
||||
wrapper.__deprecated__ = _build_deprecation_message( # type: ignore[attr-defined]
|
||||
alternative=alternative,
|
||||
alternative_import=alternative_import,
|
||||
)
|
||||
return cast("T", wrapper)
|
||||
|
||||
old_doc = inspect.cleandoc(old_doc or "").strip("\n")
|
||||
|
||||
# old_doc can be None
|
||||
if not old_doc:
|
||||
old_doc = ""
|
||||
|
||||
# Modify the docstring to include a deprecation notice.
|
||||
if (
|
||||
_alternative
|
||||
and _alternative.rsplit(".", maxsplit=1)[-1].lower()
|
||||
== _alternative.rsplit(".", maxsplit=1)[-1]
|
||||
) or _alternative:
|
||||
_alternative = f"`{_alternative}`"
|
||||
|
||||
if (
|
||||
_alternative_import
|
||||
and _alternative_import.rsplit(".", maxsplit=1)[-1].lower()
|
||||
== _alternative_import.rsplit(".", maxsplit=1)[-1]
|
||||
) or _alternative_import:
|
||||
_alternative_import = f"`{_alternative_import}`"
|
||||
|
||||
components = [
|
||||
_message,
|
||||
f"Use {_alternative} instead." if _alternative else "",
|
||||
f"Use {_alternative_import} instead." if _alternative_import else "",
|
||||
_addendum,
|
||||
]
|
||||
details = " ".join([component.strip() for component in components if component])
|
||||
package = _package or (
|
||||
_name.split(".")[0].replace("_", "-") if "." in _name else None
|
||||
)
|
||||
if removal:
|
||||
if removal.startswith("1.") and package and package.startswith("langchain"):
|
||||
removal_str = f"It will not be removed until {package}=={removal}."
|
||||
else:
|
||||
removal_str = f"It will be removed in {package}=={removal}."
|
||||
else:
|
||||
removal_str = ""
|
||||
new_doc = f"""\
|
||||
!!! deprecated "{since} {details} {removal_str}"
|
||||
|
||||
{old_doc}\
|
||||
"""
|
||||
|
||||
if inspect.iscoroutinefunction(obj):
|
||||
return finalize(awarning_emitting_wrapper, new_doc)
|
||||
return finalize(warning_emitting_wrapper, new_doc)
|
||||
|
||||
return deprecate
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def suppress_langchain_deprecation_warning() -> Generator[None, None, None]:
|
||||
"""Context manager to suppress `LangChainDeprecationWarning`."""
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", LangChainDeprecationWarning)
|
||||
warnings.simplefilter("ignore", LangChainPendingDeprecationWarning)
|
||||
yield
|
||||
|
||||
|
||||
def warn_deprecated(
|
||||
since: str,
|
||||
*,
|
||||
message: str = "",
|
||||
name: str = "",
|
||||
alternative: str = "",
|
||||
alternative_import: str = "",
|
||||
pending: bool = False,
|
||||
obj_type: str = "",
|
||||
addendum: str = "",
|
||||
removal: str = "",
|
||||
package: str = "",
|
||||
) -> None:
|
||||
"""Display a standardized deprecation.
|
||||
|
||||
Args:
|
||||
since: The release at which this API became deprecated.
|
||||
message: Override the default deprecation message.
|
||||
|
||||
The `%(since)s`, `%(name)s`, `%(alternative)s`, `%(obj_type)s`,
|
||||
`%(addendum)s`, and `%(removal)s` format specifiers will be replaced by the
|
||||
values of the respective arguments passed to this function.
|
||||
name: The name of the deprecated object.
|
||||
alternative: An alternative API that the user may use in place of the
|
||||
deprecated API.
|
||||
|
||||
The deprecation warning will tell the user about this alternative if
|
||||
provided.
|
||||
alternative_import: An alternative import that the user may use instead.
|
||||
pending: If `True`, uses a `PendingDeprecationWarning` instead of a
|
||||
`DeprecationWarning`.
|
||||
|
||||
Cannot be used together with removal.
|
||||
obj_type: The object type being deprecated.
|
||||
addendum: Additional text appended directly to the final message.
|
||||
removal: The expected removal version.
|
||||
|
||||
With the default (an empty string), no removal version is shown in the
|
||||
warning message.
|
||||
|
||||
Cannot be used together with pending.
|
||||
package: The package of the deprecated object.
|
||||
"""
|
||||
if not pending and removal:
|
||||
removal = f"in {removal}"
|
||||
|
||||
if not message:
|
||||
message = ""
|
||||
package_ = (
|
||||
package or name.split(".", maxsplit=1)[0].replace("_", "-")
|
||||
if "." in name
|
||||
else "LangChain"
|
||||
)
|
||||
|
||||
if obj_type:
|
||||
message += f"The {obj_type} `{name}`"
|
||||
else:
|
||||
message += f"`{name}`"
|
||||
|
||||
if pending:
|
||||
message += " will be deprecated in a future version"
|
||||
else:
|
||||
message += f" was deprecated in {package_} {since}"
|
||||
|
||||
if removal:
|
||||
message += f" and will be removed {removal}"
|
||||
|
||||
if alternative_import:
|
||||
alt_package = alternative_import.split(".", maxsplit=1)[0].replace("_", "-")
|
||||
if alt_package == package_:
|
||||
message += f". Use {alternative_import} instead."
|
||||
else:
|
||||
alt_module, alt_name = alternative_import.rsplit(".", 1)
|
||||
message += (
|
||||
f". An updated version of the {obj_type} exists in the "
|
||||
f"{alt_package} package and should be used instead. To use it run "
|
||||
f"`pip install -U {alt_package}` and import as "
|
||||
f"`from {alt_module} import {alt_name}`."
|
||||
)
|
||||
elif alternative:
|
||||
message += f". Use {alternative} instead."
|
||||
|
||||
if addendum:
|
||||
message += f" {addendum}"
|
||||
|
||||
warning_cls = (
|
||||
LangChainPendingDeprecationWarning if pending else LangChainDeprecationWarning
|
||||
)
|
||||
warning = warning_cls(message)
|
||||
warnings.warn(warning, category=LangChainDeprecationWarning, stacklevel=4)
|
||||
|
||||
|
||||
def surface_langchain_deprecation_warnings() -> None:
|
||||
"""Unmute LangChain deprecation warnings."""
|
||||
warnings.filterwarnings(
|
||||
"default",
|
||||
category=LangChainPendingDeprecationWarning,
|
||||
)
|
||||
|
||||
warnings.filterwarnings(
|
||||
"default",
|
||||
category=LangChainDeprecationWarning,
|
||||
)
|
||||
|
||||
|
||||
_P = ParamSpec("_P")
|
||||
_R = TypeVar("_R")
|
||||
|
||||
|
||||
def rename_parameter(
|
||||
*,
|
||||
since: str,
|
||||
removal: str,
|
||||
old: str,
|
||||
new: str,
|
||||
) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]:
|
||||
"""Decorator indicating that parameter *old* of *func* is renamed to *new*.
|
||||
|
||||
The actual implementation of *func* should use *new*, not *old*. If *old* is passed
|
||||
to *func*, a `DeprecationWarning` is emitted, and its value is used, even if *new*
|
||||
is also passed by keyword.
|
||||
|
||||
Args:
|
||||
since: The version in which the parameter was renamed.
|
||||
removal: The version in which the old parameter will be removed.
|
||||
old: The old parameter name.
|
||||
new: The new parameter name.
|
||||
|
||||
Returns:
|
||||
A decorator indicating that a parameter was renamed.
|
||||
|
||||
Example:
|
||||
```python
|
||||
@_api.rename_parameter("3.1", "bad_name", "good_name")
|
||||
def func(good_name): ...
|
||||
```
|
||||
"""
|
||||
|
||||
def decorator(f: Callable[_P, _R]) -> Callable[_P, _R]:
|
||||
@functools.wraps(f)
|
||||
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
|
||||
if new in kwargs and old in kwargs:
|
||||
msg = f"{f.__name__}() got multiple values for argument {new!r}"
|
||||
raise TypeError(msg)
|
||||
if old in kwargs:
|
||||
warn_deprecated(
|
||||
since,
|
||||
removal=removal,
|
||||
message=f"The parameter `{old}` of `{f.__name__}` was "
|
||||
f"deprecated in {since} and will be removed "
|
||||
f"in {removal} Use `{new}` instead.",
|
||||
)
|
||||
kwargs[new] = kwargs.pop(old)
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
@@ -0,0 +1,23 @@
|
||||
import inspect
|
||||
from typing import cast
|
||||
|
||||
|
||||
def is_caller_internal(depth: int = 2) -> bool:
|
||||
"""Return whether the caller at `depth` of this function is internal."""
|
||||
try:
|
||||
frame = inspect.currentframe()
|
||||
except AttributeError:
|
||||
return False
|
||||
if frame is None:
|
||||
return False
|
||||
try:
|
||||
for _ in range(depth):
|
||||
frame = frame.f_back
|
||||
if frame is None:
|
||||
return False
|
||||
# Directly access the module name from the frame's global variables
|
||||
module_globals = frame.f_globals
|
||||
caller_module_name = cast("str", module_globals.get("__name__", ""))
|
||||
return caller_module_name.startswith("langchain")
|
||||
finally:
|
||||
del frame
|
||||
@@ -0,0 +1,50 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).parent
|
||||
|
||||
# Get directory of langchain package
|
||||
PACKAGE_DIR = HERE.parent
|
||||
SEPARATOR = os.sep
|
||||
|
||||
|
||||
def get_relative_path(file: Path | str, *, relative_to: Path = PACKAGE_DIR) -> str:
|
||||
"""Get the path of the file as a relative path to the package directory.
|
||||
|
||||
Args:
|
||||
file: The file path to convert.
|
||||
relative_to: The base path to make the file path relative to.
|
||||
|
||||
Returns:
|
||||
The relative path as a string.
|
||||
"""
|
||||
if isinstance(file, str):
|
||||
file = Path(file)
|
||||
return str(file.relative_to(relative_to))
|
||||
|
||||
|
||||
def as_import_path(
|
||||
file: Path | str,
|
||||
*,
|
||||
suffix: str | None = None,
|
||||
relative_to: Path = PACKAGE_DIR,
|
||||
) -> str:
|
||||
"""Path of the file as a LangChain import exclude langchain top namespace.
|
||||
|
||||
Args:
|
||||
file: The file path to convert.
|
||||
suffix: An optional suffix to append to the import path.
|
||||
relative_to: The base path to make the file path relative to.
|
||||
|
||||
Returns:
|
||||
The import path as a string.
|
||||
"""
|
||||
if isinstance(file, str):
|
||||
file = Path(file)
|
||||
path = get_relative_path(file, relative_to=relative_to)
|
||||
if file.is_file():
|
||||
path = path[: -len(file.suffix)]
|
||||
import_path = path.replace(SEPARATOR, ".")
|
||||
if suffix:
|
||||
import_path += "." + suffix
|
||||
return import_path
|
||||
@@ -0,0 +1,41 @@
|
||||
from importlib import import_module
|
||||
|
||||
|
||||
def import_attr(
|
||||
attr_name: str,
|
||||
module_name: str | None,
|
||||
package: str | None,
|
||||
) -> object:
|
||||
"""Import an attribute from a module located in a package.
|
||||
|
||||
This utility function is used in custom `__getattr__` methods within `__init__.py`
|
||||
files to dynamically import attributes.
|
||||
|
||||
Args:
|
||||
attr_name: The name of the attribute to import.
|
||||
module_name: The name of the module to import from.
|
||||
|
||||
If `None`, the attribute is imported from the package itself.
|
||||
package: The name of the package where the module is located.
|
||||
|
||||
Raises:
|
||||
ImportError: If the module cannot be found.
|
||||
AttributeError: If the attribute does not exist in the module or package.
|
||||
|
||||
Returns:
|
||||
The imported attribute.
|
||||
"""
|
||||
if module_name == "__module__" or module_name is None:
|
||||
try:
|
||||
result = import_module(f".{attr_name}", package=package)
|
||||
except ModuleNotFoundError:
|
||||
msg = f"module '{package!r}' has no attribute {attr_name!r}"
|
||||
raise AttributeError(msg) from None
|
||||
else:
|
||||
try:
|
||||
module = import_module(f".{module_name}", package=package)
|
||||
except ModuleNotFoundError as err:
|
||||
msg = f"module '{package!r}.{module_name!r}' not found ({err})"
|
||||
raise ImportError(msg) from None
|
||||
result = getattr(module, attr_name)
|
||||
return result
|
||||
@@ -0,0 +1,36 @@
|
||||
"""SSRF protection and security utilities.
|
||||
|
||||
This is an **internal** module (note the `_security` prefix). It is NOT part of
|
||||
the public `langchain-core` API and may change or be removed at any time without
|
||||
notice. External code should not import from or depend on anything in this
|
||||
module. Any vulnerability reports should target the public APIs that use these
|
||||
utilities, not this internal module directly.
|
||||
"""
|
||||
|
||||
from langchain_core._security._exceptions import SSRFBlockedError
|
||||
from langchain_core._security._policy import (
|
||||
SSRFPolicy,
|
||||
validate_hostname,
|
||||
validate_resolved_ip,
|
||||
validate_url,
|
||||
validate_url_sync,
|
||||
)
|
||||
from langchain_core._security._transport import (
|
||||
SSRFSafeSyncTransport,
|
||||
SSRFSafeTransport,
|
||||
ssrf_safe_async_client,
|
||||
ssrf_safe_client,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"SSRFBlockedError",
|
||||
"SSRFPolicy",
|
||||
"SSRFSafeSyncTransport",
|
||||
"SSRFSafeTransport",
|
||||
"ssrf_safe_async_client",
|
||||
"ssrf_safe_client",
|
||||
"validate_hostname",
|
||||
"validate_resolved_ip",
|
||||
"validate_url",
|
||||
"validate_url_sync",
|
||||
]
|
||||
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,9 @@
|
||||
"""SSRF protection exceptions."""
|
||||
|
||||
|
||||
class SSRFBlockedError(Exception):
|
||||
"""Raised when a request is blocked by SSRF protection policy."""
|
||||
|
||||
def __init__(self, reason: str) -> None:
|
||||
self.reason = reason
|
||||
super().__init__(f"SSRF blocked: {reason}")
|
||||
@@ -0,0 +1,306 @@
|
||||
"""SSRF protection policy with IP validation and DNS-aware URL checking."""
|
||||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import ipaddress
|
||||
import os
|
||||
import socket
|
||||
import urllib.parse
|
||||
|
||||
from langchain_core._security._exceptions import SSRFBlockedError
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Blocklist constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_BLOCKED_IPV4_NETWORKS: tuple[ipaddress.IPv4Network, ...] = tuple(
|
||||
ipaddress.IPv4Network(n)
|
||||
for n in (
|
||||
"10.0.0.0/8", # RFC 1918 - private class A
|
||||
"172.16.0.0/12", # RFC 1918 - private class B
|
||||
"192.168.0.0/16", # RFC 1918 - private class C
|
||||
"127.0.0.0/8", # RFC 1122 - loopback
|
||||
"169.254.0.0/16", # RFC 3927 - link-local
|
||||
"0.0.0.0/8", # RFC 1122 - "this network"
|
||||
"100.64.0.0/10", # RFC 6598 - shared/CGN address space
|
||||
"192.0.0.0/24", # RFC 6890 - IETF protocol assignments
|
||||
"192.0.2.0/24", # RFC 5737 - TEST-NET-1 (documentation)
|
||||
"198.18.0.0/15", # RFC 2544 - benchmarking
|
||||
"198.51.100.0/24", # RFC 5737 - TEST-NET-2 (documentation)
|
||||
"203.0.113.0/24", # RFC 5737 - TEST-NET-3 (documentation)
|
||||
"224.0.0.0/4", # RFC 5771 - multicast
|
||||
"240.0.0.0/4", # RFC 1112 - reserved for future use
|
||||
"255.255.255.255/32", # RFC 919 - limited broadcast
|
||||
)
|
||||
)
|
||||
|
||||
_BLOCKED_IPV6_NETWORKS: tuple[ipaddress.IPv6Network, ...] = tuple(
|
||||
ipaddress.IPv6Network(n)
|
||||
for n in (
|
||||
"::1/128", # RFC 4291 - loopback
|
||||
"fc00::/7", # RFC 4193 - unique local addresses (ULA)
|
||||
"fe80::/10", # RFC 4291 - link-local
|
||||
"ff00::/8", # RFC 4291 - multicast
|
||||
"::ffff:0:0/96", # RFC 4291 - IPv4-mapped IPv6 addresses
|
||||
"::0.0.0.0/96", # RFC 4291 - IPv4-compatible IPv6 (deprecated)
|
||||
"64:ff9b::/96", # RFC 6052 - NAT64 well-known prefix
|
||||
"64:ff9b:1::/48", # RFC 8215 - NAT64 discovery prefix
|
||||
)
|
||||
)
|
||||
|
||||
_CLOUD_METADATA_IPS: frozenset[str] = frozenset(
|
||||
{
|
||||
"169.254.169.254", # AWS, GCP, Azure, DigitalOcean, Oracle Cloud
|
||||
"169.254.170.2", # AWS ECS task metadata
|
||||
"169.254.170.23", # AWS EKS Pod Identity Agent
|
||||
"100.100.100.200", # Alibaba Cloud metadata
|
||||
"fd00:ec2::254", # AWS EC2 IMDSv2 over IPv6 (Nitro instances)
|
||||
"fd00:ec2::23", # AWS EKS Pod Identity Agent (IPv6)
|
||||
"fe80::a9fe:a9fe", # OpenStack Nova metadata (IPv6 link-local)
|
||||
}
|
||||
)
|
||||
|
||||
# Network ranges that are always blocked when block_cloud_metadata=True,
|
||||
# independent of block_private_ips. The entire link-local range is used by
|
||||
# cloud metadata services across providers.
|
||||
_CLOUD_METADATA_NETWORKS: tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...] = (
|
||||
ipaddress.IPv4Network("169.254.0.0/16"),
|
||||
)
|
||||
|
||||
_CLOUD_METADATA_HOSTNAMES: frozenset[str] = frozenset(
|
||||
{
|
||||
"metadata.google.internal",
|
||||
"metadata.amazonaws.com",
|
||||
"metadata",
|
||||
"instance-data",
|
||||
}
|
||||
)
|
||||
|
||||
_LOCALHOST_NAMES: frozenset[str] = frozenset(
|
||||
{
|
||||
"localhost",
|
||||
"localhost.localdomain",
|
||||
"host.docker.internal",
|
||||
}
|
||||
)
|
||||
|
||||
_K8S_SUFFIX = ".svc.cluster.local"
|
||||
|
||||
_LOOPBACK_IPV4 = ipaddress.IPv4Network("127.0.0.0/8")
|
||||
_LOOPBACK_IPV6 = ipaddress.IPv6Address("::1")
|
||||
|
||||
# NAT64 well-known prefixes
|
||||
_NAT64_PREFIX = ipaddress.IPv6Network("64:ff9b::/96")
|
||||
_NAT64_DISCOVERY_PREFIX = ipaddress.IPv6Network("64:ff9b:1::/48")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSRFPolicy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class SSRFPolicy:
|
||||
"""Immutable policy controlling which URLs/IPs are considered safe."""
|
||||
|
||||
allowed_schemes: frozenset[str] = frozenset({"http", "https"})
|
||||
block_private_ips: bool = True
|
||||
block_localhost: bool = True
|
||||
block_cloud_metadata: bool = True
|
||||
block_k8s_internal: bool = True
|
||||
allowed_hosts: frozenset[str] = frozenset()
|
||||
additional_blocked_cidrs: tuple[
|
||||
ipaddress.IPv4Network | ipaddress.IPv6Network, ...
|
||||
] = ()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _extract_embedded_ipv4(
|
||||
addr: ipaddress.IPv6Address,
|
||||
) -> ipaddress.IPv4Address | None:
|
||||
"""Extract an embedded IPv4 from IPv4-mapped or NAT64 IPv6 addresses."""
|
||||
# Check ipv4_mapped first (covers ::ffff:x.x.x.x)
|
||||
if addr.ipv4_mapped is not None:
|
||||
return addr.ipv4_mapped
|
||||
|
||||
# Check NAT64 prefixes — embedded IPv4 is in the last 4 bytes
|
||||
if addr in _NAT64_PREFIX or addr in _NAT64_DISCOVERY_PREFIX:
|
||||
raw = addr.packed
|
||||
return ipaddress.IPv4Address(raw[-4:])
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _ip_in_blocked_networks(
|
||||
addr: ipaddress.IPv4Address | ipaddress.IPv6Address,
|
||||
policy: SSRFPolicy,
|
||||
) -> str | None:
|
||||
"""Return a reason string if *addr* falls in a blocked range, else None."""
|
||||
# NOTE: if profiling shows this is a hot path, consider memoising with
|
||||
# @functools.lru_cache (key on (addr, id(policy))).
|
||||
if isinstance(addr, ipaddress.IPv4Address):
|
||||
if policy.block_private_ips:
|
||||
for net in _BLOCKED_IPV4_NETWORKS:
|
||||
if addr in net:
|
||||
return "private IP range"
|
||||
for net in policy.additional_blocked_cidrs: # type: ignore[assignment]
|
||||
if isinstance(net, ipaddress.IPv4Network) and addr in net:
|
||||
return "blocked CIDR"
|
||||
else:
|
||||
if policy.block_private_ips:
|
||||
for net in _BLOCKED_IPV6_NETWORKS: # type: ignore[assignment]
|
||||
if addr in net:
|
||||
return "private IP range"
|
||||
for net in policy.additional_blocked_cidrs: # type: ignore[assignment]
|
||||
if isinstance(net, ipaddress.IPv6Network) and addr in net:
|
||||
return "blocked CIDR"
|
||||
|
||||
# Loopback check — independent of block_private_ips so that
|
||||
# block_localhost=True still catches 127.x.x.x / ::1 even when
|
||||
# private IPs are allowed.
|
||||
if policy.block_localhost:
|
||||
if isinstance(addr, ipaddress.IPv4Address) and (
|
||||
addr in _LOOPBACK_IPV4 or addr in ipaddress.IPv4Network("0.0.0.0/8")
|
||||
):
|
||||
return "localhost address"
|
||||
if isinstance(addr, ipaddress.IPv6Address) and addr == _LOOPBACK_IPV6:
|
||||
return "localhost address"
|
||||
|
||||
# Cloud metadata check — IP set *and* network ranges (e.g. 169.254.0.0/16).
|
||||
# Independent of block_private_ips so that allow_private=True still blocks
|
||||
# cloud metadata endpoints.
|
||||
if policy.block_cloud_metadata:
|
||||
if str(addr) in _CLOUD_METADATA_IPS:
|
||||
return "cloud metadata endpoint"
|
||||
for net in _CLOUD_METADATA_NETWORKS: # type: ignore[assignment]
|
||||
if addr in net:
|
||||
return "cloud metadata endpoint"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public validation functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def validate_resolved_ip(ip_str: str, policy: SSRFPolicy) -> None:
|
||||
"""Validate a resolved IP address against the SSRF policy.
|
||||
|
||||
Raises SSRFBlockedError if the IP is blocked.
|
||||
"""
|
||||
try:
|
||||
addr = ipaddress.ip_address(ip_str)
|
||||
except ValueError as exc:
|
||||
raise SSRFBlockedError("invalid IP address") from exc
|
||||
|
||||
if isinstance(addr, ipaddress.IPv6Address):
|
||||
inner = _extract_embedded_ipv4(addr)
|
||||
if inner is not None:
|
||||
addr = inner
|
||||
|
||||
reason = _ip_in_blocked_networks(addr, policy)
|
||||
if reason is not None:
|
||||
raise SSRFBlockedError(reason)
|
||||
|
||||
|
||||
def validate_hostname(hostname: str, policy: SSRFPolicy) -> None:
|
||||
"""Validate a hostname against the SSRF policy.
|
||||
|
||||
Raises SSRFBlockedError if the hostname is blocked.
|
||||
"""
|
||||
lower = hostname.lower()
|
||||
|
||||
if policy.block_localhost and lower in _LOCALHOST_NAMES:
|
||||
raise SSRFBlockedError("localhost address")
|
||||
|
||||
if policy.block_cloud_metadata and lower in _CLOUD_METADATA_HOSTNAMES:
|
||||
raise SSRFBlockedError("cloud metadata endpoint")
|
||||
|
||||
if policy.block_k8s_internal and lower.endswith(_K8S_SUFFIX):
|
||||
raise SSRFBlockedError("Kubernetes internal DNS")
|
||||
|
||||
|
||||
def _effective_allowed_hosts(policy: SSRFPolicy) -> frozenset[str]:
|
||||
"""Return allowed_hosts, augmented for local environments."""
|
||||
extra: set[str] = set()
|
||||
if os.environ.get("LANGCHAIN_ENV", "").startswith("local"):
|
||||
extra.update({"localhost", "testserver"})
|
||||
if extra:
|
||||
return policy.allowed_hosts | frozenset(extra)
|
||||
return policy.allowed_hosts
|
||||
|
||||
|
||||
async def validate_url(url: str, policy: SSRFPolicy = SSRFPolicy()) -> None:
|
||||
"""Validate a URL against the SSRF policy, including DNS resolution.
|
||||
|
||||
This is the primary entry-point for async code paths. It delegates
|
||||
scheme/hostname/allowed-hosts checks to `validate_url_sync`, then
|
||||
resolves DNS and validates every resolved IP.
|
||||
|
||||
Raises:
|
||||
SSRFBlockedError: If the URL violates the policy.
|
||||
"""
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
hostname = parsed.hostname or ""
|
||||
|
||||
validate_url_sync(url, policy)
|
||||
|
||||
allowed = {h.lower() for h in _effective_allowed_hosts(policy)}
|
||||
if hostname.lower() in allowed:
|
||||
return
|
||||
|
||||
scheme = (parsed.scheme or "").lower()
|
||||
port = parsed.port or (443 if scheme == "https" else 80)
|
||||
try:
|
||||
addrinfo = await asyncio.to_thread(
|
||||
socket.getaddrinfo, hostname, port, type=socket.SOCK_STREAM
|
||||
)
|
||||
except socket.gaierror as exc:
|
||||
msg = "DNS resolution failed"
|
||||
raise SSRFBlockedError(msg) from exc
|
||||
|
||||
for _family, _type, _proto, _canonname, sockaddr in addrinfo:
|
||||
validate_resolved_ip(str(sockaddr[0]), policy)
|
||||
|
||||
|
||||
def validate_url_sync(url: str, policy: SSRFPolicy = SSRFPolicy()) -> None:
|
||||
"""Synchronous URL validation (no DNS resolution).
|
||||
|
||||
Suitable for Pydantic validators and other sync contexts. Checks scheme
|
||||
and hostname patterns only - use `validate_url` for full DNS-aware checking.
|
||||
|
||||
Raises:
|
||||
SSRFBlockedError: If the URL violates the policy.
|
||||
"""
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
|
||||
scheme = (parsed.scheme or "").lower()
|
||||
if scheme not in policy.allowed_schemes:
|
||||
msg = f"scheme '{scheme}' not allowed"
|
||||
raise SSRFBlockedError(msg)
|
||||
|
||||
hostname = parsed.hostname
|
||||
if not hostname:
|
||||
msg = "missing hostname"
|
||||
raise SSRFBlockedError(msg)
|
||||
|
||||
allowed = _effective_allowed_hosts(policy)
|
||||
if hostname.lower() in {h.lower() for h in allowed}:
|
||||
return
|
||||
|
||||
try:
|
||||
ipaddress.ip_address(hostname)
|
||||
validate_resolved_ip(hostname, policy)
|
||||
except SSRFBlockedError:
|
||||
raise
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
return
|
||||
|
||||
validate_hostname(hostname, policy)
|
||||
@@ -0,0 +1,155 @@
|
||||
"""SSRF Protection - thin wrapper raising ValueError for internal callers.
|
||||
|
||||
Delegates all validation to `langchain_core._security._policy`.
|
||||
"""
|
||||
|
||||
import os
|
||||
import socket
|
||||
from typing import Annotated, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from pydantic import (
|
||||
AnyHttpUrl,
|
||||
BeforeValidator,
|
||||
HttpUrl,
|
||||
)
|
||||
|
||||
from langchain_core._security._exceptions import SSRFBlockedError
|
||||
from langchain_core._security._policy import (
|
||||
SSRFPolicy,
|
||||
)
|
||||
from langchain_core._security._policy import (
|
||||
validate_resolved_ip as _validate_resolved_ip,
|
||||
)
|
||||
from langchain_core._security._policy import (
|
||||
validate_url_sync as _validate_url_sync,
|
||||
)
|
||||
|
||||
|
||||
def _policy_for(*, allow_private: bool, allow_http: bool) -> SSRFPolicy:
|
||||
"""Build an `SSRFPolicy` from the legacy flag interface."""
|
||||
schemes = frozenset({"http", "https"}) if allow_http else frozenset({"https"})
|
||||
return SSRFPolicy(
|
||||
allowed_schemes=schemes,
|
||||
block_private_ips=not allow_private,
|
||||
block_localhost=not allow_private,
|
||||
block_cloud_metadata=True,
|
||||
block_k8s_internal=True,
|
||||
)
|
||||
|
||||
|
||||
def validate_safe_url(
|
||||
url: str | AnyHttpUrl,
|
||||
*,
|
||||
allow_private: bool = False,
|
||||
allow_http: bool = True,
|
||||
) -> str:
|
||||
"""Validate a URL for SSRF protection.
|
||||
|
||||
This function validates URLs to prevent Server-Side Request Forgery (SSRF) attacks
|
||||
by blocking requests to private networks and cloud metadata endpoints.
|
||||
|
||||
Args:
|
||||
url: The URL to validate (string or Pydantic HttpUrl).
|
||||
allow_private: If `True`, allows private IPs and localhost (for development).
|
||||
Cloud metadata endpoints are ALWAYS blocked.
|
||||
allow_http: If `True`, allows both HTTP and HTTPS. If `False`, only HTTPS.
|
||||
|
||||
Returns:
|
||||
The validated URL as a string.
|
||||
|
||||
Raises:
|
||||
ValueError: If URL is invalid or potentially dangerous.
|
||||
"""
|
||||
url_str = str(url)
|
||||
parsed = urlparse(url_str)
|
||||
hostname = parsed.hostname or ""
|
||||
|
||||
# Test-environment bypass (preserved from original implementation)
|
||||
if (
|
||||
os.environ.get("LANGCHAIN_ENV") == "local_test"
|
||||
and hostname.startswith("test")
|
||||
and "server" in hostname
|
||||
):
|
||||
return url_str
|
||||
|
||||
policy = _policy_for(allow_private=allow_private, allow_http=allow_http)
|
||||
|
||||
# Synchronous scheme + hostname checks
|
||||
try:
|
||||
_validate_url_sync(url_str, policy)
|
||||
except SSRFBlockedError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
|
||||
# DNS resolution and IP validation
|
||||
try:
|
||||
addr_info = socket.getaddrinfo(
|
||||
hostname,
|
||||
parsed.port or (443 if parsed.scheme == "https" else 80),
|
||||
socket.AF_UNSPEC,
|
||||
socket.SOCK_STREAM,
|
||||
)
|
||||
|
||||
for result in addr_info:
|
||||
ip_str: str = result[4][0] # type: ignore[assignment]
|
||||
try:
|
||||
_validate_resolved_ip(ip_str, policy)
|
||||
except SSRFBlockedError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
|
||||
except socket.gaierror as e:
|
||||
msg = f"Failed to resolve hostname '{hostname}': {e}"
|
||||
raise ValueError(msg) from e
|
||||
except OSError as e:
|
||||
msg = f"Network error while validating URL: {e}"
|
||||
raise ValueError(msg) from e
|
||||
|
||||
return url_str
|
||||
|
||||
|
||||
def is_safe_url(
|
||||
url: str | AnyHttpUrl,
|
||||
*,
|
||||
allow_private: bool = False,
|
||||
allow_http: bool = True,
|
||||
) -> bool:
|
||||
"""Non-throwing version of `validate_safe_url`."""
|
||||
try:
|
||||
validate_safe_url(url, allow_private=allow_private, allow_http=allow_http)
|
||||
except ValueError:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
def _validate_url_ssrf_strict(v: Any) -> Any:
|
||||
"""Validate URL for SSRF protection (strict mode)."""
|
||||
if isinstance(v, str):
|
||||
validate_safe_url(v, allow_private=False, allow_http=True)
|
||||
return v
|
||||
|
||||
|
||||
def _validate_url_ssrf_https_only(v: Any) -> Any:
|
||||
if isinstance(v, str):
|
||||
validate_safe_url(v, allow_private=False, allow_http=False)
|
||||
return v
|
||||
|
||||
|
||||
def _validate_url_ssrf_relaxed(v: Any) -> Any:
|
||||
"""Validate URL for SSRF protection (relaxed mode - allows private IPs)."""
|
||||
if isinstance(v, str):
|
||||
validate_safe_url(v, allow_private=True, allow_http=True)
|
||||
return v
|
||||
|
||||
|
||||
# Annotated types with SSRF protection
|
||||
SSRFProtectedUrl = Annotated[HttpUrl, BeforeValidator(_validate_url_ssrf_strict)]
|
||||
SSRFProtectedUrlRelaxed = Annotated[
|
||||
HttpUrl, BeforeValidator(_validate_url_ssrf_relaxed)
|
||||
]
|
||||
SSRFProtectedHttpsUrl = Annotated[
|
||||
HttpUrl, BeforeValidator(_validate_url_ssrf_https_only)
|
||||
]
|
||||
SSRFProtectedHttpsUrlStr = Annotated[
|
||||
str, BeforeValidator(_validate_url_ssrf_https_only)
|
||||
]
|
||||
@@ -0,0 +1,252 @@
|
||||
"""SSRF-safe httpx transport with DNS resolution and IP pinning."""
|
||||
|
||||
import asyncio
|
||||
import socket
|
||||
|
||||
import httpx
|
||||
|
||||
from langchain_core._security._exceptions import SSRFBlockedError
|
||||
from langchain_core._security._policy import (
|
||||
SSRFPolicy,
|
||||
_effective_allowed_hosts,
|
||||
validate_resolved_ip,
|
||||
validate_url_sync,
|
||||
)
|
||||
|
||||
# Keys that AsyncHTTPTransport accepts (forwarded from factory kwargs).
|
||||
_TRANSPORT_KWARGS = frozenset(
|
||||
{
|
||||
"verify",
|
||||
"cert",
|
||||
"trust_env",
|
||||
"http1",
|
||||
"http2",
|
||||
"limits",
|
||||
"retries",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class SSRFSafeTransport(httpx.AsyncBaseTransport):
|
||||
"""httpx async transport that validates DNS results against an SSRF policy.
|
||||
|
||||
For every outgoing request the transport:
|
||||
1. Checks the URL scheme against `policy.allowed_schemes`.
|
||||
2. Validates the hostname against blocked patterns.
|
||||
3. Resolves DNS and validates **all** returned IPs.
|
||||
4. Rewrites the request to connect to the first valid IP while
|
||||
preserving the original `Host` header and TLS SNI hostname.
|
||||
|
||||
Redirects are re-validated on each hop because `follow_redirects`
|
||||
is set on the *client*, causing `handle_async_request` to be called
|
||||
again for each redirect target.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
policy: SSRFPolicy = SSRFPolicy(),
|
||||
**transport_kwargs: object,
|
||||
) -> None:
|
||||
self._policy = policy
|
||||
self._inner = httpx.AsyncHTTPTransport(**transport_kwargs) # type: ignore[arg-type]
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Core request handler
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def handle_async_request(
|
||||
self,
|
||||
request: httpx.Request,
|
||||
) -> httpx.Response:
|
||||
hostname = request.url.host or ""
|
||||
scheme = request.url.scheme.lower()
|
||||
|
||||
# 1-3. Scheme, hostname, and pattern checks (reuse sync validator).
|
||||
try:
|
||||
validate_url_sync(str(request.url), self._policy)
|
||||
except SSRFBlockedError:
|
||||
raise
|
||||
|
||||
# Allowed-hosts bypass - skip DNS/IP validation entirely.
|
||||
allowed = {h.lower() for h in _effective_allowed_hosts(self._policy)}
|
||||
if hostname.lower() in allowed:
|
||||
return await self._inner.handle_async_request(request)
|
||||
|
||||
# 4. DNS resolution
|
||||
port = request.url.port or (443 if scheme == "https" else 80)
|
||||
try:
|
||||
addrinfo = await asyncio.to_thread(
|
||||
socket.getaddrinfo,
|
||||
hostname,
|
||||
port,
|
||||
type=socket.SOCK_STREAM,
|
||||
)
|
||||
except socket.gaierror as exc:
|
||||
raise SSRFBlockedError("DNS resolution failed") from exc
|
||||
|
||||
if not addrinfo:
|
||||
raise SSRFBlockedError("DNS resolution returned no results")
|
||||
|
||||
# 5. Validate ALL resolved IPs - any blocked means reject.
|
||||
for _family, _type, _proto, _canonname, sockaddr in addrinfo:
|
||||
ip_str: str = sockaddr[0] # type: ignore[assignment]
|
||||
validate_resolved_ip(ip_str, self._policy)
|
||||
|
||||
# 6. Pin to first resolved IP.
|
||||
pinned_ip = addrinfo[0][4][0]
|
||||
|
||||
# 7. Rewrite URL to use pinned IP, preserving Host header and SNI.
|
||||
pinned_url = request.url.copy_with(host=pinned_ip)
|
||||
|
||||
# Build extensions dict, adding sni_hostname for HTTPS so TLS
|
||||
# certificate validation uses the original hostname.
|
||||
extensions = dict(request.extensions)
|
||||
if scheme == "https":
|
||||
extensions["sni_hostname"] = hostname.encode("ascii")
|
||||
|
||||
pinned_request = httpx.Request(
|
||||
method=request.method,
|
||||
url=pinned_url,
|
||||
headers=request.headers, # Host header already set to original
|
||||
content=request.content,
|
||||
extensions=extensions,
|
||||
)
|
||||
|
||||
return await self._inner.handle_async_request(pinned_request)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Lifecycle
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._inner.aclose()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
# Factory
|
||||
# ---------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class SSRFSafeSyncTransport(httpx.BaseTransport):
|
||||
"""httpx sync transport that validates DNS results against an SSRF policy.
|
||||
|
||||
Sync mirror of `SSRFSafeTransport`. See that class for full documentation.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
policy: SSRFPolicy = SSRFPolicy(),
|
||||
**transport_kwargs: object,
|
||||
) -> None:
|
||||
self._policy = policy
|
||||
self._inner = httpx.HTTPTransport(**transport_kwargs) # type: ignore[arg-type]
|
||||
|
||||
def handle_request(
|
||||
self,
|
||||
request: httpx.Request,
|
||||
) -> httpx.Response:
|
||||
hostname = request.url.host or ""
|
||||
scheme = request.url.scheme.lower()
|
||||
|
||||
validate_url_sync(str(request.url), self._policy)
|
||||
|
||||
allowed = {h.lower() for h in _effective_allowed_hosts(self._policy)}
|
||||
if hostname.lower() in allowed:
|
||||
return self._inner.handle_request(request)
|
||||
|
||||
port = request.url.port or (443 if scheme == "https" else 80)
|
||||
try:
|
||||
addrinfo = socket.getaddrinfo(
|
||||
hostname,
|
||||
port,
|
||||
type=socket.SOCK_STREAM,
|
||||
)
|
||||
except socket.gaierror as exc:
|
||||
raise SSRFBlockedError("DNS resolution failed") from exc
|
||||
|
||||
if not addrinfo:
|
||||
raise SSRFBlockedError("DNS resolution returned no results")
|
||||
|
||||
for _family, _type, _proto, _canonname, sockaddr in addrinfo:
|
||||
ip_str: str = sockaddr[0] # type: ignore[assignment]
|
||||
validate_resolved_ip(ip_str, self._policy)
|
||||
|
||||
pinned_ip = addrinfo[0][4][0]
|
||||
pinned_url = request.url.copy_with(host=pinned_ip)
|
||||
|
||||
extensions = dict(request.extensions)
|
||||
if scheme == "https":
|
||||
extensions["sni_hostname"] = hostname.encode("ascii")
|
||||
|
||||
pinned_request = httpx.Request(
|
||||
method=request.method,
|
||||
url=pinned_url,
|
||||
headers=request.headers,
|
||||
content=request.content,
|
||||
extensions=extensions,
|
||||
)
|
||||
|
||||
return self._inner.handle_request(pinned_request)
|
||||
|
||||
def close(self) -> None:
|
||||
self._inner.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
# Factories
|
||||
# ---------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def ssrf_safe_client(
|
||||
policy: SSRFPolicy = SSRFPolicy(),
|
||||
**kwargs: object,
|
||||
) -> httpx.Client:
|
||||
"""Create an `httpx.Client` with SSRF protection."""
|
||||
transport_kwargs: dict[str, object] = {}
|
||||
client_kwargs: dict[str, object] = {}
|
||||
for key, value in kwargs.items():
|
||||
if key in _TRANSPORT_KWARGS:
|
||||
transport_kwargs[key] = value
|
||||
else:
|
||||
client_kwargs[key] = value
|
||||
|
||||
transport = SSRFSafeSyncTransport(policy=policy, **transport_kwargs)
|
||||
|
||||
client_kwargs.setdefault("follow_redirects", True)
|
||||
client_kwargs.setdefault("max_redirects", 10)
|
||||
|
||||
return httpx.Client(
|
||||
transport=transport,
|
||||
**client_kwargs, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def ssrf_safe_async_client(
|
||||
policy: SSRFPolicy = SSRFPolicy(),
|
||||
**kwargs: object,
|
||||
) -> httpx.AsyncClient:
|
||||
"""Create an `httpx.AsyncClient` with SSRF protection.
|
||||
|
||||
Drop-in replacement for `httpx.AsyncClient(...)` - callers just swap
|
||||
the constructor call. Transport-specific kwargs (`verify`, `cert`,
|
||||
`retries`, etc.) are forwarded to the inner `AsyncHTTPTransport`;
|
||||
everything else goes to the `AsyncClient`.
|
||||
"""
|
||||
transport_kwargs: dict[str, object] = {}
|
||||
client_kwargs: dict[str, object] = {}
|
||||
for key, value in kwargs.items():
|
||||
if key in _TRANSPORT_KWARGS:
|
||||
transport_kwargs[key] = value
|
||||
else:
|
||||
client_kwargs[key] = value
|
||||
|
||||
transport = SSRFSafeTransport(policy=policy, **transport_kwargs)
|
||||
|
||||
# Apply defaults only if not overridden by caller.
|
||||
client_kwargs.setdefault("follow_redirects", True)
|
||||
client_kwargs.setdefault("max_redirects", 10)
|
||||
|
||||
return httpx.AsyncClient(
|
||||
transport=transport,
|
||||
**client_kwargs, # type: ignore[arg-type]
|
||||
)
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Schema definitions for representing agent actions, observations, and return values.
|
||||
|
||||
!!! warning
|
||||
|
||||
The schema definitions are provided for backwards compatibility.
|
||||
|
||||
!!! warning
|
||||
|
||||
New agents should be built using the
|
||||
[`langchain` library](https://pypi.org/project/langchain/), which provides a
|
||||
simpler and more flexible way to define agents.
|
||||
|
||||
See docs on [building agents](https://docs.langchain.com/oss/python/langchain/agents).
|
||||
|
||||
Agents use language models to choose a sequence of actions to take.
|
||||
|
||||
A basic agent works in the following manner:
|
||||
|
||||
1. Given a prompt an agent uses an LLM to request an action to take
|
||||
(e.g., a tool to run).
|
||||
2. The agent executes the action (e.g., runs the tool), and receives an observation.
|
||||
3. The agent returns the observation to the LLM, which can then be used to generate
|
||||
the next action.
|
||||
4. When the agent reaches a stopping condition, it returns a final return value.
|
||||
|
||||
The schemas for the agents themselves are defined in `langchain.agents.agent`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Sequence
|
||||
from typing import Any, Literal
|
||||
|
||||
from langchain_core.load.serializable import Serializable
|
||||
from langchain_core.messages import (
|
||||
AIMessage,
|
||||
BaseMessage,
|
||||
FunctionMessage,
|
||||
HumanMessage,
|
||||
)
|
||||
|
||||
|
||||
class AgentAction(Serializable):
|
||||
"""Represents a request to execute an action by an agent.
|
||||
|
||||
The action consists of the name of the tool to execute and the input to pass
|
||||
to the tool. The log is used to pass along extra information about the action.
|
||||
"""
|
||||
|
||||
tool: str
|
||||
"""The name of the `Tool` to execute."""
|
||||
|
||||
tool_input: str | dict
|
||||
"""The input to pass in to the `Tool`."""
|
||||
|
||||
log: str
|
||||
"""Additional information to log about the action.
|
||||
|
||||
This log can be used in a few ways. First, it can be used to audit what exactly the
|
||||
LLM predicted to lead to this `(tool, tool_input)`.
|
||||
|
||||
Second, it can be used in future iterations to show the LLMs prior thoughts. This is
|
||||
useful when `(tool, tool_input)` does not contain full information about the LLM
|
||||
prediction (for example, any `thought` before the tool/tool_input).
|
||||
"""
|
||||
|
||||
type: Literal["AgentAction"] = "AgentAction"
|
||||
|
||||
# Override init to support instantiation by position for backward compat.
|
||||
def __init__(self, tool: str, tool_input: str | dict, log: str, **kwargs: Any):
|
||||
"""Create an `AgentAction`.
|
||||
|
||||
Args:
|
||||
tool: The name of the tool to execute.
|
||||
tool_input: The input to pass in to the `Tool`.
|
||||
log: Additional information to log about the action.
|
||||
"""
|
||||
super().__init__(tool=tool, tool_input=tool_input, log=log, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def is_lc_serializable(cls) -> bool:
|
||||
"""`AgentAction` is serializable.
|
||||
|
||||
Returns:
|
||||
`True`
|
||||
"""
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def get_lc_namespace(cls) -> list[str]:
|
||||
"""Get the namespace of the LangChain object.
|
||||
|
||||
Returns:
|
||||
`["langchain", "schema", "agent"]`
|
||||
"""
|
||||
return ["langchain", "schema", "agent"]
|
||||
|
||||
@property
|
||||
def messages(self) -> Sequence[BaseMessage]:
|
||||
"""Return the messages that correspond to this action."""
|
||||
return _convert_agent_action_to_messages(self)
|
||||
|
||||
|
||||
class AgentActionMessageLog(AgentAction):
|
||||
"""Representation of an action to be executed by an agent.
|
||||
|
||||
This is similar to `AgentAction`, but includes a message log consisting of
|
||||
chat messages.
|
||||
|
||||
This is useful when working with `ChatModels`, and is used to reconstruct
|
||||
conversation history from the agent's perspective.
|
||||
"""
|
||||
|
||||
message_log: Sequence[BaseMessage]
|
||||
"""Similar to log, this can be used to pass along extra information about what exact
|
||||
messages were predicted by the LLM before parsing out the `(tool, tool_input)`.
|
||||
|
||||
This is again useful if `(tool, tool_input)` cannot be used to fully recreate the
|
||||
LLM prediction, and you need that LLM prediction (for future agent iteration).
|
||||
|
||||
Compared to `log`, this is useful when the underlying LLM is a chat model (and
|
||||
therefore returns messages rather than a string).
|
||||
"""
|
||||
# Ignoring type because we're overriding the type from AgentAction.
|
||||
# And this is the correct thing to do in this case.
|
||||
# The type literal is used for serialization purposes.
|
||||
type: Literal["AgentActionMessageLog"] = "AgentActionMessageLog" # type: ignore[assignment]
|
||||
|
||||
|
||||
class AgentStep(Serializable):
|
||||
"""Result of running an `AgentAction`."""
|
||||
|
||||
action: AgentAction
|
||||
"""The `AgentAction` that was executed."""
|
||||
|
||||
observation: Any
|
||||
"""The result of the `AgentAction`."""
|
||||
|
||||
@property
|
||||
def messages(self) -> Sequence[BaseMessage]:
|
||||
"""Messages that correspond to this observation."""
|
||||
return _convert_agent_observation_to_messages(self.action, self.observation)
|
||||
|
||||
|
||||
class AgentFinish(Serializable):
|
||||
"""Final return value of an `ActionAgent`.
|
||||
|
||||
Agents return an `AgentFinish` when they have reached a stopping condition.
|
||||
"""
|
||||
|
||||
return_values: dict
|
||||
"""Dictionary of return values."""
|
||||
|
||||
log: str
|
||||
"""Additional information to log about the return value.
|
||||
|
||||
This is used to pass along the full LLM prediction, not just the parsed out
|
||||
return value.
|
||||
|
||||
For example, if the full LLM prediction was `Final Answer: 2` you may want to just
|
||||
return `2` as a return value, but pass along the full string as a `log` (for
|
||||
debugging or observability purposes).
|
||||
"""
|
||||
type: Literal["AgentFinish"] = "AgentFinish"
|
||||
|
||||
def __init__(self, return_values: dict, log: str, **kwargs: Any):
|
||||
"""Override init to support instantiation by position for backward compat."""
|
||||
super().__init__(return_values=return_values, log=log, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def is_lc_serializable(cls) -> bool:
|
||||
"""Return `True` as this class is serializable."""
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def get_lc_namespace(cls) -> list[str]:
|
||||
"""Get the namespace of the LangChain object.
|
||||
|
||||
Returns:
|
||||
`["langchain", "schema", "agent"]`
|
||||
"""
|
||||
return ["langchain", "schema", "agent"]
|
||||
|
||||
@property
|
||||
def messages(self) -> Sequence[BaseMessage]:
|
||||
"""Messages that correspond to this observation."""
|
||||
return [AIMessage(content=self.log)]
|
||||
|
||||
|
||||
def _convert_agent_action_to_messages(
|
||||
agent_action: AgentAction,
|
||||
) -> Sequence[BaseMessage]:
|
||||
"""Convert an agent action to a message.
|
||||
|
||||
This code is used to reconstruct the original AI message from the agent action.
|
||||
|
||||
Args:
|
||||
agent_action: Agent action to convert.
|
||||
|
||||
Returns:
|
||||
`AIMessage` that corresponds to the original tool invocation.
|
||||
"""
|
||||
if isinstance(agent_action, AgentActionMessageLog):
|
||||
return agent_action.message_log
|
||||
return [AIMessage(content=agent_action.log)]
|
||||
|
||||
|
||||
def _convert_agent_observation_to_messages(
|
||||
agent_action: AgentAction, observation: Any
|
||||
) -> Sequence[BaseMessage]:
|
||||
"""Convert an agent action to a message.
|
||||
|
||||
This code is used to reconstruct the original AI message from the agent action.
|
||||
|
||||
Args:
|
||||
agent_action: Agent action to convert.
|
||||
observation: Observation to convert to a message.
|
||||
|
||||
Returns:
|
||||
`AIMessage` that corresponds to the original tool invocation.
|
||||
"""
|
||||
if isinstance(agent_action, AgentActionMessageLog):
|
||||
return [_create_function_message(agent_action, observation)]
|
||||
content = observation
|
||||
if not isinstance(observation, str):
|
||||
try:
|
||||
content = json.dumps(observation, ensure_ascii=False)
|
||||
except Exception:
|
||||
content = str(observation)
|
||||
return [HumanMessage(content=content)]
|
||||
|
||||
|
||||
def _create_function_message(
|
||||
agent_action: AgentAction, observation: Any
|
||||
) -> FunctionMessage:
|
||||
"""Convert agent action and observation into a function message.
|
||||
|
||||
Args:
|
||||
agent_action: the tool invocation request from the agent.
|
||||
observation: the result of the tool invocation.
|
||||
|
||||
Returns:
|
||||
`FunctionMessage` that corresponds to the original tool invocation.
|
||||
"""
|
||||
if not isinstance(observation, str):
|
||||
try:
|
||||
content = json.dumps(observation, ensure_ascii=False)
|
||||
except Exception:
|
||||
content = str(observation)
|
||||
else:
|
||||
content = observation
|
||||
return FunctionMessage(
|
||||
name=agent_action.tool,
|
||||
content=content,
|
||||
)
|
||||
@@ -0,0 +1,272 @@
|
||||
"""Optional caching layer for language models.
|
||||
|
||||
Distinct from provider-based [prompt caching](https://docs.langchain.com/oss/python/langchain/models#prompt-caching).
|
||||
|
||||
!!! warning "Beta feature"
|
||||
|
||||
This is a beta feature. Please be wary of deploying experimental code to production
|
||||
unless you've taken appropriate precautions.
|
||||
|
||||
A cache is useful for two reasons:
|
||||
|
||||
1. It can save you money by reducing the number of API calls you make to the LLM
|
||||
provider if you're often requesting the same completion multiple times.
|
||||
2. It can speed up your application by reducing the number of API calls you make to the
|
||||
LLM provider.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from typing_extensions import override
|
||||
|
||||
from langchain_core.outputs import Generation
|
||||
from langchain_core.runnables import run_in_executor
|
||||
|
||||
RETURN_VAL_TYPE = Sequence[Generation]
|
||||
|
||||
|
||||
class BaseCache(ABC):
|
||||
"""Interface for a caching layer for LLMs and Chat models.
|
||||
|
||||
The cache interface consists of the following methods:
|
||||
|
||||
- lookup: Look up a value based on a prompt and `llm_string`.
|
||||
- update: Update the cache based on a prompt and `llm_string`.
|
||||
- clear: Clear the cache.
|
||||
|
||||
In addition, the cache interface provides an async version of each method.
|
||||
|
||||
The default implementation of the async methods is to run the synchronous
|
||||
method in an executor. It's recommended to override the async methods
|
||||
and provide async implementations to avoid unnecessary overhead.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def lookup(self, prompt: str, llm_string: str) -> RETURN_VAL_TYPE | None:
|
||||
"""Look up based on `prompt` and `llm_string`.
|
||||
|
||||
A cache implementation is expected to generate a key from the 2-tuple
|
||||
of `prompt` and `llm_string` (e.g., by concatenating them with a delimiter).
|
||||
|
||||
Args:
|
||||
prompt: A string representation of the prompt.
|
||||
|
||||
In the case of a chat model, the prompt is a non-trivial
|
||||
serialization of the prompt into the language model.
|
||||
llm_string: A string representation of the LLM configuration.
|
||||
|
||||
This is used to capture the invocation parameters of the LLM
|
||||
(e.g., model name, temperature, stop tokens, max tokens, etc.).
|
||||
|
||||
These invocation parameters are serialized into a string representation.
|
||||
|
||||
Returns:
|
||||
On a cache miss, return `None`. On a cache hit, return the cached value.
|
||||
The cached value is a list of `Generation` (or subclasses).
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def update(self, prompt: str, llm_string: str, return_val: RETURN_VAL_TYPE) -> None:
|
||||
"""Update cache based on `prompt` and `llm_string`.
|
||||
|
||||
The `prompt` and `llm_string` are used to generate a key for the cache. The key
|
||||
should match that of the lookup method.
|
||||
|
||||
Args:
|
||||
prompt: A string representation of the prompt.
|
||||
|
||||
In the case of a chat model, the prompt is a non-trivial
|
||||
serialization of the prompt into the language model.
|
||||
llm_string: A string representation of the LLM configuration.
|
||||
|
||||
This is used to capture the invocation parameters of the LLM
|
||||
(e.g., model name, temperature, stop tokens, max tokens, etc.).
|
||||
|
||||
These invocation parameters are serialized into a string
|
||||
representation.
|
||||
return_val: The value to be cached.
|
||||
|
||||
The value is a list of `Generation` (or subclasses).
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def clear(self, **kwargs: Any) -> None:
|
||||
"""Clear cache that can take additional keyword arguments."""
|
||||
|
||||
async def alookup(self, prompt: str, llm_string: str) -> RETURN_VAL_TYPE | None:
|
||||
"""Async look up based on `prompt` and `llm_string`.
|
||||
|
||||
A cache implementation is expected to generate a key from the 2-tuple
|
||||
of `prompt` and `llm_string` (e.g., by concatenating them with a delimiter).
|
||||
|
||||
Args:
|
||||
prompt: A string representation of the prompt.
|
||||
|
||||
In the case of a chat model, the prompt is a non-trivial
|
||||
serialization of the prompt into the language model.
|
||||
llm_string: A string representation of the LLM configuration.
|
||||
|
||||
This is used to capture the invocation parameters of the LLM
|
||||
(e.g., model name, temperature, stop tokens, max tokens, etc.).
|
||||
|
||||
These invocation parameters are serialized into a string
|
||||
representation.
|
||||
|
||||
Returns:
|
||||
On a cache miss, return `None`. On a cache hit, return the cached value.
|
||||
The cached value is a list of `Generation` (or subclasses).
|
||||
"""
|
||||
return await run_in_executor(None, self.lookup, prompt, llm_string)
|
||||
|
||||
async def aupdate(
|
||||
self, prompt: str, llm_string: str, return_val: RETURN_VAL_TYPE
|
||||
) -> None:
|
||||
"""Async update cache based on `prompt` and `llm_string`.
|
||||
|
||||
The prompt and llm_string are used to generate a key for the cache.
|
||||
The key should match that of the look up method.
|
||||
|
||||
Args:
|
||||
prompt: A string representation of the prompt.
|
||||
|
||||
In the case of a chat model, the prompt is a non-trivial
|
||||
serialization of the prompt into the language model.
|
||||
llm_string: A string representation of the LLM configuration.
|
||||
|
||||
This is used to capture the invocation parameters of the LLM
|
||||
(e.g., model name, temperature, stop tokens, max tokens, etc.).
|
||||
|
||||
These invocation parameters are serialized into a string
|
||||
representation.
|
||||
return_val: The value to be cached. The value is a list of `Generation`
|
||||
(or subclasses).
|
||||
"""
|
||||
return await run_in_executor(None, self.update, prompt, llm_string, return_val)
|
||||
|
||||
async def aclear(self, **kwargs: Any) -> None:
|
||||
"""Async clear cache that can take additional keyword arguments."""
|
||||
return await run_in_executor(None, self.clear, **kwargs)
|
||||
|
||||
|
||||
class InMemoryCache(BaseCache):
|
||||
"""Cache that stores things in memory.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from langchain_core.caches import InMemoryCache
|
||||
from langchain_core.outputs import Generation
|
||||
|
||||
# Initialize cache
|
||||
cache = InMemoryCache()
|
||||
|
||||
# Update cache
|
||||
cache.update(
|
||||
prompt="What is the capital of France?",
|
||||
llm_string="model='gpt-5.4-mini',
|
||||
return_val=[Generation(text="Paris")],
|
||||
)
|
||||
|
||||
# Lookup cache
|
||||
result = cache.lookup(
|
||||
prompt="What is the capital of France?",
|
||||
llm_string="model='gpt-5.4-mini',
|
||||
)
|
||||
# result is [Generation(text="Paris")]
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, *, maxsize: int | None = None) -> None:
|
||||
"""Initialize with empty cache.
|
||||
|
||||
Args:
|
||||
maxsize: The maximum number of items to store in the cache.
|
||||
|
||||
If `None`, the cache has no maximum size.
|
||||
|
||||
If the cache exceeds the maximum size, the oldest items are removed.
|
||||
|
||||
Raises:
|
||||
ValueError: If `maxsize` is less than or equal to `0`.
|
||||
"""
|
||||
self._cache: dict[tuple[str, str], RETURN_VAL_TYPE] = {}
|
||||
if maxsize is not None and maxsize <= 0:
|
||||
msg = "maxsize must be greater than 0"
|
||||
raise ValueError(msg)
|
||||
self._maxsize = maxsize
|
||||
|
||||
def lookup(self, prompt: str, llm_string: str) -> RETURN_VAL_TYPE | None:
|
||||
"""Look up based on `prompt` and `llm_string`.
|
||||
|
||||
Args:
|
||||
prompt: A string representation of the prompt.
|
||||
|
||||
In the case of a chat model, the prompt is a non-trivial
|
||||
serialization of the prompt into the language model.
|
||||
llm_string: A string representation of the LLM configuration.
|
||||
|
||||
Returns:
|
||||
On a cache miss, return `None`. On a cache hit, return the cached value.
|
||||
"""
|
||||
return self._cache.get((prompt, llm_string), None)
|
||||
|
||||
def update(self, prompt: str, llm_string: str, return_val: RETURN_VAL_TYPE) -> None:
|
||||
"""Update cache based on `prompt` and `llm_string`.
|
||||
|
||||
Args:
|
||||
prompt: A string representation of the prompt.
|
||||
|
||||
In the case of a chat model, the prompt is a non-trivial
|
||||
serialization of the prompt into the language model.
|
||||
llm_string: A string representation of the LLM configuration.
|
||||
return_val: The value to be cached.
|
||||
|
||||
The value is a list of `Generation` (or subclasses).
|
||||
"""
|
||||
if self._maxsize is not None and len(self._cache) == self._maxsize:
|
||||
del self._cache[next(iter(self._cache))]
|
||||
self._cache[prompt, llm_string] = return_val
|
||||
|
||||
@override
|
||||
def clear(self, **kwargs: Any) -> None:
|
||||
"""Clear cache."""
|
||||
self._cache = {}
|
||||
|
||||
async def alookup(self, prompt: str, llm_string: str) -> RETURN_VAL_TYPE | None:
|
||||
"""Async look up based on `prompt` and `llm_string`.
|
||||
|
||||
Args:
|
||||
prompt: A string representation of the prompt.
|
||||
|
||||
In the case of a chat model, the prompt is a non-trivial
|
||||
serialization of the prompt into the language model.
|
||||
llm_string: A string representation of the LLM configuration.
|
||||
|
||||
Returns:
|
||||
On a cache miss, return `None`. On a cache hit, return the cached value.
|
||||
"""
|
||||
return self.lookup(prompt, llm_string)
|
||||
|
||||
async def aupdate(
|
||||
self, prompt: str, llm_string: str, return_val: RETURN_VAL_TYPE
|
||||
) -> None:
|
||||
"""Async update cache based on `prompt` and `llm_string`.
|
||||
|
||||
Args:
|
||||
prompt: A string representation of the prompt.
|
||||
|
||||
In the case of a chat model, the prompt is a non-trivial
|
||||
serialization of the prompt into the language model.
|
||||
llm_string: A string representation of the LLM configuration.
|
||||
return_val: The value to be cached. The value is a list of `Generation`
|
||||
(or subclasses).
|
||||
"""
|
||||
self.update(prompt, llm_string, return_val)
|
||||
|
||||
@override
|
||||
async def aclear(self, **kwargs: Any) -> None:
|
||||
"""Async clear cache."""
|
||||
self.clear()
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Callback handlers allow listening to events in LangChain."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from langchain_core._import_utils import import_attr
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langchain_core.callbacks.base import (
|
||||
AsyncCallbackHandler,
|
||||
BaseCallbackHandler,
|
||||
BaseCallbackManager,
|
||||
CallbackManagerMixin,
|
||||
Callbacks,
|
||||
ChainManagerMixin,
|
||||
LLMManagerMixin,
|
||||
RetrieverManagerMixin,
|
||||
RunManagerMixin,
|
||||
ToolManagerMixin,
|
||||
)
|
||||
from langchain_core.callbacks.file import FileCallbackHandler
|
||||
from langchain_core.callbacks.manager import (
|
||||
AsyncCallbackManager,
|
||||
AsyncCallbackManagerForChainGroup,
|
||||
AsyncCallbackManagerForChainRun,
|
||||
AsyncCallbackManagerForLLMRun,
|
||||
AsyncCallbackManagerForRetrieverRun,
|
||||
AsyncCallbackManagerForToolRun,
|
||||
AsyncParentRunManager,
|
||||
AsyncRunManager,
|
||||
BaseRunManager,
|
||||
CallbackManager,
|
||||
CallbackManagerForChainGroup,
|
||||
CallbackManagerForChainRun,
|
||||
CallbackManagerForLLMRun,
|
||||
CallbackManagerForRetrieverRun,
|
||||
CallbackManagerForToolRun,
|
||||
ParentRunManager,
|
||||
RunManager,
|
||||
adispatch_custom_event,
|
||||
dispatch_custom_event,
|
||||
)
|
||||
from langchain_core.callbacks.stdout import StdOutCallbackHandler
|
||||
from langchain_core.callbacks.streaming_stdout import StreamingStdOutCallbackHandler
|
||||
from langchain_core.callbacks.usage import (
|
||||
UsageMetadataCallbackHandler,
|
||||
get_usage_metadata_callback,
|
||||
)
|
||||
|
||||
__all__ = (
|
||||
"AsyncCallbackHandler",
|
||||
"AsyncCallbackManager",
|
||||
"AsyncCallbackManagerForChainGroup",
|
||||
"AsyncCallbackManagerForChainRun",
|
||||
"AsyncCallbackManagerForLLMRun",
|
||||
"AsyncCallbackManagerForRetrieverRun",
|
||||
"AsyncCallbackManagerForToolRun",
|
||||
"AsyncParentRunManager",
|
||||
"AsyncRunManager",
|
||||
"BaseCallbackHandler",
|
||||
"BaseCallbackManager",
|
||||
"BaseRunManager",
|
||||
"CallbackManager",
|
||||
"CallbackManagerForChainGroup",
|
||||
"CallbackManagerForChainRun",
|
||||
"CallbackManagerForLLMRun",
|
||||
"CallbackManagerForRetrieverRun",
|
||||
"CallbackManagerForToolRun",
|
||||
"CallbackManagerMixin",
|
||||
"Callbacks",
|
||||
"ChainManagerMixin",
|
||||
"FileCallbackHandler",
|
||||
"LLMManagerMixin",
|
||||
"ParentRunManager",
|
||||
"RetrieverManagerMixin",
|
||||
"RunManager",
|
||||
"RunManagerMixin",
|
||||
"StdOutCallbackHandler",
|
||||
"StreamingStdOutCallbackHandler",
|
||||
"ToolManagerMixin",
|
||||
"UsageMetadataCallbackHandler",
|
||||
"adispatch_custom_event",
|
||||
"dispatch_custom_event",
|
||||
"get_usage_metadata_callback",
|
||||
)
|
||||
|
||||
_dynamic_imports = {
|
||||
"AsyncCallbackHandler": "base",
|
||||
"BaseCallbackHandler": "base",
|
||||
"BaseCallbackManager": "base",
|
||||
"CallbackManagerMixin": "base",
|
||||
"Callbacks": "base",
|
||||
"ChainManagerMixin": "base",
|
||||
"LLMManagerMixin": "base",
|
||||
"RetrieverManagerMixin": "base",
|
||||
"RunManagerMixin": "base",
|
||||
"ToolManagerMixin": "base",
|
||||
"FileCallbackHandler": "file",
|
||||
"AsyncCallbackManager": "manager",
|
||||
"AsyncCallbackManagerForChainGroup": "manager",
|
||||
"AsyncCallbackManagerForChainRun": "manager",
|
||||
"AsyncCallbackManagerForLLMRun": "manager",
|
||||
"AsyncCallbackManagerForRetrieverRun": "manager",
|
||||
"AsyncCallbackManagerForToolRun": "manager",
|
||||
"AsyncParentRunManager": "manager",
|
||||
"AsyncRunManager": "manager",
|
||||
"BaseRunManager": "manager",
|
||||
"CallbackManager": "manager",
|
||||
"CallbackManagerForChainGroup": "manager",
|
||||
"CallbackManagerForChainRun": "manager",
|
||||
"CallbackManagerForLLMRun": "manager",
|
||||
"CallbackManagerForRetrieverRun": "manager",
|
||||
"CallbackManagerForToolRun": "manager",
|
||||
"ParentRunManager": "manager",
|
||||
"RunManager": "manager",
|
||||
"adispatch_custom_event": "manager",
|
||||
"dispatch_custom_event": "manager",
|
||||
"StdOutCallbackHandler": "stdout",
|
||||
"StreamingStdOutCallbackHandler": "streaming_stdout",
|
||||
"UsageMetadataCallbackHandler": "usage",
|
||||
"get_usage_metadata_callback": "usage",
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(attr_name: str) -> object:
|
||||
module_name = _dynamic_imports.get(attr_name)
|
||||
result = import_attr(attr_name, module_name, __spec__.parent)
|
||||
globals()[attr_name] = result
|
||||
return result
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return list(__all__)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,267 @@
|
||||
"""Callback handler that writes to a file."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, TextIO, cast
|
||||
|
||||
from typing_extensions import Self, override
|
||||
|
||||
from langchain_core._api import warn_deprecated
|
||||
from langchain_core.callbacks import BaseCallbackHandler
|
||||
from langchain_core.utils.input import print_text
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langchain_core.agents import AgentAction, AgentFinish
|
||||
|
||||
|
||||
_GLOBAL_DEPRECATION_WARNED = False
|
||||
|
||||
|
||||
class FileCallbackHandler(BaseCallbackHandler):
|
||||
"""Callback handler that writes to a file.
|
||||
|
||||
This handler supports both context manager usage (recommended) and direct
|
||||
instantiation (deprecated) for backwards compatibility.
|
||||
|
||||
Examples:
|
||||
Using as a context manager (recommended):
|
||||
|
||||
```python
|
||||
with FileCallbackHandler("output.txt") as handler:
|
||||
# Use handler with your chain/agent
|
||||
chain.invoke(inputs, config={"callbacks": [handler]})
|
||||
```
|
||||
|
||||
Direct instantiation (deprecated):
|
||||
|
||||
```python
|
||||
handler = FileCallbackHandler("output.txt")
|
||||
# File remains open until handler is garbage collected
|
||||
try:
|
||||
chain.invoke(inputs, config={"callbacks": [handler]})
|
||||
finally:
|
||||
handler.close() # Explicit cleanup recommended
|
||||
```
|
||||
|
||||
Args:
|
||||
filename: The file path to write to.
|
||||
mode: The file open mode. Defaults to `'a'` (append).
|
||||
color: Default color for text output.
|
||||
|
||||
!!! note
|
||||
|
||||
When not used as a context manager, a deprecation warning will be issued on
|
||||
first use. The file will be opened immediately in `__init__` and closed in
|
||||
`__del__` or when `close()` is called explicitly.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, filename: str, mode: str = "a", color: str | None = None
|
||||
) -> None:
|
||||
"""Initialize the file callback handler.
|
||||
|
||||
Args:
|
||||
filename: Path to the output file.
|
||||
mode: File open mode (e.g., `'w'`, `'a'`, `'x'`). Defaults to `'a'`.
|
||||
color: Default text color for output.
|
||||
|
||||
"""
|
||||
self.filename = filename
|
||||
self.mode = mode
|
||||
self.color = color
|
||||
self._file_opened_in_context = False
|
||||
self.file: TextIO = cast(
|
||||
"TextIO",
|
||||
# Open the file in the specified mode with UTF-8 encoding.
|
||||
Path(self.filename).open(self.mode, encoding="utf-8"), # noqa: SIM115
|
||||
)
|
||||
|
||||
def __enter__(self) -> Self:
|
||||
"""Enter the context manager.
|
||||
|
||||
Returns:
|
||||
The `FileCallbackHandler` instance.
|
||||
|
||||
!!! note
|
||||
|
||||
The file is already opened in `__init__`, so this just marks that the
|
||||
handler is being used as a context manager.
|
||||
|
||||
"""
|
||||
self._file_opened_in_context = True
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_val: BaseException | None,
|
||||
exc_tb: object,
|
||||
) -> None:
|
||||
"""Exit the context manager and close the file.
|
||||
|
||||
Args:
|
||||
exc_type: Exception type if an exception occurred.
|
||||
exc_val: Exception value if an exception occurred.
|
||||
exc_tb: Exception traceback if an exception occurred.
|
||||
|
||||
"""
|
||||
self.close()
|
||||
|
||||
def __del__(self) -> None:
|
||||
"""Destructor to cleanup when done."""
|
||||
self.close()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the file if it's open.
|
||||
|
||||
This method is safe to call multiple times and will only close
|
||||
the file if it's currently open.
|
||||
|
||||
"""
|
||||
if hasattr(self, "file") and self.file and not self.file.closed:
|
||||
self.file.close()
|
||||
|
||||
def _write(
|
||||
self,
|
||||
text: str,
|
||||
color: str | None = None,
|
||||
end: str = "",
|
||||
) -> None:
|
||||
"""Write text to the file with deprecation warning if needed.
|
||||
|
||||
Args:
|
||||
text: The text to write to the file.
|
||||
color: Optional color for the text. Defaults to `self.color`.
|
||||
end: String appended after the text.
|
||||
file: Optional file to write to. Defaults to `self.file`.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the file is closed or not available.
|
||||
|
||||
"""
|
||||
global _GLOBAL_DEPRECATION_WARNED # noqa: PLW0603
|
||||
if not self._file_opened_in_context and not _GLOBAL_DEPRECATION_WARNED:
|
||||
warn_deprecated(
|
||||
since="0.3.67",
|
||||
pending=True,
|
||||
message=(
|
||||
"Using FileCallbackHandler without a context manager is "
|
||||
"deprecated. Use 'with FileCallbackHandler(...) as "
|
||||
"handler:' instead."
|
||||
),
|
||||
)
|
||||
_GLOBAL_DEPRECATION_WARNED = True
|
||||
|
||||
if not hasattr(self, "file") or self.file is None or self.file.closed:
|
||||
msg = "File is not open. Use FileCallbackHandler as a context manager."
|
||||
raise RuntimeError(msg)
|
||||
|
||||
print_text(text, file=self.file, color=color, end=end)
|
||||
|
||||
@override
|
||||
def on_chain_start(
|
||||
self, serialized: dict[str, Any], inputs: dict[str, Any], **kwargs: Any
|
||||
) -> None:
|
||||
"""Print that we are entering a chain.
|
||||
|
||||
Args:
|
||||
serialized: The serialized chain information.
|
||||
inputs: The inputs to the chain.
|
||||
**kwargs: Additional keyword arguments that may contain `'name'`.
|
||||
|
||||
"""
|
||||
name = (
|
||||
kwargs.get("name")
|
||||
or serialized.get("name", serialized.get("id", ["<unknown>"])[-1])
|
||||
or "<unknown>"
|
||||
)
|
||||
self._write(f"\n\n> Entering new {name} chain...", end="\n")
|
||||
|
||||
@override
|
||||
def on_chain_end(self, outputs: dict[str, Any], **kwargs: Any) -> None:
|
||||
"""Print that we finished a chain.
|
||||
|
||||
Args:
|
||||
outputs: The outputs of the chain.
|
||||
**kwargs: Additional keyword arguments.
|
||||
|
||||
"""
|
||||
self._write("\n> Finished chain.", end="\n")
|
||||
|
||||
@override
|
||||
def on_agent_action(
|
||||
self, action: AgentAction, color: str | None = None, **kwargs: Any
|
||||
) -> Any:
|
||||
"""Handle agent action by writing the action log.
|
||||
|
||||
Args:
|
||||
action: The agent action containing the log to write.
|
||||
color: Color override for this specific output.
|
||||
|
||||
If `None`, uses `self.color`.
|
||||
**kwargs: Additional keyword arguments.
|
||||
|
||||
"""
|
||||
self._write(action.log, color=color or self.color)
|
||||
|
||||
@override
|
||||
def on_tool_end(
|
||||
self,
|
||||
output: str,
|
||||
color: str | None = None,
|
||||
observation_prefix: str | None = None,
|
||||
llm_prefix: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Handle tool end by writing the output with optional prefixes.
|
||||
|
||||
Args:
|
||||
output: The tool output to write.
|
||||
color: Color override for this specific output.
|
||||
|
||||
If `None`, uses `self.color`.
|
||||
observation_prefix: Optional prefix to write before the output.
|
||||
llm_prefix: Optional prefix to write after the output.
|
||||
**kwargs: Additional keyword arguments.
|
||||
|
||||
"""
|
||||
if observation_prefix is not None:
|
||||
self._write(f"\n{observation_prefix}")
|
||||
self._write(output)
|
||||
if llm_prefix is not None:
|
||||
self._write(f"\n{llm_prefix}")
|
||||
|
||||
@override
|
||||
def on_text(
|
||||
self, text: str, color: str | None = None, end: str = "", **kwargs: Any
|
||||
) -> None:
|
||||
"""Handle text output.
|
||||
|
||||
Args:
|
||||
text: The text to write.
|
||||
color: Color override for this specific output.
|
||||
|
||||
If `None`, uses `self.color`.
|
||||
end: String appended after the text.
|
||||
**kwargs: Additional keyword arguments.
|
||||
|
||||
"""
|
||||
self._write(text, color=color or self.color, end=end)
|
||||
|
||||
@override
|
||||
def on_agent_finish(
|
||||
self, finish: AgentFinish, color: str | None = None, **kwargs: Any
|
||||
) -> None:
|
||||
"""Handle agent finish by writing the finish log.
|
||||
|
||||
Args:
|
||||
finish: The agent finish object containing the log to write.
|
||||
color: Color override for this specific output.
|
||||
|
||||
If `None`, uses `self.color`.
|
||||
**kwargs: Additional keyword arguments.
|
||||
|
||||
"""
|
||||
self._write(finish.log, color=color or self.color, end="\n")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,123 @@
|
||||
"""Callback handler that prints to std out."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from typing_extensions import override
|
||||
|
||||
from langchain_core.callbacks.base import BaseCallbackHandler
|
||||
from langchain_core.utils import print_text
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langchain_core.agents import AgentAction, AgentFinish
|
||||
|
||||
|
||||
class StdOutCallbackHandler(BaseCallbackHandler):
|
||||
"""Callback handler that prints to std out."""
|
||||
|
||||
def __init__(self, color: str | None = None) -> None:
|
||||
"""Initialize callback handler.
|
||||
|
||||
Args:
|
||||
color: The color to use for the text.
|
||||
"""
|
||||
self.color = color
|
||||
|
||||
@override
|
||||
def on_chain_start(
|
||||
self, serialized: dict[str, Any], inputs: dict[str, Any], **kwargs: Any
|
||||
) -> None:
|
||||
"""Print out that we are entering a chain.
|
||||
|
||||
Args:
|
||||
serialized: The serialized chain.
|
||||
inputs: The inputs to the chain.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
if "name" in kwargs:
|
||||
name = kwargs["name"]
|
||||
elif serialized:
|
||||
name = serialized.get("name", serialized.get("id", ["<unknown>"])[-1])
|
||||
else:
|
||||
name = "<unknown>"
|
||||
print(f"\n\n\033[1m> Entering new {name} chain...\033[0m") # noqa: T201
|
||||
|
||||
@override
|
||||
def on_chain_end(self, outputs: dict[str, Any], **kwargs: Any) -> None:
|
||||
"""Print out that we finished a chain.
|
||||
|
||||
Args:
|
||||
outputs: The outputs of the chain.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
print("\n\033[1m> Finished chain.\033[0m") # noqa: T201
|
||||
|
||||
@override
|
||||
def on_agent_action(
|
||||
self, action: AgentAction, color: str | None = None, **kwargs: Any
|
||||
) -> Any:
|
||||
"""Run on agent action.
|
||||
|
||||
Args:
|
||||
action: The agent action.
|
||||
color: The color to use for the text.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
print_text(action.log, color=color or self.color)
|
||||
|
||||
@override
|
||||
def on_tool_end(
|
||||
self,
|
||||
output: Any,
|
||||
color: str | None = None,
|
||||
observation_prefix: str | None = None,
|
||||
llm_prefix: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""If not the final action, print out observation.
|
||||
|
||||
Args:
|
||||
output: The output to print.
|
||||
color: The color to use for the text.
|
||||
observation_prefix: The observation prefix.
|
||||
llm_prefix: The LLM prefix.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
output = str(output)
|
||||
if observation_prefix is not None:
|
||||
print_text(f"\n{observation_prefix}")
|
||||
print_text(output, color=color or self.color)
|
||||
if llm_prefix is not None:
|
||||
print_text(f"\n{llm_prefix}")
|
||||
|
||||
@override
|
||||
def on_text(
|
||||
self,
|
||||
text: str,
|
||||
color: str | None = None,
|
||||
end: str = "",
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Run when the agent ends.
|
||||
|
||||
Args:
|
||||
text: The text to print.
|
||||
color: The color to use for the text.
|
||||
end: The end character to use.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
print_text(text, color=color or self.color, end=end)
|
||||
|
||||
@override
|
||||
def on_agent_finish(
|
||||
self, finish: AgentFinish, color: str | None = None, **kwargs: Any
|
||||
) -> None:
|
||||
"""Run on the agent end.
|
||||
|
||||
Args:
|
||||
finish: The agent finish.
|
||||
color: The color to use for the text.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
print_text(finish.log, color=color or self.color, end="\n")
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Callback Handler streams to stdout on new llm token."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from typing_extensions import override
|
||||
|
||||
from langchain_core.callbacks.base import BaseCallbackHandler
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langchain_core.agents import AgentAction, AgentFinish
|
||||
from langchain_core.messages import BaseMessage
|
||||
from langchain_core.outputs import LLMResult
|
||||
|
||||
|
||||
class StreamingStdOutCallbackHandler(BaseCallbackHandler):
|
||||
"""Callback handler for streaming.
|
||||
|
||||
!!! warning "Only works with LLMs that support streaming."
|
||||
"""
|
||||
|
||||
def on_llm_start(
|
||||
self, serialized: dict[str, Any], prompts: list[str], **kwargs: Any
|
||||
) -> None:
|
||||
"""Run when LLM starts running.
|
||||
|
||||
Args:
|
||||
serialized: The serialized LLM.
|
||||
prompts: The prompts to run.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
|
||||
def on_chat_model_start(
|
||||
self,
|
||||
serialized: dict[str, Any],
|
||||
messages: list[list[BaseMessage]],
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Run when LLM starts running.
|
||||
|
||||
Args:
|
||||
serialized: The serialized LLM.
|
||||
messages: The messages to run.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
|
||||
@override
|
||||
def on_llm_new_token(self, token: str, **kwargs: Any) -> None:
|
||||
"""Run on new LLM token. Only available when streaming is enabled.
|
||||
|
||||
Args:
|
||||
token: The new token.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
sys.stdout.write(token)
|
||||
sys.stdout.flush()
|
||||
|
||||
def on_llm_end(self, response: LLMResult, **kwargs: Any) -> None:
|
||||
"""Run when LLM ends running.
|
||||
|
||||
Args:
|
||||
response: The response from the LLM.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
|
||||
def on_llm_error(self, error: BaseException, **kwargs: Any) -> None:
|
||||
"""Run when LLM errors.
|
||||
|
||||
Args:
|
||||
error: The error that occurred.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
|
||||
def on_chain_start(
|
||||
self, serialized: dict[str, Any], inputs: dict[str, Any], **kwargs: Any
|
||||
) -> None:
|
||||
"""Run when a chain starts running.
|
||||
|
||||
Args:
|
||||
serialized: The serialized chain.
|
||||
inputs: The inputs to the chain.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
|
||||
def on_chain_end(self, outputs: dict[str, Any], **kwargs: Any) -> None:
|
||||
"""Run when a chain ends running.
|
||||
|
||||
Args:
|
||||
outputs: The outputs of the chain.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
|
||||
def on_chain_error(self, error: BaseException, **kwargs: Any) -> None:
|
||||
"""Run when chain errors.
|
||||
|
||||
Args:
|
||||
error: The error that occurred.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
|
||||
def on_tool_start(
|
||||
self, serialized: dict[str, Any], input_str: str, **kwargs: Any
|
||||
) -> None:
|
||||
"""Run when the tool starts running.
|
||||
|
||||
Args:
|
||||
serialized: The serialized tool.
|
||||
input_str: The input string.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
|
||||
def on_agent_action(self, action: AgentAction, **kwargs: Any) -> Any:
|
||||
"""Run on agent action.
|
||||
|
||||
Args:
|
||||
action: The agent action.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
|
||||
def on_tool_end(self, output: Any, **kwargs: Any) -> None:
|
||||
"""Run when tool ends running.
|
||||
|
||||
Args:
|
||||
output: The output of the tool.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
|
||||
def on_tool_error(self, error: BaseException, **kwargs: Any) -> None:
|
||||
"""Run when tool errors.
|
||||
|
||||
Args:
|
||||
error: The error that occurred.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
|
||||
def on_text(self, text: str, **kwargs: Any) -> None:
|
||||
"""Run on an arbitrary text.
|
||||
|
||||
Args:
|
||||
text: The text to print.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
|
||||
def on_agent_finish(self, finish: AgentFinish, **kwargs: Any) -> None:
|
||||
"""Run on the agent end.
|
||||
|
||||
Args:
|
||||
finish: The agent finish.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Callback Handler that tracks `AIMessage.usage_metadata`."""
|
||||
|
||||
import threading
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from typing import Any
|
||||
|
||||
from typing_extensions import override
|
||||
|
||||
from langchain_core.callbacks import BaseCallbackHandler
|
||||
from langchain_core.messages import AIMessage
|
||||
from langchain_core.messages.ai import UsageMetadata, add_usage
|
||||
from langchain_core.outputs import ChatGeneration, LLMResult
|
||||
from langchain_core.tracers.context import register_configure_hook
|
||||
|
||||
|
||||
class UsageMetadataCallbackHandler(BaseCallbackHandler):
|
||||
"""Callback Handler that tracks `AIMessage.usage_metadata`.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from langchain.chat_models import init_chat_model
|
||||
from langchain_core.callbacks import UsageMetadataCallbackHandler
|
||||
|
||||
llm_1 = init_chat_model(model="openai:gpt-4o-mini")
|
||||
llm_2 = init_chat_model(model="anthropic:claude-haiku-4-5-20251001")
|
||||
|
||||
callback = UsageMetadataCallbackHandler()
|
||||
result_1 = llm_1.invoke("Hello", config={"callbacks": [callback]})
|
||||
result_2 = llm_2.invoke("Hello", config={"callbacks": [callback]})
|
||||
callback.usage_metadata
|
||||
```
|
||||
|
||||
```txt
|
||||
{'gpt-4o-mini-2024-07-18': {'input_tokens': 8,
|
||||
'output_tokens': 10,
|
||||
'total_tokens': 18,
|
||||
'input_token_details': {'audio': 0, 'cache_read': 0},
|
||||
'output_token_details': {'audio': 0, 'reasoning': 0}},
|
||||
'claude-haiku-4-5-20251001': {'input_tokens': 8,
|
||||
'output_tokens': 21,
|
||||
'total_tokens': 29,
|
||||
'input_token_details': {'cache_read': 0, 'cache_creation': 0}}}
|
||||
```
|
||||
|
||||
!!! version-added "Added in `langchain-core` 0.3.49"
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the `UsageMetadataCallbackHandler`."""
|
||||
super().__init__()
|
||||
self._lock = threading.Lock()
|
||||
self.usage_metadata: dict[str, UsageMetadata] = {}
|
||||
|
||||
@override
|
||||
def __repr__(self) -> str:
|
||||
return str(self.usage_metadata)
|
||||
|
||||
@override
|
||||
def on_llm_end(self, response: LLMResult, **kwargs: Any) -> None:
|
||||
"""Collect token usage."""
|
||||
# Check for usage_metadata (langchain-core >= 0.2.2)
|
||||
try:
|
||||
generation = response.generations[0][0]
|
||||
except IndexError:
|
||||
generation = None
|
||||
|
||||
usage_metadata = None
|
||||
model_name = None
|
||||
if isinstance(generation, ChatGeneration):
|
||||
try:
|
||||
message = generation.message
|
||||
if isinstance(message, AIMessage):
|
||||
usage_metadata = message.usage_metadata
|
||||
model_name = message.response_metadata.get("model_name")
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
# update shared state behind lock
|
||||
if usage_metadata and model_name:
|
||||
with self._lock:
|
||||
if model_name not in self.usage_metadata:
|
||||
self.usage_metadata[model_name] = usage_metadata
|
||||
else:
|
||||
self.usage_metadata[model_name] = add_usage(
|
||||
self.usage_metadata[model_name], usage_metadata
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_usage_metadata_callback(
|
||||
name: str = "usage_metadata_callback",
|
||||
) -> Generator[UsageMetadataCallbackHandler, None, None]:
|
||||
"""Get usage metadata callback.
|
||||
|
||||
Get context manager for tracking usage metadata across chat model calls using
|
||||
[`AIMessage.usage_metadata`][langchain.messages.AIMessage.usage_metadata].
|
||||
|
||||
Args:
|
||||
name: The name of the context variable.
|
||||
|
||||
Yields:
|
||||
The usage metadata callback.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from langchain.chat_models import init_chat_model
|
||||
from langchain_core.callbacks import get_usage_metadata_callback
|
||||
|
||||
llm_1 = init_chat_model(model="openai:gpt-4o-mini")
|
||||
llm_2 = init_chat_model(model="anthropic:claude-haiku-4-5-20251001")
|
||||
|
||||
with get_usage_metadata_callback() as cb:
|
||||
llm_1.invoke("Hello")
|
||||
llm_2.invoke("Hello")
|
||||
print(cb.usage_metadata)
|
||||
```
|
||||
|
||||
```txt
|
||||
{
|
||||
"gpt-4o-mini-2024-07-18": {
|
||||
"input_tokens": 8,
|
||||
"output_tokens": 10,
|
||||
"total_tokens": 18,
|
||||
"input_token_details": {"audio": 0, "cache_read": 0},
|
||||
"output_token_details": {"audio": 0, "reasoning": 0},
|
||||
},
|
||||
"claude-haiku-4-5-20251001": {
|
||||
"input_tokens": 8,
|
||||
"output_tokens": 21,
|
||||
"total_tokens": 29,
|
||||
"input_token_details": {"cache_read": 0, "cache_creation": 0},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
!!! version-added "Added in `langchain-core` 0.3.49"
|
||||
|
||||
"""
|
||||
usage_metadata_callback_var: ContextVar[UsageMetadataCallbackHandler | None] = (
|
||||
ContextVar(name, default=None)
|
||||
)
|
||||
register_configure_hook(usage_metadata_callback_var, inheritable=True)
|
||||
cb = UsageMetadataCallbackHandler()
|
||||
usage_metadata_callback_var.set(cb)
|
||||
yield cb
|
||||
usage_metadata_callback_var.set(None)
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Chat message history stores a history of the message interactions in a chat."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from langchain_core.messages import (
|
||||
AIMessage,
|
||||
BaseMessage,
|
||||
HumanMessage,
|
||||
get_buffer_string,
|
||||
)
|
||||
from langchain_core.runnables.config import run_in_executor
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
|
||||
class BaseChatMessageHistory(ABC):
|
||||
"""Abstract base class for storing chat message history.
|
||||
|
||||
Implementations guidelines:
|
||||
|
||||
Implementations are expected to over-ride all or some of the following methods:
|
||||
|
||||
* `add_messages`: sync variant for bulk addition of messages
|
||||
* `aadd_messages`: async variant for bulk addition of messages
|
||||
* `messages`: sync variant for getting messages
|
||||
* `aget_messages`: async variant for getting messages
|
||||
* `clear`: sync variant for clearing messages
|
||||
* `aclear`: async variant for clearing messages
|
||||
|
||||
`add_messages` contains a default implementation that calls `add_message`
|
||||
for each message in the sequence. This is provided for backwards compatibility
|
||||
with existing implementations which only had `add_message`.
|
||||
|
||||
Async variants all have default implementations that call the sync variants.
|
||||
Implementers can choose to override the async implementations to provide
|
||||
truly async implementations.
|
||||
|
||||
Usage guidelines:
|
||||
|
||||
When used for updating history, users should favor usage of `add_messages`
|
||||
over `add_message` or other variants like `add_user_message` and `add_ai_message`
|
||||
to avoid unnecessary round-trips to the underlying persistence layer.
|
||||
|
||||
Example:
|
||||
```python
|
||||
import json
|
||||
import os
|
||||
from langchain_core.messages import messages_from_dict, message_to_dict
|
||||
|
||||
|
||||
class FileChatMessageHistory(BaseChatMessageHistory):
|
||||
storage_path: str
|
||||
session_id: str
|
||||
|
||||
@property
|
||||
def messages(self) -> list[BaseMessage]:
|
||||
try:
|
||||
with open(
|
||||
os.path.join(self.storage_path, self.session_id),
|
||||
"r",
|
||||
encoding="utf-8",
|
||||
) as f:
|
||||
messages_data = json.load(f)
|
||||
return messages_from_dict(messages_data)
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
|
||||
def add_messages(self, messages: Sequence[BaseMessage]) -> None:
|
||||
all_messages = list(self.messages) # Existing messages
|
||||
all_messages.extend(messages) # Add new messages
|
||||
|
||||
serialized = [message_to_dict(message) for message in all_messages]
|
||||
file_path = os.path.join(self.storage_path, self.session_id)
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
json.dump(serialized, f)
|
||||
|
||||
def clear(self) -> None:
|
||||
file_path = os.path.join(self.storage_path, self.session_id)
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
json.dump([], f)
|
||||
```
|
||||
"""
|
||||
|
||||
messages: list[BaseMessage]
|
||||
"""A property or attribute that returns a list of messages.
|
||||
|
||||
In general, getting the messages may involve IO to the underlying persistence
|
||||
layer, so this operation is expected to incur some latency.
|
||||
"""
|
||||
|
||||
async def aget_messages(self) -> list[BaseMessage]:
|
||||
"""Async version of getting messages.
|
||||
|
||||
Can over-ride this method to provide an efficient async implementation.
|
||||
|
||||
In general, fetching messages may involve IO to the underlying persistence
|
||||
layer.
|
||||
|
||||
Returns:
|
||||
The messages.
|
||||
"""
|
||||
return await run_in_executor(None, lambda: self.messages)
|
||||
|
||||
def add_user_message(self, message: HumanMessage | str) -> None:
|
||||
"""Convenience method for adding a human message string to the store.
|
||||
|
||||
!!! note
|
||||
|
||||
This is a convenience method. Code should favor the bulk `add_messages`
|
||||
interface instead to save on round-trips to the persistence layer.
|
||||
|
||||
This method may be deprecated in a future release.
|
||||
|
||||
Args:
|
||||
message: The `HumanMessage` to add to the store.
|
||||
"""
|
||||
if isinstance(message, HumanMessage):
|
||||
self.add_message(message)
|
||||
else:
|
||||
self.add_message(HumanMessage(content=message))
|
||||
|
||||
def add_ai_message(self, message: AIMessage | str) -> None:
|
||||
"""Convenience method for adding an `AIMessage` string to the store.
|
||||
|
||||
!!! note
|
||||
|
||||
This is a convenience method. Code should favor the bulk `add_messages`
|
||||
interface instead to save on round-trips to the persistence layer.
|
||||
|
||||
This method may be deprecated in a future release.
|
||||
|
||||
Args:
|
||||
message: The `AIMessage` to add.
|
||||
"""
|
||||
if isinstance(message, AIMessage):
|
||||
self.add_message(message)
|
||||
else:
|
||||
self.add_message(AIMessage(content=message))
|
||||
|
||||
def add_message(self, message: BaseMessage) -> None:
|
||||
"""Add a Message object to the store.
|
||||
|
||||
Args:
|
||||
message: A `BaseMessage` object to store.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If the sub-class has not implemented an efficient
|
||||
`add_messages` method.
|
||||
"""
|
||||
if type(self).add_messages != BaseChatMessageHistory.add_messages:
|
||||
# This means that the sub-class has implemented an efficient add_messages
|
||||
# method, so we should use it.
|
||||
self.add_messages([message])
|
||||
else:
|
||||
msg = (
|
||||
"add_message is not implemented for this class. "
|
||||
"Please implement add_message or add_messages."
|
||||
)
|
||||
raise NotImplementedError(msg)
|
||||
|
||||
def add_messages(self, messages: Sequence[BaseMessage]) -> None:
|
||||
"""Add a list of messages.
|
||||
|
||||
Implementations should over-ride this method to handle bulk addition of messages
|
||||
in an efficient manner to avoid unnecessary round-trips to the underlying store.
|
||||
|
||||
Args:
|
||||
messages: A sequence of `BaseMessage` objects to store.
|
||||
"""
|
||||
for message in messages:
|
||||
self.add_message(message)
|
||||
|
||||
async def aadd_messages(self, messages: Sequence[BaseMessage]) -> None:
|
||||
"""Async add a list of messages.
|
||||
|
||||
Args:
|
||||
messages: A sequence of `BaseMessage` objects to store.
|
||||
"""
|
||||
await run_in_executor(None, self.add_messages, messages)
|
||||
|
||||
@abstractmethod
|
||||
def clear(self) -> None:
|
||||
"""Remove all messages from the store."""
|
||||
|
||||
async def aclear(self) -> None:
|
||||
"""Async remove all messages from the store."""
|
||||
await run_in_executor(None, self.clear)
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Return a string representation of the chat history."""
|
||||
return get_buffer_string(self.messages)
|
||||
|
||||
|
||||
class InMemoryChatMessageHistory(BaseChatMessageHistory, BaseModel):
|
||||
"""In memory implementation of chat message history.
|
||||
|
||||
Stores messages in a memory list.
|
||||
"""
|
||||
|
||||
messages: list[BaseMessage] = Field(default_factory=list)
|
||||
"""A list of messages stored in memory."""
|
||||
|
||||
async def aget_messages(self) -> list[BaseMessage]:
|
||||
"""Async version of getting messages.
|
||||
|
||||
Can over-ride this method to provide an efficient async implementation.
|
||||
|
||||
In general, fetching messages may involve IO to the underlying persistence
|
||||
layer.
|
||||
|
||||
Returns:
|
||||
List of messages.
|
||||
"""
|
||||
return self.messages
|
||||
|
||||
def add_message(self, message: BaseMessage) -> None:
|
||||
"""Add a self-created message to the store.
|
||||
|
||||
Args:
|
||||
message: The message to add.
|
||||
"""
|
||||
self.messages.append(message)
|
||||
|
||||
async def aadd_messages(self, messages: Sequence[BaseMessage]) -> None:
|
||||
"""Async add messages to the store.
|
||||
|
||||
Args:
|
||||
messages: The messages to add.
|
||||
"""
|
||||
self.add_messages(messages)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all messages from the store."""
|
||||
self.messages = []
|
||||
|
||||
async def aclear(self) -> None:
|
||||
"""Async clear all messages from the store."""
|
||||
self.clear()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Chat loaders."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Iterator
|
||||
|
||||
from langchain_core.chat_sessions import ChatSession
|
||||
|
||||
|
||||
class BaseChatLoader(ABC):
|
||||
"""Base class for chat loaders."""
|
||||
|
||||
@abstractmethod
|
||||
def lazy_load(self) -> Iterator[ChatSession]:
|
||||
"""Lazy load the chat sessions.
|
||||
|
||||
Returns:
|
||||
An iterator of chat sessions.
|
||||
"""
|
||||
|
||||
def load(self) -> list[ChatSession]:
|
||||
"""Eagerly load the chat sessions into memory.
|
||||
|
||||
Returns:
|
||||
A list of chat sessions.
|
||||
"""
|
||||
return list(self.lazy_load())
|
||||
@@ -0,0 +1,19 @@
|
||||
"""**Chat Sessions** are a collection of messages and function calls."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import TypedDict
|
||||
|
||||
from langchain_core.messages import BaseMessage
|
||||
|
||||
|
||||
class ChatSession(TypedDict, total=False):
|
||||
"""Chat Session.
|
||||
|
||||
Chat Session represents a single conversation, channel, or other group of messages.
|
||||
"""
|
||||
|
||||
messages: Sequence[BaseMessage]
|
||||
"""A sequence of the LangChain chat messages loaded from the source."""
|
||||
|
||||
functions: Sequence[dict]
|
||||
"""A sequence of the function calling specs for the messages."""
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Cross Encoder interface."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class BaseCrossEncoder(ABC):
|
||||
"""Interface for cross encoder models."""
|
||||
|
||||
@abstractmethod
|
||||
def score(self, text_pairs: list[tuple[str, str]]) -> list[float]:
|
||||
"""Score pairs' similarity.
|
||||
|
||||
Args:
|
||||
text_pairs: List of pairs of texts.
|
||||
|
||||
Returns:
|
||||
List of scores.
|
||||
"""
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Document loaders."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from langchain_core._import_utils import import_attr
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langchain_core.document_loaders.base import BaseBlobParser, BaseLoader
|
||||
from langchain_core.document_loaders.blob_loaders import Blob, BlobLoader, PathLike
|
||||
from langchain_core.document_loaders.langsmith import LangSmithLoader
|
||||
|
||||
__all__ = (
|
||||
"BaseBlobParser",
|
||||
"BaseLoader",
|
||||
"Blob",
|
||||
"BlobLoader",
|
||||
"LangSmithLoader",
|
||||
"PathLike",
|
||||
)
|
||||
|
||||
_dynamic_imports = {
|
||||
"BaseBlobParser": "base",
|
||||
"BaseLoader": "base",
|
||||
"Blob": "blob_loaders",
|
||||
"BlobLoader": "blob_loaders",
|
||||
"PathLike": "blob_loaders",
|
||||
"LangSmithLoader": "langsmith",
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(attr_name: str) -> object:
|
||||
module_name = _dynamic_imports.get(attr_name)
|
||||
result = import_attr(attr_name, module_name, __spec__.parent)
|
||||
globals()[attr_name] = result
|
||||
return result
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return list(__all__)
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,155 @@
|
||||
"""Abstract interface for document loader implementations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from langchain_core.runnables import run_in_executor
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
|
||||
from langchain_text_splitters import TextSplitter
|
||||
|
||||
from langchain_core.documents import Document
|
||||
from langchain_core.documents.base import Blob
|
||||
|
||||
try:
|
||||
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
||||
|
||||
_HAS_TEXT_SPLITTERS = True
|
||||
except ImportError:
|
||||
_HAS_TEXT_SPLITTERS = False
|
||||
|
||||
|
||||
class BaseLoader(ABC): # noqa: B024
|
||||
"""Interface for document loader.
|
||||
|
||||
Implementations should implement the lazy-loading method using generators to avoid
|
||||
loading all documents into memory at once.
|
||||
|
||||
`load` is provided just for user convenience and should not be overridden.
|
||||
"""
|
||||
|
||||
# Sub-classes should not implement this method directly. Instead, they
|
||||
# should implement the lazy load method.
|
||||
def load(self) -> list[Document]:
|
||||
"""Load data into `Document` objects.
|
||||
|
||||
Returns:
|
||||
The documents.
|
||||
"""
|
||||
return list(self.lazy_load())
|
||||
|
||||
async def aload(self) -> list[Document]:
|
||||
"""Load data into `Document` objects.
|
||||
|
||||
Returns:
|
||||
The documents.
|
||||
"""
|
||||
return [document async for document in self.alazy_load()]
|
||||
|
||||
def load_and_split(
|
||||
self, text_splitter: TextSplitter | None = None
|
||||
) -> list[Document]:
|
||||
"""Load `Document` and split into chunks. Chunks are returned as `Document`.
|
||||
|
||||
!!! danger
|
||||
|
||||
Do not override this method. It should be considered to be deprecated!
|
||||
|
||||
Args:
|
||||
text_splitter: `TextSplitter` instance to use for splitting documents.
|
||||
|
||||
Defaults to `RecursiveCharacterTextSplitter`.
|
||||
|
||||
Raises:
|
||||
ImportError: If `langchain-text-splitters` is not installed and no
|
||||
`text_splitter` is provided.
|
||||
|
||||
Returns:
|
||||
List of `Document` objects.
|
||||
"""
|
||||
if text_splitter is None:
|
||||
if not _HAS_TEXT_SPLITTERS:
|
||||
msg = (
|
||||
"Unable to import from langchain_text_splitters. Please specify "
|
||||
"text_splitter or install langchain_text_splitters with "
|
||||
"`pip install -U langchain-text-splitters`."
|
||||
)
|
||||
raise ImportError(msg)
|
||||
|
||||
text_splitter_: TextSplitter = RecursiveCharacterTextSplitter()
|
||||
else:
|
||||
text_splitter_ = text_splitter
|
||||
docs = self.load()
|
||||
return text_splitter_.split_documents(docs)
|
||||
|
||||
# Attention: This method will be upgraded into an abstractmethod once it's
|
||||
# implemented in all the existing subclasses.
|
||||
def lazy_load(self) -> Iterator[Document]:
|
||||
"""A lazy loader for `Document`.
|
||||
|
||||
Yields:
|
||||
The `Document` objects.
|
||||
"""
|
||||
if type(self).load != BaseLoader.load:
|
||||
return iter(self.load())
|
||||
msg = f"{self.__class__.__name__} does not implement lazy_load()"
|
||||
raise NotImplementedError(msg)
|
||||
|
||||
async def alazy_load(self) -> AsyncIterator[Document]:
|
||||
"""A lazy loader for `Document`.
|
||||
|
||||
Yields:
|
||||
The `Document` objects.
|
||||
"""
|
||||
iterator = await run_in_executor(None, self.lazy_load)
|
||||
done = object()
|
||||
while True:
|
||||
doc = await run_in_executor(None, next, iterator, done)
|
||||
if doc is done:
|
||||
break
|
||||
yield doc # type: ignore[misc]
|
||||
|
||||
|
||||
class BaseBlobParser(ABC):
|
||||
"""Abstract interface for blob parsers.
|
||||
|
||||
A blob parser provides a way to parse raw data stored in a blob into one or more
|
||||
`Document` objects.
|
||||
|
||||
The parser can be composed with blob loaders, making it easy to reuse a parser
|
||||
independent of how the blob was originally loaded.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def lazy_parse(self, blob: Blob) -> Iterator[Document]:
|
||||
"""Lazy parsing interface.
|
||||
|
||||
Subclasses are required to implement this method.
|
||||
|
||||
Args:
|
||||
blob: `Blob` instance
|
||||
|
||||
Returns:
|
||||
Generator of `Document` objects
|
||||
"""
|
||||
|
||||
def parse(self, blob: Blob) -> list[Document]:
|
||||
"""Eagerly parse the blob into a `Document` or list of `Document` objects.
|
||||
|
||||
This is a convenience method for interactive development environment.
|
||||
|
||||
Production applications should favor the `lazy_parse` method instead.
|
||||
|
||||
Subclasses should generally not over-ride this parse method.
|
||||
|
||||
Args:
|
||||
blob: `Blob` instance
|
||||
|
||||
Returns:
|
||||
List of `Document` objects
|
||||
"""
|
||||
return list(self.lazy_parse(blob))
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Schema for Blobs and Blob Loaders.
|
||||
|
||||
The goal is to facilitate decoupling of content loading from content parsing code. In
|
||||
addition, content loading code should provide a lazy loading interface by default.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
# Re-export Blob and PathLike for backwards compatibility
|
||||
from langchain_core.documents.base import Blob, PathLike
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
|
||||
class BlobLoader(ABC):
|
||||
"""Abstract interface for blob loaders implementation.
|
||||
|
||||
Implementer should be able to load raw content from a storage system according to
|
||||
some criteria and return the raw content lazily as a stream of blobs.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def yield_blobs(
|
||||
self,
|
||||
) -> Iterator[Blob]:
|
||||
"""A lazy loader for raw data represented by LangChain's `Blob` object.
|
||||
|
||||
Yields:
|
||||
`Blob` objects.
|
||||
"""
|
||||
|
||||
|
||||
# Re-export Blob and Pathlike for backwards compatibility
|
||||
__all__ = ["Blob", "BlobLoader", "PathLike"]
|
||||
@@ -0,0 +1,143 @@
|
||||
"""LangSmith document loader."""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Callable, Iterator, Sequence
|
||||
from typing import Any
|
||||
|
||||
from langsmith import Client as LangSmithClient
|
||||
from typing_extensions import override
|
||||
|
||||
from langchain_core.document_loaders.base import BaseLoader
|
||||
from langchain_core.documents import Document
|
||||
from langchain_core.tracers._compat import pydantic_to_dict
|
||||
|
||||
|
||||
class LangSmithLoader(BaseLoader):
|
||||
"""Load LangSmith Dataset examples as `Document` objects.
|
||||
|
||||
Loads the example inputs as the `Document` page content and places the entire
|
||||
example into the `Document` metadata. This allows you to easily create few-shot
|
||||
example retrievers from the loaded documents.
|
||||
|
||||
??? example "Lazy loading"
|
||||
|
||||
```python
|
||||
from langchain_core.document_loaders import LangSmithLoader
|
||||
|
||||
loader = LangSmithLoader(dataset_id="...", limit=100)
|
||||
docs = []
|
||||
for doc in loader.lazy_load():
|
||||
docs.append(doc)
|
||||
```
|
||||
|
||||
```python
|
||||
# -> [Document("...", metadata={"inputs": {...}, "outputs": {...}, ...}), ...]
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
dataset_id: uuid.UUID | str | None = None,
|
||||
dataset_name: str | None = None,
|
||||
example_ids: Sequence[uuid.UUID | str] | None = None,
|
||||
as_of: datetime.datetime | str | None = None,
|
||||
splits: Sequence[str] | None = None,
|
||||
inline_s3_urls: bool = True,
|
||||
offset: int = 0,
|
||||
limit: int | None = None,
|
||||
metadata: dict | None = None,
|
||||
filter: str | None = None, # noqa: A002
|
||||
content_key: str = "",
|
||||
format_content: Callable[..., str] | None = None,
|
||||
client: LangSmithClient | None = None,
|
||||
**client_kwargs: Any,
|
||||
) -> None:
|
||||
"""Create a LangSmith loader.
|
||||
|
||||
Args:
|
||||
dataset_id: The ID of the dataset to filter by.
|
||||
dataset_name: The name of the dataset to filter by.
|
||||
content_key: The inputs key to set as `Document` page content.
|
||||
|
||||
`'.'` characters are interpreted as nested keys, e.g.
|
||||
`content_key="first.second"` will result in
|
||||
`Document(page_content=format_content(example.inputs["first"]["second"]))`
|
||||
format_content: Function for converting the content extracted from the example
|
||||
inputs into a string.
|
||||
|
||||
Defaults to JSON-encoding the contents.
|
||||
example_ids: The IDs of the examples to filter by.
|
||||
as_of: The dataset version tag or timestamp to retrieve the examples as of.
|
||||
|
||||
Response examples will only be those that were present at the time of
|
||||
the tagged (or timestamped) version.
|
||||
splits: A list of dataset splits, which are divisions of your dataset such
|
||||
as `train`, `test`, or `validation`.
|
||||
|
||||
Returns examples only from the specified splits.
|
||||
inline_s3_urls: Whether to inline S3 URLs.
|
||||
offset: The offset to start from.
|
||||
limit: The maximum number of examples to return.
|
||||
metadata: Metadata to filter by.
|
||||
filter: A structured filter string to apply to the examples.
|
||||
client: LangSmith Client.
|
||||
|
||||
If not provided will be initialized from below args.
|
||||
client_kwargs: Keyword args to pass to LangSmith client init.
|
||||
|
||||
Should only be specified if `client` isn't.
|
||||
|
||||
Raises:
|
||||
ValueError: If both `client` and `client_kwargs` are provided.
|
||||
""" # noqa: E501
|
||||
if client and client_kwargs:
|
||||
raise ValueError
|
||||
self._client = client or LangSmithClient(**client_kwargs)
|
||||
self.content_key = list(content_key.split(".")) if content_key else []
|
||||
self.format_content = format_content or _stringify
|
||||
self.dataset_id = dataset_id
|
||||
self.dataset_name = dataset_name
|
||||
self.example_ids = example_ids
|
||||
self.as_of = as_of
|
||||
self.splits = splits
|
||||
self.inline_s3_urls = inline_s3_urls
|
||||
self.offset = offset
|
||||
self.limit = limit
|
||||
self.metadata = metadata
|
||||
self.filter = filter
|
||||
|
||||
@override
|
||||
def lazy_load(self) -> Iterator[Document]:
|
||||
for example in self._client.list_examples(
|
||||
dataset_id=self.dataset_id,
|
||||
dataset_name=self.dataset_name,
|
||||
example_ids=self.example_ids,
|
||||
as_of=self.as_of,
|
||||
splits=self.splits,
|
||||
inline_s3_urls=self.inline_s3_urls,
|
||||
offset=self.offset,
|
||||
limit=self.limit,
|
||||
metadata=self.metadata,
|
||||
filter=self.filter,
|
||||
):
|
||||
content: Any = example.inputs
|
||||
for key in self.content_key:
|
||||
content = content[key]
|
||||
content_str = self.format_content(content)
|
||||
metadata = pydantic_to_dict(example)
|
||||
# Stringify datetime and UUID types.
|
||||
for k in ("dataset_id", "created_at", "modified_at", "source_run_id", "id"):
|
||||
metadata[k] = str(metadata[k]) if metadata[k] else metadata[k]
|
||||
yield Document(content_str, metadata=metadata)
|
||||
|
||||
|
||||
def _stringify(x: str | dict[str, Any]) -> str:
|
||||
if isinstance(x, str):
|
||||
return x
|
||||
try:
|
||||
return json.dumps(x, indent=2)
|
||||
except Exception:
|
||||
return str(x)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Documents module for data retrieval and processing workflows.
|
||||
|
||||
This module provides core abstractions for handling data in retrieval-augmented
|
||||
generation (RAG) pipelines, vector stores, and document processing workflows.
|
||||
|
||||
!!! warning "Documents vs. message content"
|
||||
|
||||
This module is distinct from `langchain_core.messages.content`, which provides
|
||||
multimodal content blocks for **LLM chat I/O** (text, images, audio, etc. within
|
||||
messages).
|
||||
|
||||
**Key distinction:**
|
||||
|
||||
- **Documents** (this module): For **data retrieval and processing workflows**
|
||||
- Vector stores, retrievers, RAG pipelines
|
||||
- Text chunking, embedding, and semantic search
|
||||
- Example: Chunks of a PDF stored in a vector database
|
||||
|
||||
- **Content Blocks** (`messages.content`): For **LLM conversational I/O**
|
||||
- Multimodal message content sent to/from models
|
||||
- Tool calls, reasoning, citations within chat
|
||||
- Example: An image sent to a vision model in a chat message (via
|
||||
[`ImageContentBlock`][langchain.messages.ImageContentBlock])
|
||||
|
||||
While both can represent similar data types (text, files), they serve different
|
||||
architectural purposes in LangChain applications.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from langchain_core._import_utils import import_attr
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langchain_core.documents.base import Document
|
||||
from langchain_core.documents.compressor import BaseDocumentCompressor
|
||||
from langchain_core.documents.transformers import BaseDocumentTransformer
|
||||
|
||||
__all__ = ("BaseDocumentCompressor", "BaseDocumentTransformer", "Document")
|
||||
|
||||
_dynamic_imports = {
|
||||
"Document": "base",
|
||||
"BaseDocumentCompressor": "compressor",
|
||||
"BaseDocumentTransformer": "transformers",
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(attr_name: str) -> object:
|
||||
module_name = _dynamic_imports.get(attr_name)
|
||||
result = import_attr(attr_name, module_name, __spec__.parent)
|
||||
globals()[attr_name] = result
|
||||
return result
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return list(__all__)
|
||||
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,347 @@
|
||||
"""Base classes for media and documents.
|
||||
|
||||
This module contains core abstractions for **data retrieval and processing workflows**:
|
||||
|
||||
- `BaseMedia`: Base class providing `id` and `metadata` fields
|
||||
- `Blob`: Raw data loading (files, binary data) - used by document loaders
|
||||
- `Document`: Text content for retrieval (RAG, vector stores, semantic search)
|
||||
|
||||
!!! note "Not for LLM chat messages"
|
||||
|
||||
These classes are for data processing pipelines, not LLM I/O. For multimodal
|
||||
content in chat messages (images, audio in conversations), see
|
||||
`langchain.messages` content blocks instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import mimetypes
|
||||
from io import BufferedReader, BytesIO
|
||||
from pathlib import Path, PurePath
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||
|
||||
from pydantic import ConfigDict, Field, model_validator
|
||||
|
||||
from langchain_core.load.serializable import Serializable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Generator
|
||||
|
||||
PathLike = str | PurePath
|
||||
|
||||
|
||||
class BaseMedia(Serializable):
|
||||
"""Base class for content used in retrieval and data processing workflows.
|
||||
|
||||
Provides common fields for content that needs to be stored, indexed, or searched.
|
||||
|
||||
!!! note
|
||||
|
||||
For multimodal content in **chat messages** (images, audio sent to/from LLMs),
|
||||
use `langchain.messages` content blocks instead.
|
||||
"""
|
||||
|
||||
# The ID field is optional at the moment.
|
||||
# It will likely become required in a future major release after
|
||||
# it has been adopted by enough VectorStore implementations.
|
||||
id: str | None = Field(default=None, coerce_numbers_to_str=True)
|
||||
"""An optional identifier for the document.
|
||||
|
||||
Ideally this should be unique across the document collection and formatted
|
||||
as a UUID, but this will not be enforced.
|
||||
"""
|
||||
|
||||
metadata: dict = Field(default_factory=dict)
|
||||
"""Arbitrary metadata associated with the content."""
|
||||
|
||||
|
||||
class Blob(BaseMedia):
|
||||
"""Raw data abstraction for document loading and file processing.
|
||||
|
||||
Represents raw bytes or text, either in-memory or by file reference. Used
|
||||
primarily by document loaders to decouple data loading from parsing.
|
||||
|
||||
Inspired by [Mozilla's `Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob)
|
||||
|
||||
???+ example "Initialize a blob from in-memory data"
|
||||
|
||||
```python
|
||||
from langchain_core.documents import Blob
|
||||
|
||||
blob = Blob.from_data("Hello, world!")
|
||||
|
||||
# Read the blob as a string
|
||||
print(blob.as_string())
|
||||
|
||||
# Read the blob as bytes
|
||||
print(blob.as_bytes())
|
||||
|
||||
# Read the blob as a byte stream
|
||||
with blob.as_bytes_io() as f:
|
||||
print(f.read())
|
||||
```
|
||||
|
||||
??? example "Load from memory and specify MIME type and metadata"
|
||||
|
||||
```python
|
||||
from langchain_core.documents import Blob
|
||||
|
||||
blob = Blob.from_data(
|
||||
data="Hello, world!",
|
||||
mime_type="text/plain",
|
||||
metadata={"source": "https://example.com"},
|
||||
)
|
||||
```
|
||||
|
||||
??? example "Load the blob from a file"
|
||||
|
||||
```python
|
||||
from langchain_core.documents import Blob
|
||||
|
||||
blob = Blob.from_path("path/to/file.txt")
|
||||
|
||||
# Read the blob as a string
|
||||
print(blob.as_string())
|
||||
|
||||
# Read the blob as bytes
|
||||
print(blob.as_bytes())
|
||||
|
||||
# Read the blob as a byte stream
|
||||
with blob.as_bytes_io() as f:
|
||||
print(f.read())
|
||||
```
|
||||
"""
|
||||
|
||||
data: bytes | str | None = None
|
||||
"""Raw data associated with the `Blob`."""
|
||||
|
||||
mimetype: str | None = None
|
||||
"""MIME type, not to be confused with a file extension."""
|
||||
|
||||
encoding: str = "utf-8"
|
||||
"""Encoding to use if decoding the bytes into a string.
|
||||
|
||||
Uses `utf-8` as default encoding if decoding to string.
|
||||
"""
|
||||
|
||||
path: PathLike | None = None
|
||||
"""Location where the original content was found."""
|
||||
|
||||
model_config = ConfigDict(
|
||||
arbitrary_types_allowed=True,
|
||||
frozen=True,
|
||||
)
|
||||
|
||||
@property
|
||||
def source(self) -> str | None:
|
||||
"""The source location of the blob as string if known otherwise none.
|
||||
|
||||
If a path is associated with the `Blob`, it will default to the path location.
|
||||
|
||||
Unless explicitly set via a metadata field called `'source'`, in which
|
||||
case that value will be used instead.
|
||||
"""
|
||||
if self.metadata and "source" in self.metadata:
|
||||
return cast("str | None", self.metadata["source"])
|
||||
return str(self.path) if self.path else None
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def check_blob_is_valid(cls, values: dict[str, Any]) -> Any:
|
||||
"""Verify that either data or path is provided."""
|
||||
if "data" not in values and "path" not in values:
|
||||
msg = "Either data or path must be provided"
|
||||
raise ValueError(msg)
|
||||
return values
|
||||
|
||||
def as_string(self) -> str:
|
||||
"""Read data as a string.
|
||||
|
||||
Raises:
|
||||
ValueError: If the blob cannot be represented as a string.
|
||||
|
||||
Returns:
|
||||
The data as a string.
|
||||
"""
|
||||
if self.data is None and self.path:
|
||||
return Path(self.path).read_text(encoding=self.encoding)
|
||||
if isinstance(self.data, bytes):
|
||||
return self.data.decode(self.encoding)
|
||||
if isinstance(self.data, str):
|
||||
return self.data
|
||||
msg = f"Unable to get string for blob {self}"
|
||||
raise ValueError(msg)
|
||||
|
||||
def as_bytes(self) -> bytes:
|
||||
"""Read data as bytes.
|
||||
|
||||
Raises:
|
||||
ValueError: If the blob cannot be represented as bytes.
|
||||
|
||||
Returns:
|
||||
The data as bytes.
|
||||
"""
|
||||
if isinstance(self.data, bytes):
|
||||
return self.data
|
||||
if isinstance(self.data, str):
|
||||
return self.data.encode(self.encoding)
|
||||
if self.data is None and self.path:
|
||||
return Path(self.path).read_bytes()
|
||||
msg = f"Unable to get bytes for blob {self}"
|
||||
raise ValueError(msg)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def as_bytes_io(self) -> Generator[BytesIO | BufferedReader, None, None]:
|
||||
"""Read data as a byte stream.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If the blob cannot be represented as a byte stream.
|
||||
|
||||
Yields:
|
||||
The data as a byte stream.
|
||||
"""
|
||||
if isinstance(self.data, bytes):
|
||||
yield BytesIO(self.data)
|
||||
elif self.data is None and self.path:
|
||||
with Path(self.path).open("rb") as f:
|
||||
yield f
|
||||
else:
|
||||
msg = f"Unable to convert blob {self}"
|
||||
raise NotImplementedError(msg)
|
||||
|
||||
@classmethod
|
||||
def from_path(
|
||||
cls,
|
||||
path: PathLike,
|
||||
*,
|
||||
encoding: str = "utf-8",
|
||||
mime_type: str | None = None,
|
||||
guess_type: bool = True,
|
||||
metadata: dict | None = None,
|
||||
) -> Blob:
|
||||
"""Load the blob from a path like object.
|
||||
|
||||
Args:
|
||||
path: Path-like object to file to be read
|
||||
encoding: Encoding to use if decoding the bytes into a string
|
||||
mime_type: If provided, will be set as the MIME type of the data
|
||||
guess_type: If `True`, the MIME type will be guessed from the file
|
||||
extension, if a MIME type was not provided
|
||||
metadata: Metadata to associate with the `Blob`
|
||||
|
||||
Returns:
|
||||
`Blob` instance
|
||||
"""
|
||||
if mime_type is None and guess_type:
|
||||
mimetype = mimetypes.guess_type(path)[0]
|
||||
else:
|
||||
mimetype = mime_type
|
||||
# We do not load the data immediately, instead we treat the blob as a
|
||||
# reference to the underlying data.
|
||||
return cls(
|
||||
data=None,
|
||||
mimetype=mimetype,
|
||||
encoding=encoding,
|
||||
path=path,
|
||||
metadata=metadata if metadata is not None else {},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_data(
|
||||
cls,
|
||||
data: str | bytes,
|
||||
*,
|
||||
encoding: str = "utf-8",
|
||||
mime_type: str | None = None,
|
||||
path: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
) -> Blob:
|
||||
"""Initialize the `Blob` from in-memory data.
|
||||
|
||||
Args:
|
||||
data: The in-memory data associated with the `Blob`
|
||||
encoding: Encoding to use if decoding the bytes into a string
|
||||
mime_type: If provided, will be set as the MIME type of the data
|
||||
path: If provided, will be set as the source from which the data came
|
||||
metadata: Metadata to associate with the `Blob`
|
||||
|
||||
Returns:
|
||||
`Blob` instance
|
||||
"""
|
||||
return cls(
|
||||
data=data,
|
||||
mimetype=mime_type,
|
||||
encoding=encoding,
|
||||
path=path,
|
||||
metadata=metadata if metadata is not None else {},
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return the blob representation."""
|
||||
str_repr = f"Blob {id(self)}"
|
||||
if self.source:
|
||||
str_repr += f" {self.source}"
|
||||
return str_repr
|
||||
|
||||
|
||||
class Document(BaseMedia):
|
||||
"""Class for storing a piece of text and associated metadata.
|
||||
|
||||
!!! note
|
||||
|
||||
`Document` is for **retrieval workflows**, not chat I/O. For sending text
|
||||
to an LLM in a conversation, use message types from `langchain.messages`.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from langchain_core.documents import Document
|
||||
|
||||
document = Document(
|
||||
page_content="Hello, world!", metadata={"source": "https://example.com"}
|
||||
)
|
||||
```
|
||||
"""
|
||||
|
||||
page_content: str
|
||||
"""String text."""
|
||||
|
||||
type: Literal["Document"] = "Document"
|
||||
|
||||
def __init__(self, page_content: str, **kwargs: Any) -> None:
|
||||
"""Pass page_content in as positional or named arg."""
|
||||
# my-py is complaining that page_content is not defined on the base class.
|
||||
# Here, we're relying on pydantic base class to handle the validation.
|
||||
super().__init__(page_content=page_content, **kwargs) # type: ignore[call-arg,unused-ignore]
|
||||
|
||||
@classmethod
|
||||
def is_lc_serializable(cls) -> bool:
|
||||
"""Return `True` as this class is serializable."""
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def get_lc_namespace(cls) -> list[str]:
|
||||
"""Get the namespace of the LangChain object.
|
||||
|
||||
Returns:
|
||||
`["langchain", "schema", "document"]`
|
||||
"""
|
||||
return ["langchain", "schema", "document"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Override `__str__` to restrict it to page_content and metadata.
|
||||
|
||||
Returns:
|
||||
A string representation of the `Document`.
|
||||
"""
|
||||
# The format matches pydantic format for __str__.
|
||||
#
|
||||
# The purpose of this change is to make sure that user code that feeds
|
||||
# Document objects directly into prompts remains unchanged due to the addition
|
||||
# of the id field (or any other fields in the future).
|
||||
#
|
||||
# This override will likely be removed in the future in favor of a more general
|
||||
# solution of formatting content directly inside the prompts.
|
||||
if self.metadata:
|
||||
return f"page_content='{self.page_content}' metadata={self.metadata}"
|
||||
return f"page_content='{self.page_content}'"
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Document compressor."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from langchain_core.runnables import run_in_executor
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from langchain_core.callbacks import Callbacks
|
||||
from langchain_core.documents import Document
|
||||
|
||||
|
||||
class BaseDocumentCompressor(BaseModel, ABC):
|
||||
"""Base class for document compressors.
|
||||
|
||||
This abstraction is primarily used for post-processing of retrieved documents.
|
||||
|
||||
`Document` objects matching a given query are first retrieved.
|
||||
|
||||
Then the list of documents can be further processed.
|
||||
|
||||
For example, one could re-rank the retrieved documents using an LLM.
|
||||
|
||||
!!! note
|
||||
Users should favor using a `RunnableLambda` instead of sub-classing from this
|
||||
interface.
|
||||
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def compress_documents(
|
||||
self,
|
||||
documents: Sequence[Document],
|
||||
query: str,
|
||||
callbacks: Callbacks | None = None,
|
||||
) -> Sequence[Document]:
|
||||
"""Compress retrieved documents given the query context.
|
||||
|
||||
Args:
|
||||
documents: The retrieved `Document` objects.
|
||||
query: The query context.
|
||||
callbacks: Optional `Callbacks` to run during compression.
|
||||
|
||||
Returns:
|
||||
The compressed documents.
|
||||
|
||||
"""
|
||||
|
||||
async def acompress_documents(
|
||||
self,
|
||||
documents: Sequence[Document],
|
||||
query: str,
|
||||
callbacks: Callbacks | None = None,
|
||||
) -> Sequence[Document]:
|
||||
"""Async compress retrieved documents given the query context.
|
||||
|
||||
Args:
|
||||
documents: The retrieved `Document` objects.
|
||||
query: The query context.
|
||||
callbacks: Optional `Callbacks` to run during compression.
|
||||
|
||||
Returns:
|
||||
The compressed documents.
|
||||
|
||||
"""
|
||||
return await run_in_executor(
|
||||
None, self.compress_documents, documents, query, callbacks
|
||||
)
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Document transformers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from langchain_core.runnables.config import run_in_executor
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from langchain_core.documents import Document
|
||||
|
||||
|
||||
class BaseDocumentTransformer(ABC):
|
||||
"""Abstract base class for document transformation.
|
||||
|
||||
A document transformation takes a sequence of `Document` objects and returns a
|
||||
sequence of transformed `Document` objects.
|
||||
|
||||
Example:
|
||||
```python
|
||||
class EmbeddingsRedundantFilter(BaseDocumentTransformer, BaseModel):
|
||||
embeddings: Embeddings
|
||||
similarity_fn: Callable = cosine_similarity
|
||||
similarity_threshold: float = 0.95
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
def transform_documents(
|
||||
self, documents: Sequence[Document], **kwargs: Any
|
||||
) -> Sequence[Document]:
|
||||
stateful_documents = get_stateful_documents(documents)
|
||||
embedded_documents = _get_embeddings_from_stateful_docs(
|
||||
self.embeddings, stateful_documents
|
||||
)
|
||||
included_idxs = _filter_similar_embeddings(
|
||||
embedded_documents,
|
||||
self.similarity_fn,
|
||||
self.similarity_threshold,
|
||||
)
|
||||
return [stateful_documents[i] for i in sorted(included_idxs)]
|
||||
|
||||
async def atransform_documents(
|
||||
self, documents: Sequence[Document], **kwargs: Any
|
||||
) -> Sequence[Document]:
|
||||
raise NotImplementedError
|
||||
```
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def transform_documents(
|
||||
self, documents: Sequence[Document], **kwargs: Any
|
||||
) -> Sequence[Document]:
|
||||
"""Transform a list of documents.
|
||||
|
||||
Args:
|
||||
documents: A sequence of `Document` objects to be transformed.
|
||||
|
||||
Returns:
|
||||
A sequence of transformed `Document` objects.
|
||||
"""
|
||||
|
||||
async def atransform_documents(
|
||||
self, documents: Sequence[Document], **kwargs: Any
|
||||
) -> Sequence[Document]:
|
||||
"""Asynchronously transform a list of documents.
|
||||
|
||||
Args:
|
||||
documents: A sequence of `Document` objects to be transformed.
|
||||
|
||||
Returns:
|
||||
A sequence of transformed `Document` objects.
|
||||
"""
|
||||
return await run_in_executor(
|
||||
None, self.transform_documents, documents, **kwargs
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Embeddings."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from langchain_core._import_utils import import_attr
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langchain_core.embeddings.embeddings import Embeddings
|
||||
from langchain_core.embeddings.fake import (
|
||||
DeterministicFakeEmbedding,
|
||||
FakeEmbeddings,
|
||||
)
|
||||
|
||||
__all__ = ("DeterministicFakeEmbedding", "Embeddings", "FakeEmbeddings")
|
||||
|
||||
_dynamic_imports = {
|
||||
"Embeddings": "embeddings",
|
||||
"DeterministicFakeEmbedding": "fake",
|
||||
"FakeEmbeddings": "fake",
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(attr_name: str) -> object:
|
||||
module_name = _dynamic_imports.get(attr_name)
|
||||
result = import_attr(attr_name, module_name, __spec__.parent)
|
||||
globals()[attr_name] = result
|
||||
return result
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return list(__all__)
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,78 @@
|
||||
"""**Embeddings** interface."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from langchain_core.runnables.config import run_in_executor
|
||||
|
||||
|
||||
class Embeddings(ABC):
|
||||
"""Interface for embedding models.
|
||||
|
||||
This is an interface meant for implementing text embedding models.
|
||||
|
||||
Text embedding models are used to map text to a vector (a point in n-dimensional
|
||||
space).
|
||||
|
||||
Texts that are similar will usually be mapped to points that are close to each
|
||||
other in this space. The exact details of what's considered "similar" and how
|
||||
"distance" is measured in this space are dependent on the specific embedding model.
|
||||
|
||||
This abstraction contains a method for embedding a list of documents and a method
|
||||
for embedding a query text. The embedding of a query text is expected to be a single
|
||||
vector, while the embedding of a list of documents is expected to be a list of
|
||||
vectors.
|
||||
|
||||
Usually the query embedding is identical to the document embedding, but the
|
||||
abstraction allows treating them independently.
|
||||
|
||||
In addition to the synchronous methods, this interface also provides asynchronous
|
||||
versions of the methods.
|
||||
|
||||
By default, the asynchronous methods are implemented using the synchronous methods;
|
||||
however, implementations may choose to override the asynchronous methods with
|
||||
an async native implementation for performance reasons.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def embed_documents(self, texts: list[str]) -> list[list[float]]:
|
||||
"""Embed search docs.
|
||||
|
||||
Args:
|
||||
texts: List of text to embed.
|
||||
|
||||
Returns:
|
||||
List of embeddings.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def embed_query(self, text: str) -> list[float]:
|
||||
"""Embed query text.
|
||||
|
||||
Args:
|
||||
text: Text to embed.
|
||||
|
||||
Returns:
|
||||
Embedding.
|
||||
"""
|
||||
|
||||
async def aembed_documents(self, texts: list[str]) -> list[list[float]]:
|
||||
"""Asynchronous Embed search docs.
|
||||
|
||||
Args:
|
||||
texts: List of text to embed.
|
||||
|
||||
Returns:
|
||||
List of embeddings.
|
||||
"""
|
||||
return await run_in_executor(None, self.embed_documents, texts)
|
||||
|
||||
async def aembed_query(self, text: str) -> list[float]:
|
||||
"""Asynchronous Embed query text.
|
||||
|
||||
Args:
|
||||
text: Text to embed.
|
||||
|
||||
Returns:
|
||||
Embedding.
|
||||
"""
|
||||
return await run_in_executor(None, self.embed_query, text)
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Module contains a few fake embedding models for testing purposes."""
|
||||
|
||||
# Please do not add additional fake embedding model implementations here.
|
||||
import contextlib
|
||||
import hashlib
|
||||
|
||||
from pydantic import BaseModel
|
||||
from typing_extensions import override
|
||||
|
||||
from langchain_core.embeddings import Embeddings
|
||||
|
||||
with contextlib.suppress(ImportError):
|
||||
import numpy as np
|
||||
|
||||
|
||||
class FakeEmbeddings(Embeddings, BaseModel):
|
||||
"""Fake embedding model for unit testing purposes.
|
||||
|
||||
This embedding model creates embeddings by sampling from a normal distribution.
|
||||
|
||||
!!! danger "Toy model"
|
||||
Do not use this outside of testing, as it is not a real embedding model.
|
||||
|
||||
Instantiate:
|
||||
```python
|
||||
from langchain_core.embeddings import FakeEmbeddings
|
||||
|
||||
embed = FakeEmbeddings(size=100)
|
||||
```
|
||||
|
||||
Embed single text:
|
||||
```python
|
||||
input_text = "The meaning of life is 42"
|
||||
vector = embed.embed_query(input_text)
|
||||
print(vector[:3])
|
||||
```
|
||||
```python
|
||||
[-0.700234640213188, -0.581266257710429, -1.1328482266445354]
|
||||
```
|
||||
|
||||
Embed multiple texts:
|
||||
```python
|
||||
input_texts = ["Document 1...", "Document 2..."]
|
||||
vectors = embed.embed_documents(input_texts)
|
||||
print(len(vectors))
|
||||
# The first 3 coordinates for the first vector
|
||||
print(vectors[0][:3])
|
||||
```
|
||||
```python
|
||||
2
|
||||
[-0.5670477847544458, -0.31403828652395727, -0.5840547508955257]
|
||||
```
|
||||
"""
|
||||
|
||||
size: int
|
||||
"""The size of the embedding vector."""
|
||||
|
||||
def _get_embedding(self) -> list[float]:
|
||||
return list(np.random.default_rng().normal(size=self.size))
|
||||
|
||||
@override
|
||||
def embed_documents(self, texts: list[str]) -> list[list[float]]:
|
||||
return [self._get_embedding() for _ in texts]
|
||||
|
||||
@override
|
||||
def embed_query(self, text: str) -> list[float]:
|
||||
return self._get_embedding()
|
||||
|
||||
|
||||
class DeterministicFakeEmbedding(Embeddings, BaseModel):
|
||||
"""Deterministic fake embedding model for unit testing purposes.
|
||||
|
||||
This embedding model creates embeddings by sampling from a normal distribution
|
||||
with a seed based on the hash of the text.
|
||||
|
||||
!!! danger "Toy model"
|
||||
Do not use this outside of testing, as it is not a real embedding model.
|
||||
|
||||
Instantiate:
|
||||
```python
|
||||
from langchain_core.embeddings import DeterministicFakeEmbedding
|
||||
|
||||
embed = DeterministicFakeEmbedding(size=100)
|
||||
```
|
||||
|
||||
Embed single text:
|
||||
```python
|
||||
input_text = "The meaning of life is 42"
|
||||
vector = embed.embed_query(input_text)
|
||||
print(vector[:3])
|
||||
```
|
||||
```python
|
||||
[-0.700234640213188, -0.581266257710429, -1.1328482266445354]
|
||||
```
|
||||
|
||||
Embed multiple texts:
|
||||
```python
|
||||
input_texts = ["Document 1...", "Document 2..."]
|
||||
vectors = embed.embed_documents(input_texts)
|
||||
print(len(vectors))
|
||||
# The first 3 coordinates for the first vector
|
||||
print(vectors[0][:3])
|
||||
```
|
||||
```python
|
||||
2
|
||||
[-0.5670477847544458, -0.31403828652395727, -0.5840547508955257]
|
||||
```
|
||||
"""
|
||||
|
||||
size: int
|
||||
"""The size of the embedding vector."""
|
||||
|
||||
def _get_embedding(self, seed: int) -> list[float]:
|
||||
# set the seed for the random generator
|
||||
rng = np.random.default_rng(seed)
|
||||
return list(rng.normal(size=self.size))
|
||||
|
||||
@staticmethod
|
||||
def _get_seed(text: str) -> int:
|
||||
"""Get a seed for the random generator, using the hash of the text."""
|
||||
return int(hashlib.sha256(text.encode("utf-8")).hexdigest(), 16) % 10**8
|
||||
|
||||
@override
|
||||
def embed_documents(self, texts: list[str]) -> list[list[float]]:
|
||||
return [self._get_embedding(seed=self._get_seed(_)) for _ in texts]
|
||||
|
||||
@override
|
||||
def embed_query(self, text: str) -> list[float]:
|
||||
return self._get_embedding(seed=self._get_seed(text))
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Utilities for getting information about the runtime environment."""
|
||||
|
||||
import platform
|
||||
from functools import lru_cache
|
||||
|
||||
from langchain_core import __version__
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_runtime_environment() -> dict:
|
||||
"""Get information about the LangChain runtime environment.
|
||||
|
||||
Returns:
|
||||
A dictionary with information about the runtime environment.
|
||||
"""
|
||||
return {
|
||||
"library_version": __version__,
|
||||
"library": "langchain-core",
|
||||
"platform": platform.platform(),
|
||||
"runtime": "python",
|
||||
"runtime_version": platform.python_version(),
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Example selectors.
|
||||
|
||||
**Example selector** implements logic for selecting examples to include them in prompts.
|
||||
This allows us to select examples that are most relevant to the input.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from langchain_core._import_utils import import_attr
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langchain_core.example_selectors.base import BaseExampleSelector
|
||||
from langchain_core.example_selectors.length_based import (
|
||||
LengthBasedExampleSelector,
|
||||
)
|
||||
from langchain_core.example_selectors.semantic_similarity import (
|
||||
MaxMarginalRelevanceExampleSelector,
|
||||
SemanticSimilarityExampleSelector,
|
||||
sorted_values,
|
||||
)
|
||||
|
||||
__all__ = (
|
||||
"BaseExampleSelector",
|
||||
"LengthBasedExampleSelector",
|
||||
"MaxMarginalRelevanceExampleSelector",
|
||||
"SemanticSimilarityExampleSelector",
|
||||
"sorted_values",
|
||||
)
|
||||
|
||||
_dynamic_imports = {
|
||||
"BaseExampleSelector": "base",
|
||||
"LengthBasedExampleSelector": "length_based",
|
||||
"MaxMarginalRelevanceExampleSelector": "semantic_similarity",
|
||||
"SemanticSimilarityExampleSelector": "semantic_similarity",
|
||||
"sorted_values": "semantic_similarity",
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(attr_name: str) -> object:
|
||||
module_name = _dynamic_imports.get(attr_name)
|
||||
result = import_attr(attr_name, module_name, __spec__.parent)
|
||||
globals()[attr_name] = result
|
||||
return result
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return list(__all__)
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,58 @@
|
||||
"""Interface for selecting examples to include in prompts."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.runnables import run_in_executor
|
||||
|
||||
|
||||
class BaseExampleSelector(ABC):
|
||||
"""Interface for selecting examples to include in prompts."""
|
||||
|
||||
@abstractmethod
|
||||
def add_example(self, example: dict[str, str]) -> Any:
|
||||
"""Add new example to store.
|
||||
|
||||
Args:
|
||||
example: A dictionary with keys as input variables
|
||||
and values as their values.
|
||||
|
||||
Returns:
|
||||
Any return value.
|
||||
"""
|
||||
|
||||
async def aadd_example(self, example: dict[str, str]) -> Any:
|
||||
"""Async add new example to store.
|
||||
|
||||
Args:
|
||||
example: A dictionary with keys as input variables
|
||||
and values as their values.
|
||||
|
||||
Returns:
|
||||
Any return value.
|
||||
"""
|
||||
return await run_in_executor(None, self.add_example, example)
|
||||
|
||||
@abstractmethod
|
||||
def select_examples(self, input_variables: dict[str, str]) -> list[dict]:
|
||||
"""Select which examples to use based on the inputs.
|
||||
|
||||
Args:
|
||||
input_variables: A dictionary with keys as input variables
|
||||
and values as their values.
|
||||
|
||||
Returns:
|
||||
A list of examples.
|
||||
"""
|
||||
|
||||
async def aselect_examples(self, input_variables: dict[str, str]) -> list[dict]:
|
||||
"""Async select which examples to use based on the inputs.
|
||||
|
||||
Args:
|
||||
input_variables: A dictionary with keys as input variables
|
||||
and values as their values.
|
||||
|
||||
Returns:
|
||||
A list of examples.
|
||||
"""
|
||||
return await run_in_executor(None, self.select_examples, input_variables)
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Select examples based on length."""
|
||||
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from typing_extensions import Self
|
||||
|
||||
from langchain_core.example_selectors.base import BaseExampleSelector
|
||||
from langchain_core.prompts.prompt import PromptTemplate
|
||||
|
||||
|
||||
def _get_length_based(text: str) -> int:
|
||||
return len(re.split(r"\n| ", text))
|
||||
|
||||
|
||||
class LengthBasedExampleSelector(BaseExampleSelector, BaseModel):
|
||||
r"""Select examples based on length.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from langchain_core.example_selectors import LengthBasedExampleSelector
|
||||
from langchain_core.prompts import PromptTemplate
|
||||
|
||||
# Define examples
|
||||
examples = [
|
||||
{"input": "happy", "output": "sad"},
|
||||
{"input": "tall", "output": "short"},
|
||||
{"input": "fast", "output": "slow"},
|
||||
]
|
||||
|
||||
# Create prompt template
|
||||
example_prompt = PromptTemplate(
|
||||
input_variables=["input", "output"],
|
||||
template="Input: {input}\nOutput: {output}",
|
||||
)
|
||||
|
||||
# Create selector with max length constraint
|
||||
selector = LengthBasedExampleSelector(
|
||||
examples=examples,
|
||||
example_prompt=example_prompt,
|
||||
max_length=50, # Maximum prompt length
|
||||
)
|
||||
|
||||
# Select examples for a new input
|
||||
selected = selector.select_examples({"input": "large", "output": "tiny"})
|
||||
# Returns examples that fit within max_length constraint
|
||||
```
|
||||
"""
|
||||
|
||||
examples: list[dict]
|
||||
"""A list of the examples that the prompt template expects."""
|
||||
|
||||
example_prompt: PromptTemplate
|
||||
"""Prompt template used to format the examples."""
|
||||
|
||||
get_text_length: Callable[[str], int] = _get_length_based
|
||||
"""Function to measure prompt length. Defaults to word count."""
|
||||
|
||||
max_length: int = 2048
|
||||
"""Max length for the prompt, beyond which examples are cut."""
|
||||
|
||||
example_text_lengths: list[int] = Field(default_factory=list)
|
||||
"""Length of each example."""
|
||||
|
||||
def add_example(self, example: dict[str, str]) -> None:
|
||||
"""Add new example to list.
|
||||
|
||||
Args:
|
||||
example: A dictionary with keys as input variables
|
||||
and values as their values.
|
||||
"""
|
||||
self.examples.append(example)
|
||||
string_example = self.example_prompt.format(**example)
|
||||
self.example_text_lengths.append(self.get_text_length(string_example))
|
||||
|
||||
async def aadd_example(self, example: dict[str, str]) -> None:
|
||||
"""Async add new example to list.
|
||||
|
||||
Args:
|
||||
example: A dictionary with keys as input variables
|
||||
and values as their values.
|
||||
"""
|
||||
self.add_example(example)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def post_init(self) -> Self:
|
||||
"""Validate that the examples are formatted correctly."""
|
||||
if self.example_text_lengths:
|
||||
return self
|
||||
string_examples = [self.example_prompt.format(**eg) for eg in self.examples]
|
||||
self.example_text_lengths = [self.get_text_length(eg) for eg in string_examples]
|
||||
return self
|
||||
|
||||
def select_examples(self, input_variables: dict[str, str]) -> list[dict]:
|
||||
"""Select which examples to use based on the input lengths.
|
||||
|
||||
Args:
|
||||
input_variables: A dictionary with keys as input variables
|
||||
and values as their values.
|
||||
|
||||
Returns:
|
||||
A list of examples to include in the prompt.
|
||||
"""
|
||||
inputs = " ".join(input_variables.values())
|
||||
remaining_length = self.max_length - self.get_text_length(inputs)
|
||||
i = 0
|
||||
examples = []
|
||||
while remaining_length > 0 and i < len(self.examples):
|
||||
new_length = remaining_length - self.example_text_lengths[i]
|
||||
if new_length < 0:
|
||||
break
|
||||
examples.append(self.examples[i])
|
||||
remaining_length = new_length
|
||||
i += 1
|
||||
return examples
|
||||
|
||||
async def aselect_examples(self, input_variables: dict[str, str]) -> list[dict]:
|
||||
"""Async select which examples to use based on the input lengths.
|
||||
|
||||
Args:
|
||||
input_variables: A dictionary with keys as input variables
|
||||
and values as their values.
|
||||
|
||||
Returns:
|
||||
A list of examples to include in the prompt.
|
||||
"""
|
||||
return self.select_examples(input_variables)
|
||||
@@ -0,0 +1,358 @@
|
||||
"""Example selector that selects examples based on SemanticSimilarity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from langchain_core.example_selectors.base import BaseExampleSelector
|
||||
from langchain_core.vectorstores import VectorStore
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langchain_core.documents import Document
|
||||
from langchain_core.embeddings import Embeddings
|
||||
|
||||
|
||||
def sorted_values(values: dict[str, str]) -> list[Any]:
|
||||
"""Return a list of values in dict sorted by key.
|
||||
|
||||
Args:
|
||||
values: A dictionary with keys as input variables
|
||||
and values as their values.
|
||||
|
||||
Returns:
|
||||
A list of values in dict sorted by key.
|
||||
"""
|
||||
return [values[val] for val in sorted(values)]
|
||||
|
||||
|
||||
class _VectorStoreExampleSelector(BaseExampleSelector, BaseModel, ABC):
|
||||
"""Example selector that selects examples based on SemanticSimilarity."""
|
||||
|
||||
vectorstore: VectorStore
|
||||
"""VectorStore that contains information about examples."""
|
||||
k: int = 4
|
||||
"""Number of examples to select."""
|
||||
example_keys: list[str] | None = None
|
||||
"""Optional keys to filter examples to."""
|
||||
input_keys: list[str] | None = None
|
||||
"""Optional keys to filter input to. If provided, the search is based on
|
||||
the input variables instead of all variables."""
|
||||
vectorstore_kwargs: dict[str, Any] | None = None
|
||||
"""Extra arguments passed to similarity_search function of the `VectorStore`."""
|
||||
|
||||
model_config = ConfigDict(
|
||||
arbitrary_types_allowed=True,
|
||||
extra="forbid",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _example_to_text(example: dict[str, str], input_keys: list[str] | None) -> str:
|
||||
if input_keys:
|
||||
return " ".join(sorted_values({key: example[key] for key in input_keys}))
|
||||
return " ".join(sorted_values(example))
|
||||
|
||||
def _documents_to_examples(self, documents: list[Document]) -> list[dict]:
|
||||
# Get the examples from the metadata.
|
||||
# This assumes that examples are stored in metadata.
|
||||
examples = [dict(e.metadata) for e in documents]
|
||||
# If example keys are provided, filter examples to those keys.
|
||||
if self.example_keys:
|
||||
examples = [{k: eg[k] for k in self.example_keys} for eg in examples]
|
||||
return examples
|
||||
|
||||
def add_example(self, example: dict[str, str]) -> str:
|
||||
"""Add a new example to vectorstore.
|
||||
|
||||
Args:
|
||||
example: A dictionary with keys as input variables
|
||||
and values as their values.
|
||||
|
||||
Returns:
|
||||
The ID of the added example.
|
||||
"""
|
||||
ids = self.vectorstore.add_texts(
|
||||
[self._example_to_text(example, self.input_keys)], metadatas=[example]
|
||||
)
|
||||
return ids[0]
|
||||
|
||||
async def aadd_example(self, example: dict[str, str]) -> str:
|
||||
"""Async add new example to vectorstore.
|
||||
|
||||
Args:
|
||||
example: A dictionary with keys as input variables
|
||||
and values as their values.
|
||||
|
||||
Returns:
|
||||
The ID of the added example.
|
||||
"""
|
||||
ids = await self.vectorstore.aadd_texts(
|
||||
[self._example_to_text(example, self.input_keys)], metadatas=[example]
|
||||
)
|
||||
return ids[0]
|
||||
|
||||
|
||||
class SemanticSimilarityExampleSelector(_VectorStoreExampleSelector):
|
||||
"""Select examples based on semantic similarity."""
|
||||
|
||||
def select_examples(self, input_variables: dict[str, str]) -> list[dict]:
|
||||
"""Select examples based on semantic similarity.
|
||||
|
||||
Args:
|
||||
input_variables: The input variables to use for search.
|
||||
|
||||
Returns:
|
||||
The selected examples.
|
||||
"""
|
||||
# Get the docs with the highest similarity.
|
||||
vectorstore_kwargs = self.vectorstore_kwargs or {}
|
||||
example_docs = self.vectorstore.similarity_search(
|
||||
self._example_to_text(input_variables, self.input_keys),
|
||||
k=self.k,
|
||||
**vectorstore_kwargs,
|
||||
)
|
||||
return self._documents_to_examples(example_docs)
|
||||
|
||||
async def aselect_examples(self, input_variables: dict[str, str]) -> list[dict]:
|
||||
"""Asynchronously select examples based on semantic similarity.
|
||||
|
||||
Args:
|
||||
input_variables: The input variables to use for search.
|
||||
|
||||
Returns:
|
||||
The selected examples.
|
||||
"""
|
||||
# Get the docs with the highest similarity.
|
||||
vectorstore_kwargs = self.vectorstore_kwargs or {}
|
||||
example_docs = await self.vectorstore.asimilarity_search(
|
||||
self._example_to_text(input_variables, self.input_keys),
|
||||
k=self.k,
|
||||
**vectorstore_kwargs,
|
||||
)
|
||||
return self._documents_to_examples(example_docs)
|
||||
|
||||
@classmethod
|
||||
def from_examples(
|
||||
cls,
|
||||
examples: list[dict],
|
||||
embeddings: Embeddings,
|
||||
vectorstore_cls: type[VectorStore],
|
||||
k: int = 4,
|
||||
input_keys: list[str] | None = None,
|
||||
*,
|
||||
example_keys: list[str] | None = None,
|
||||
vectorstore_kwargs: dict | None = None,
|
||||
**vectorstore_cls_kwargs: Any,
|
||||
) -> SemanticSimilarityExampleSelector:
|
||||
"""Create k-shot example selector using example list and embeddings.
|
||||
|
||||
Reshuffles examples dynamically based on query similarity.
|
||||
|
||||
Args:
|
||||
examples: List of examples to use in the prompt.
|
||||
embeddings: An initialized embedding API interface, e.g. OpenAIEmbeddings().
|
||||
vectorstore_cls: A vector store DB interface class, e.g. FAISS.
|
||||
k: Number of examples to select.
|
||||
input_keys: If provided, the search is based on the input variables
|
||||
instead of all variables.
|
||||
example_keys: If provided, keys to filter examples to.
|
||||
vectorstore_kwargs: Extra arguments passed to similarity_search function
|
||||
of the `VectorStore`.
|
||||
vectorstore_cls_kwargs: optional kwargs containing url for vector store
|
||||
|
||||
Returns:
|
||||
The ExampleSelector instantiated, backed by a vector store.
|
||||
"""
|
||||
string_examples = [cls._example_to_text(eg, input_keys) for eg in examples]
|
||||
vectorstore = vectorstore_cls.from_texts(
|
||||
string_examples, embeddings, metadatas=examples, **vectorstore_cls_kwargs
|
||||
)
|
||||
return cls(
|
||||
vectorstore=vectorstore,
|
||||
k=k,
|
||||
input_keys=input_keys,
|
||||
example_keys=example_keys,
|
||||
vectorstore_kwargs=vectorstore_kwargs,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def afrom_examples(
|
||||
cls,
|
||||
examples: list[dict],
|
||||
embeddings: Embeddings,
|
||||
vectorstore_cls: type[VectorStore],
|
||||
k: int = 4,
|
||||
input_keys: list[str] | None = None,
|
||||
*,
|
||||
example_keys: list[str] | None = None,
|
||||
vectorstore_kwargs: dict | None = None,
|
||||
**vectorstore_cls_kwargs: Any,
|
||||
) -> SemanticSimilarityExampleSelector:
|
||||
"""Async create k-shot example selector using example list and embeddings.
|
||||
|
||||
Reshuffles examples dynamically based on query similarity.
|
||||
|
||||
Args:
|
||||
examples: List of examples to use in the prompt.
|
||||
embeddings: An initialized embedding API interface, e.g. OpenAIEmbeddings().
|
||||
vectorstore_cls: A vector store DB interface class, e.g. FAISS.
|
||||
k: Number of examples to select.
|
||||
input_keys: If provided, the search is based on the input variables
|
||||
instead of all variables.
|
||||
example_keys: If provided, keys to filter examples to.
|
||||
vectorstore_kwargs: Extra arguments passed to similarity_search function
|
||||
of the `VectorStore`.
|
||||
vectorstore_cls_kwargs: optional kwargs containing url for vector store
|
||||
|
||||
Returns:
|
||||
The ExampleSelector instantiated, backed by a vector store.
|
||||
"""
|
||||
string_examples = [cls._example_to_text(eg, input_keys) for eg in examples]
|
||||
vectorstore = await vectorstore_cls.afrom_texts(
|
||||
string_examples, embeddings, metadatas=examples, **vectorstore_cls_kwargs
|
||||
)
|
||||
return cls(
|
||||
vectorstore=vectorstore,
|
||||
k=k,
|
||||
input_keys=input_keys,
|
||||
example_keys=example_keys,
|
||||
vectorstore_kwargs=vectorstore_kwargs,
|
||||
)
|
||||
|
||||
|
||||
class MaxMarginalRelevanceExampleSelector(_VectorStoreExampleSelector):
|
||||
"""Select examples based on Max Marginal Relevance.
|
||||
|
||||
This was shown to improve performance in this paper:
|
||||
https://arxiv.org/pdf/2211.13892.pdf
|
||||
"""
|
||||
|
||||
fetch_k: int = 20
|
||||
"""Number of examples to fetch to rerank."""
|
||||
|
||||
def select_examples(self, input_variables: dict[str, str]) -> list[dict]:
|
||||
"""Select examples based on Max Marginal Relevance.
|
||||
|
||||
Args:
|
||||
input_variables: The input variables to use for search.
|
||||
|
||||
Returns:
|
||||
The selected examples.
|
||||
"""
|
||||
example_docs = self.vectorstore.max_marginal_relevance_search(
|
||||
self._example_to_text(input_variables, self.input_keys),
|
||||
k=self.k,
|
||||
fetch_k=self.fetch_k,
|
||||
)
|
||||
return self._documents_to_examples(example_docs)
|
||||
|
||||
async def aselect_examples(self, input_variables: dict[str, str]) -> list[dict]:
|
||||
"""Asynchronously select examples based on Max Marginal Relevance.
|
||||
|
||||
Args:
|
||||
input_variables: The input variables to use for search.
|
||||
|
||||
Returns:
|
||||
The selected examples.
|
||||
"""
|
||||
example_docs = await self.vectorstore.amax_marginal_relevance_search(
|
||||
self._example_to_text(input_variables, self.input_keys),
|
||||
k=self.k,
|
||||
fetch_k=self.fetch_k,
|
||||
)
|
||||
return self._documents_to_examples(example_docs)
|
||||
|
||||
@classmethod
|
||||
def from_examples(
|
||||
cls,
|
||||
examples: list[dict],
|
||||
embeddings: Embeddings,
|
||||
vectorstore_cls: type[VectorStore],
|
||||
k: int = 4,
|
||||
input_keys: list[str] | None = None,
|
||||
fetch_k: int = 20,
|
||||
example_keys: list[str] | None = None,
|
||||
vectorstore_kwargs: dict | None = None,
|
||||
**vectorstore_cls_kwargs: Any,
|
||||
) -> MaxMarginalRelevanceExampleSelector:
|
||||
"""Create k-shot example selector using example list and embeddings.
|
||||
|
||||
Reshuffles examples dynamically based on Max Marginal Relevance.
|
||||
|
||||
Args:
|
||||
examples: List of examples to use in the prompt.
|
||||
embeddings: An initialized embedding API interface, e.g. OpenAIEmbeddings().
|
||||
vectorstore_cls: A vector store DB interface class, e.g. FAISS.
|
||||
k: Number of examples to select.
|
||||
fetch_k: Number of `Document` objects to fetch to pass to MMR algorithm.
|
||||
input_keys: If provided, the search is based on the input variables
|
||||
instead of all variables.
|
||||
example_keys: If provided, keys to filter examples to.
|
||||
vectorstore_kwargs: Extra arguments passed to similarity_search function
|
||||
of the `VectorStore`.
|
||||
vectorstore_cls_kwargs: optional kwargs containing url for vector store
|
||||
|
||||
Returns:
|
||||
The ExampleSelector instantiated, backed by a vector store.
|
||||
"""
|
||||
string_examples = [cls._example_to_text(eg, input_keys) for eg in examples]
|
||||
vectorstore = vectorstore_cls.from_texts(
|
||||
string_examples, embeddings, metadatas=examples, **vectorstore_cls_kwargs
|
||||
)
|
||||
return cls(
|
||||
vectorstore=vectorstore,
|
||||
k=k,
|
||||
fetch_k=fetch_k,
|
||||
input_keys=input_keys,
|
||||
example_keys=example_keys,
|
||||
vectorstore_kwargs=vectorstore_kwargs,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def afrom_examples(
|
||||
cls,
|
||||
examples: list[dict],
|
||||
embeddings: Embeddings,
|
||||
vectorstore_cls: type[VectorStore],
|
||||
*,
|
||||
k: int = 4,
|
||||
input_keys: list[str] | None = None,
|
||||
fetch_k: int = 20,
|
||||
example_keys: list[str] | None = None,
|
||||
vectorstore_kwargs: dict | None = None,
|
||||
**vectorstore_cls_kwargs: Any,
|
||||
) -> MaxMarginalRelevanceExampleSelector:
|
||||
"""Create k-shot example selector using example list and embeddings.
|
||||
|
||||
Reshuffles examples dynamically based on Max Marginal Relevance.
|
||||
|
||||
Args:
|
||||
examples: List of examples to use in the prompt.
|
||||
embeddings: An initialized embedding API interface, e.g. OpenAIEmbeddings().
|
||||
vectorstore_cls: A vector store DB interface class, e.g. FAISS.
|
||||
k: Number of examples to select.
|
||||
fetch_k: Number of `Document` objects to fetch to pass to MMR algorithm.
|
||||
input_keys: If provided, the search is based on the input variables
|
||||
instead of all variables.
|
||||
example_keys: If provided, keys to filter examples to.
|
||||
vectorstore_kwargs: Extra arguments passed to similarity_search function
|
||||
of the `VectorStore`.
|
||||
vectorstore_cls_kwargs: optional kwargs containing url for vector store
|
||||
|
||||
Returns:
|
||||
The ExampleSelector instantiated, backed by a vector store.
|
||||
"""
|
||||
string_examples = [cls._example_to_text(eg, input_keys) for eg in examples]
|
||||
vectorstore = await vectorstore_cls.afrom_texts(
|
||||
string_examples, embeddings, metadatas=examples, **vectorstore_cls_kwargs
|
||||
)
|
||||
return cls(
|
||||
vectorstore=vectorstore,
|
||||
k=k,
|
||||
fetch_k=fetch_k,
|
||||
input_keys=input_keys,
|
||||
example_keys=example_keys,
|
||||
vectorstore_kwargs=vectorstore_kwargs,
|
||||
)
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Custom **exceptions** for LangChain."""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
|
||||
class LangChainException(Exception): # noqa: N818
|
||||
"""General LangChain exception."""
|
||||
|
||||
|
||||
class TracerException(LangChainException):
|
||||
"""Base class for exceptions in tracers module."""
|
||||
|
||||
|
||||
class OutputParserException(ValueError, LangChainException): # noqa: N818
|
||||
"""Exception that output parsers should raise to signify a parsing error.
|
||||
|
||||
This exists to differentiate parsing errors from other code or execution errors
|
||||
that also may arise inside the output parser.
|
||||
|
||||
`OutputParserException` will be available to catch and handle in ways to fix the
|
||||
parsing error, while other errors will be raised.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
error: Any,
|
||||
observation: str | None = None,
|
||||
llm_output: str | None = None,
|
||||
send_to_llm: bool = False, # noqa: FBT001,FBT002
|
||||
):
|
||||
"""Create an `OutputParserException`.
|
||||
|
||||
Args:
|
||||
error: The error that's being re-raised or an error message.
|
||||
observation: String explanation of error which can be passed to a model to
|
||||
try and remediate the issue.
|
||||
llm_output: String model output which is error-ing.
|
||||
|
||||
send_to_llm: Whether to send the observation and llm_output back to an Agent
|
||||
after an `OutputParserException` has been raised.
|
||||
|
||||
This gives the underlying model driving the agent the context that the
|
||||
previous output was improperly structured, in the hopes that it will
|
||||
update the output to the correct format.
|
||||
|
||||
Raises:
|
||||
ValueError: If `send_to_llm` is `True` but either observation or
|
||||
`llm_output` are not provided.
|
||||
"""
|
||||
if isinstance(error, str):
|
||||
error = create_message(
|
||||
message=error, error_code=ErrorCode.OUTPUT_PARSING_FAILURE
|
||||
)
|
||||
|
||||
super().__init__(error)
|
||||
if send_to_llm and (observation is None or llm_output is None):
|
||||
msg = (
|
||||
"Arguments 'observation' & 'llm_output'"
|
||||
" are required if 'send_to_llm' is True"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
self.observation = observation
|
||||
self.llm_output = llm_output
|
||||
self.send_to_llm = send_to_llm
|
||||
|
||||
|
||||
class ContextOverflowError(LangChainException):
|
||||
"""Exception raised when input exceeds the model's context limit.
|
||||
|
||||
This exception is raised by chat models when the input tokens exceed
|
||||
the maximum context window supported by the model.
|
||||
"""
|
||||
|
||||
|
||||
class ErrorCode(Enum):
|
||||
"""Error codes."""
|
||||
|
||||
INVALID_PROMPT_INPUT = "INVALID_PROMPT_INPUT"
|
||||
INVALID_TOOL_RESULTS = "INVALID_TOOL_RESULTS" # Used in JS; not Py (yet)
|
||||
MESSAGE_COERCION_FAILURE = "MESSAGE_COERCION_FAILURE"
|
||||
MODEL_AUTHENTICATION = "MODEL_AUTHENTICATION" # Used in JS; not Py (yet)
|
||||
MODEL_NOT_FOUND = "MODEL_NOT_FOUND" # Used in JS; not Py (yet)
|
||||
MODEL_RATE_LIMIT = "MODEL_RATE_LIMIT" # Used in JS; not Py (yet)
|
||||
OUTPUT_PARSING_FAILURE = "OUTPUT_PARSING_FAILURE"
|
||||
|
||||
|
||||
def create_message(*, message: str, error_code: ErrorCode) -> str:
|
||||
"""Create a message with a link to the LangChain troubleshooting guide.
|
||||
|
||||
Args:
|
||||
message: The message to display.
|
||||
error_code: The error code to display.
|
||||
|
||||
Returns:
|
||||
The full message with the troubleshooting link.
|
||||
|
||||
Example:
|
||||
```python
|
||||
create_message(
|
||||
message="Failed to parse output",
|
||||
error_code=ErrorCode.OUTPUT_PARSING_FAILURE,
|
||||
)
|
||||
"Failed to parse output. For troubleshooting, visit: ..."
|
||||
```
|
||||
"""
|
||||
return (
|
||||
f"{message}\n"
|
||||
"For troubleshooting, visit: https://docs.langchain.com/oss/python/langchain"
|
||||
f"/errors/{error_code.value} "
|
||||
)
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Global values and configuration that apply to all of LangChain."""
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langchain_core.caches import BaseCache
|
||||
|
||||
|
||||
# DO NOT USE THESE VALUES DIRECTLY!
|
||||
# Use them only via `get_<X>()` and `set_<X>()` below,
|
||||
# or else your code may behave unexpectedly with other uses of these global settings:
|
||||
# https://github.com/langchain-ai/langchain/pull/11311#issuecomment-1743780004
|
||||
_verbose: bool = False
|
||||
_debug: bool = False
|
||||
_llm_cache: Optional["BaseCache"] = None
|
||||
|
||||
|
||||
def set_verbose(value: bool) -> None: # noqa: FBT001
|
||||
"""Set a new value for the `verbose` global setting.
|
||||
|
||||
Args:
|
||||
value: The new value for the `verbose` global setting.
|
||||
"""
|
||||
global _verbose # noqa: PLW0603
|
||||
_verbose = value
|
||||
|
||||
|
||||
def get_verbose() -> bool:
|
||||
"""Get the value of the `verbose` global setting.
|
||||
|
||||
Returns:
|
||||
The value of the `verbose` global setting.
|
||||
"""
|
||||
return _verbose
|
||||
|
||||
|
||||
def set_debug(value: bool) -> None: # noqa: FBT001
|
||||
"""Set a new value for the `debug` global setting.
|
||||
|
||||
Args:
|
||||
value: The new value for the `debug` global setting.
|
||||
"""
|
||||
global _debug # noqa: PLW0603
|
||||
_debug = value
|
||||
|
||||
|
||||
def get_debug() -> bool:
|
||||
"""Get the value of the `debug` global setting.
|
||||
|
||||
Returns:
|
||||
The value of the `debug` global setting.
|
||||
"""
|
||||
return _debug
|
||||
|
||||
|
||||
def set_llm_cache(value: Optional["BaseCache"]) -> None:
|
||||
"""Set a new LLM cache, overwriting the previous value, if any.
|
||||
|
||||
Args:
|
||||
value: The new LLM cache to use. If `None`, the LLM cache is disabled.
|
||||
"""
|
||||
global _llm_cache # noqa: PLW0603
|
||||
_llm_cache = value
|
||||
|
||||
|
||||
def get_llm_cache() -> Optional["BaseCache"]:
|
||||
"""Get the value of the `llm_cache` global setting.
|
||||
|
||||
Returns:
|
||||
The value of the `llm_cache` global setting.
|
||||
"""
|
||||
return _llm_cache
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Code to help indexing data into a vectorstore.
|
||||
|
||||
This package contains helper logic to help deal with indexing data into
|
||||
a `VectorStore` while avoiding duplicated content and over-writing content
|
||||
if it's unchanged.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from langchain_core._import_utils import import_attr
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langchain_core.indexing.api import IndexingResult, aindex, index
|
||||
from langchain_core.indexing.base import (
|
||||
DeleteResponse,
|
||||
DocumentIndex,
|
||||
InMemoryRecordManager,
|
||||
RecordManager,
|
||||
UpsertResponse,
|
||||
)
|
||||
|
||||
__all__ = (
|
||||
"DeleteResponse",
|
||||
"DocumentIndex",
|
||||
"InMemoryRecordManager",
|
||||
"IndexingResult",
|
||||
"RecordManager",
|
||||
"UpsertResponse",
|
||||
"aindex",
|
||||
"index",
|
||||
)
|
||||
|
||||
_dynamic_imports = {
|
||||
"aindex": "api",
|
||||
"index": "api",
|
||||
"IndexingResult": "api",
|
||||
"DeleteResponse": "base",
|
||||
"DocumentIndex": "base",
|
||||
"InMemoryRecordManager": "base",
|
||||
"RecordManager": "base",
|
||||
"UpsertResponse": "base",
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(attr_name: str) -> object:
|
||||
module_name = _dynamic_imports.get(attr_name)
|
||||
result = import_attr(attr_name, module_name, __spec__.parent)
|
||||
globals()[attr_name] = result
|
||||
return result
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return list(__all__)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,954 @@
|
||||
"""Module contains logic for indexing documents into vector stores."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
import warnings
|
||||
from itertools import islice
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Literal,
|
||||
TypedDict,
|
||||
TypeVar,
|
||||
cast,
|
||||
)
|
||||
|
||||
from langchain_core.document_loaders.base import BaseLoader
|
||||
from langchain_core.documents import Document
|
||||
from langchain_core.exceptions import LangChainException
|
||||
from langchain_core.indexing.base import DocumentIndex, RecordManager
|
||||
from langchain_core.vectorstores import VectorStore
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import (
|
||||
AsyncIterable,
|
||||
AsyncIterator,
|
||||
Callable,
|
||||
Iterable,
|
||||
Iterator,
|
||||
Sequence,
|
||||
)
|
||||
|
||||
# Magic UUID to use as a namespace for hashing.
|
||||
# Used to try and generate a unique UUID for each document
|
||||
# from hashing the document content and metadata.
|
||||
NAMESPACE_UUID = uuid.UUID(int=1984)
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def _hash_string_to_uuid(input_string: str) -> str:
|
||||
"""Hashes a string and returns the corresponding UUID."""
|
||||
hash_value = hashlib.sha1(
|
||||
input_string.encode("utf-8"), usedforsecurity=False
|
||||
).hexdigest()
|
||||
return str(uuid.uuid5(NAMESPACE_UUID, hash_value))
|
||||
|
||||
|
||||
_WARNED_ABOUT_SHA1: bool = False
|
||||
|
||||
|
||||
def _warn_about_sha1() -> None:
|
||||
"""Emit a one-time warning about SHA-1 collision weaknesses."""
|
||||
# Global variable OK in this case
|
||||
global _WARNED_ABOUT_SHA1 # noqa: PLW0603
|
||||
if not _WARNED_ABOUT_SHA1:
|
||||
warnings.warn(
|
||||
"Using SHA-1 for document hashing. SHA-1 is *not* "
|
||||
"collision-resistant; a motivated attacker can construct distinct inputs "
|
||||
"that map to the same fingerprint. If this matters in your "
|
||||
"threat model, switch to a stronger algorithm such "
|
||||
"as 'blake2b', 'sha256', or 'sha512' by specifying "
|
||||
" `key_encoder` parameter in the `index` or `aindex` function. ",
|
||||
category=UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
_WARNED_ABOUT_SHA1 = True
|
||||
|
||||
|
||||
def _hash_string(
|
||||
input_string: str, *, algorithm: Literal["sha1", "sha256", "sha512", "blake2b"]
|
||||
) -> uuid.UUID:
|
||||
"""Hash *input_string* to a deterministic UUID using the configured algorithm."""
|
||||
if algorithm == "sha1":
|
||||
_warn_about_sha1()
|
||||
hash_value = _calculate_hash(input_string, algorithm)
|
||||
return uuid.uuid5(NAMESPACE_UUID, hash_value)
|
||||
|
||||
|
||||
def _hash_nested_dict(
|
||||
data: dict[Any, Any], *, algorithm: Literal["sha1", "sha256", "sha512", "blake2b"]
|
||||
) -> uuid.UUID:
|
||||
"""Hash a nested dictionary to a UUID using the configured algorithm."""
|
||||
serialized_data = json.dumps(data, sort_keys=True)
|
||||
return _hash_string(serialized_data, algorithm=algorithm)
|
||||
|
||||
|
||||
def _batch(size: int, iterable: Iterable[T]) -> Iterator[list[T]]:
|
||||
"""Utility batching function."""
|
||||
if size <= 0:
|
||||
msg = f"Batch size must be a positive integer, got {size}."
|
||||
raise ValueError(msg)
|
||||
it = iter(iterable)
|
||||
while True:
|
||||
chunk = list(islice(it, size))
|
||||
if not chunk:
|
||||
return
|
||||
yield chunk
|
||||
|
||||
|
||||
async def _abatch(size: int, iterable: AsyncIterable[T]) -> AsyncIterator[list[T]]:
|
||||
"""Utility batching function."""
|
||||
if size <= 0:
|
||||
msg = f"Batch size must be a positive integer, got {size}."
|
||||
raise ValueError(msg)
|
||||
batch: list[T] = []
|
||||
async for element in iterable:
|
||||
if len(batch) < size:
|
||||
batch.append(element)
|
||||
|
||||
if len(batch) >= size:
|
||||
yield batch
|
||||
batch = []
|
||||
|
||||
if batch:
|
||||
yield batch
|
||||
|
||||
|
||||
def _get_source_id_assigner(
|
||||
source_id_key: str | Callable[[Document], str] | None,
|
||||
) -> Callable[[Document], str | None]:
|
||||
"""Get the source id from the document."""
|
||||
if source_id_key is None:
|
||||
return lambda _doc: None
|
||||
if isinstance(source_id_key, str):
|
||||
return lambda doc: doc.metadata[source_id_key]
|
||||
if callable(source_id_key):
|
||||
return source_id_key
|
||||
msg = (
|
||||
f"source_id_key should be either None, a string or a callable. "
|
||||
f"Got {source_id_key} of type {type(source_id_key)}."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def _deduplicate_in_order(
|
||||
hashed_documents: Iterable[Document],
|
||||
) -> Iterator[Document]:
|
||||
"""Deduplicate a list of hashed documents while preserving order."""
|
||||
seen: set[str] = set()
|
||||
|
||||
for hashed_doc in hashed_documents:
|
||||
if hashed_doc.id not in seen:
|
||||
# At this stage, the id is guaranteed to be a string.
|
||||
# Avoiding unnecessary run time checks.
|
||||
seen.add(cast("str", hashed_doc.id))
|
||||
yield hashed_doc
|
||||
|
||||
|
||||
class IndexingException(LangChainException):
|
||||
"""Raised when an indexing operation fails."""
|
||||
|
||||
|
||||
def _calculate_hash(
|
||||
text: str, algorithm: Literal["sha1", "sha256", "sha512", "blake2b"]
|
||||
) -> str:
|
||||
"""Return a hexadecimal digest of *text* using *algorithm*."""
|
||||
if algorithm == "sha1":
|
||||
# Calculate the SHA-1 hash and return it as a UUID.
|
||||
digest = hashlib.sha1(text.encode("utf-8"), usedforsecurity=False).hexdigest()
|
||||
return str(uuid.uuid5(NAMESPACE_UUID, digest))
|
||||
if algorithm == "blake2b":
|
||||
return hashlib.blake2b(text.encode("utf-8")).hexdigest()
|
||||
if algorithm == "sha256":
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
if algorithm == "sha512":
|
||||
return hashlib.sha512(text.encode("utf-8")).hexdigest()
|
||||
msg = f"Unsupported hashing algorithm: {algorithm}"
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def _get_document_with_hash(
|
||||
document: Document,
|
||||
*,
|
||||
key_encoder: Callable[[Document], str]
|
||||
| Literal["sha1", "sha256", "sha512", "blake2b"],
|
||||
) -> Document:
|
||||
"""Calculate a hash of the document, and assign it to the uid.
|
||||
|
||||
When using one of the predefined hashing algorithms, the hash is calculated
|
||||
by hashing the content and the metadata of the document.
|
||||
|
||||
Args:
|
||||
document: Document to hash.
|
||||
key_encoder: Hashing algorithm to use for hashing the document.
|
||||
If not provided, a default encoder using SHA-1 will be used.
|
||||
SHA-1 is not collision-resistant, and a motivated attacker
|
||||
could craft two different texts that hash to the
|
||||
same cache key.
|
||||
|
||||
New applications should use one of the alternative encoders
|
||||
or provide a custom and strong key encoder function to avoid this risk.
|
||||
|
||||
When changing the key encoder, you must change the
|
||||
index as well to avoid duplicated documents in the cache.
|
||||
|
||||
Raises:
|
||||
ValueError: If the metadata cannot be serialized using json.
|
||||
|
||||
Returns:
|
||||
Document with a unique identifier based on the hash of the content and metadata.
|
||||
"""
|
||||
metadata: dict[str, Any] = dict(document.metadata or {})
|
||||
|
||||
if callable(key_encoder):
|
||||
# If key_encoder is a callable, we use it to generate the hash.
|
||||
hash_ = key_encoder(document)
|
||||
else:
|
||||
# The hashes are calculated separate for the content and the metadata.
|
||||
content_hash = _calculate_hash(document.page_content, algorithm=key_encoder)
|
||||
try:
|
||||
serialized_meta = json.dumps(metadata, sort_keys=True)
|
||||
except Exception as e:
|
||||
msg = (
|
||||
f"Failed to hash metadata: {e}. "
|
||||
f"Please use a dict that can be serialized using json."
|
||||
)
|
||||
raise ValueError(msg) from e
|
||||
metadata_hash = _calculate_hash(serialized_meta, algorithm=key_encoder)
|
||||
hash_ = _calculate_hash(content_hash + metadata_hash, algorithm=key_encoder)
|
||||
|
||||
return Document(
|
||||
# Assign a unique identifier based on the hash.
|
||||
id=hash_,
|
||||
page_content=document.page_content,
|
||||
metadata=document.metadata,
|
||||
)
|
||||
|
||||
|
||||
# This internal abstraction was imported by the langchain package internally, so
|
||||
# we keep it here for backwards compatibility.
|
||||
class _HashedDocument:
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""Raise an error if this class is instantiated."""
|
||||
msg = (
|
||||
"_HashedDocument is an internal abstraction that was deprecated in "
|
||||
" langchain-core 0.3.63. This abstraction is marked as private and "
|
||||
" should not have been used directly. If you are seeing this error, please "
|
||||
" update your code appropriately."
|
||||
)
|
||||
raise NotImplementedError(msg)
|
||||
|
||||
|
||||
def _delete(
|
||||
vector_store: VectorStore | DocumentIndex,
|
||||
ids: list[str],
|
||||
) -> None:
|
||||
"""Delete documents from a vector store or document index by their IDs.
|
||||
|
||||
Args:
|
||||
vector_store: The vector store or document index to delete from.
|
||||
ids: List of document IDs to delete.
|
||||
|
||||
Raises:
|
||||
IndexingException: If the delete operation fails.
|
||||
TypeError: If the `vector_store` is neither a `VectorStore` nor a
|
||||
`DocumentIndex`.
|
||||
"""
|
||||
if isinstance(vector_store, VectorStore):
|
||||
delete_ok = vector_store.delete(ids)
|
||||
if delete_ok is not None and delete_ok is False:
|
||||
msg = "The delete operation to VectorStore failed."
|
||||
raise IndexingException(msg)
|
||||
elif isinstance(vector_store, DocumentIndex):
|
||||
delete_response = vector_store.delete(ids)
|
||||
if "num_failed" in delete_response and delete_response["num_failed"] > 0:
|
||||
msg = "The delete operation to DocumentIndex failed."
|
||||
raise IndexingException(msg)
|
||||
else:
|
||||
msg = (
|
||||
f"Vectorstore should be either a VectorStore or a DocumentIndex. "
|
||||
f"Got {type(vector_store)}."
|
||||
)
|
||||
raise TypeError(msg)
|
||||
|
||||
|
||||
# PUBLIC API
|
||||
|
||||
|
||||
class IndexingResult(TypedDict):
|
||||
"""Return a detailed a breakdown of the result of the indexing operation."""
|
||||
|
||||
num_added: int
|
||||
"""Number of added documents."""
|
||||
num_updated: int
|
||||
"""Number of updated documents because they were not up to date."""
|
||||
num_deleted: int
|
||||
"""Number of deleted documents."""
|
||||
num_skipped: int
|
||||
"""Number of skipped documents because they were already up to date."""
|
||||
|
||||
|
||||
def index(
|
||||
docs_source: BaseLoader | Iterable[Document],
|
||||
record_manager: RecordManager,
|
||||
vector_store: VectorStore | DocumentIndex,
|
||||
*,
|
||||
batch_size: int = 100,
|
||||
cleanup: Literal["incremental", "full", "scoped_full"] | None = None,
|
||||
source_id_key: str | Callable[[Document], str] | None = None,
|
||||
cleanup_batch_size: int = 1_000,
|
||||
force_update: bool = False,
|
||||
key_encoder: Literal["sha1", "sha256", "sha512", "blake2b"]
|
||||
| Callable[[Document], str] = "sha1",
|
||||
upsert_kwargs: dict[str, Any] | None = None,
|
||||
) -> IndexingResult:
|
||||
"""Index data from the loader into the vector store.
|
||||
|
||||
Indexing functionality uses a manager to keep track of which documents
|
||||
are in the vector store.
|
||||
|
||||
This allows us to keep track of which documents were updated, and which
|
||||
documents were deleted, which documents should be skipped.
|
||||
|
||||
For the time being, documents are indexed using their hashes, and users
|
||||
are not able to specify the uid of the document.
|
||||
|
||||
!!! warning "Behavior changed in `langchain-core` 0.3.25"
|
||||
|
||||
Added `scoped_full` cleanup mode.
|
||||
|
||||
!!! warning
|
||||
|
||||
* In full mode, the loader should be returning
|
||||
the entire dataset, and not just a subset of the dataset.
|
||||
Otherwise, the auto_cleanup will remove documents that it is not
|
||||
supposed to.
|
||||
* In incremental mode, if documents associated with a particular
|
||||
source id appear across different batches, the indexing API
|
||||
will do some redundant work. This will still result in the
|
||||
correct end state of the index, but will unfortunately not be
|
||||
100% efficient. For example, if a given document is split into 15
|
||||
chunks, and we index them using a batch size of 5, we'll have 3 batches
|
||||
all with the same source id. In general, to avoid doing too much
|
||||
redundant work select as big a batch size as possible.
|
||||
* The `scoped_full` mode is suitable if determining an appropriate batch size
|
||||
is challenging or if your data loader cannot return the entire dataset at
|
||||
once. This mode keeps track of source IDs in memory, which should be fine
|
||||
for most use cases. If your dataset is large (10M+ docs), you will likely
|
||||
need to parallelize the indexing process regardless.
|
||||
|
||||
Args:
|
||||
docs_source: Data loader or iterable of documents to index.
|
||||
record_manager: Timestamped set to keep track of which documents were
|
||||
updated.
|
||||
vector_store: `VectorStore` or DocumentIndex to index the documents into.
|
||||
batch_size: Batch size to use when indexing.
|
||||
cleanup: How to handle clean up of documents.
|
||||
|
||||
- incremental: Cleans up all documents that haven't been updated AND
|
||||
that are associated with source IDs that were seen during indexing.
|
||||
Clean up is done continuously during indexing helping to minimize the
|
||||
probability of users seeing duplicated content.
|
||||
- full: Delete all documents that have not been returned by the loader
|
||||
during this run of indexing.
|
||||
Clean up runs after all documents have been indexed.
|
||||
This means that users may see duplicated content during indexing.
|
||||
- scoped_full: Similar to Full, but only deletes all documents
|
||||
that haven't been updated AND that are associated with
|
||||
source IDs that were seen during indexing.
|
||||
- None: Do not delete any documents.
|
||||
source_id_key: Optional key that helps identify the original source
|
||||
of the document.
|
||||
cleanup_batch_size: Batch size to use when cleaning up documents.
|
||||
force_update: Force update documents even if they are present in the
|
||||
record manager. Useful if you are re-indexing with updated embeddings.
|
||||
key_encoder: Hashing algorithm to use for hashing the document content and
|
||||
metadata. Options include "blake2b", "sha256", and "sha512".
|
||||
|
||||
!!! version-added "Added in `langchain-core` 0.3.66"
|
||||
|
||||
key_encoder: Hashing algorithm to use for hashing the document.
|
||||
If not provided, a default encoder using SHA-1 will be used.
|
||||
SHA-1 is not collision-resistant, and a motivated attacker
|
||||
could craft two different texts that hash to the
|
||||
same cache key.
|
||||
|
||||
New applications should use one of the alternative encoders
|
||||
or provide a custom and strong key encoder function to avoid this risk.
|
||||
|
||||
When changing the key encoder, you must change the
|
||||
index as well to avoid duplicated documents in the cache.
|
||||
upsert_kwargs: Additional keyword arguments to pass to the add_documents
|
||||
method of the `VectorStore` or the upsert method of the DocumentIndex.
|
||||
For example, you can use this to specify a custom vector_field:
|
||||
upsert_kwargs={"vector_field": "embedding"}
|
||||
!!! version-added "Added in `langchain-core` 0.3.10"
|
||||
|
||||
Returns:
|
||||
Indexing result which contains information about how many documents
|
||||
were added, updated, deleted, or skipped.
|
||||
|
||||
Raises:
|
||||
ValueError: If cleanup mode is not one of 'incremental', 'full' or None
|
||||
ValueError: If cleanup mode is incremental and source_id_key is None.
|
||||
ValueError: If `VectorStore` does not have
|
||||
"delete" and "add_documents" required methods.
|
||||
ValueError: If source_id_key is not None, but is not a string or callable.
|
||||
TypeError: If `vectorstore` is not a `VectorStore` or a DocumentIndex.
|
||||
AssertionError: If `source_id` is None when cleanup mode is incremental.
|
||||
(should be unreachable code).
|
||||
"""
|
||||
# Behavior is deprecated, but we keep it for backwards compatibility.
|
||||
# # Warn only once per process.
|
||||
if key_encoder == "sha1":
|
||||
_warn_about_sha1()
|
||||
|
||||
if cleanup not in {"incremental", "full", "scoped_full", None}:
|
||||
msg = (
|
||||
f"cleanup should be one of 'incremental', 'full', 'scoped_full' or None. "
|
||||
f"Got {cleanup}."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
if (cleanup in {"incremental", "scoped_full"}) and source_id_key is None:
|
||||
msg = (
|
||||
"Source id key is required when cleanup mode is incremental or scoped_full."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
destination = vector_store # Renaming internally for clarity
|
||||
|
||||
# If it's a vectorstore, let's check if it has the required methods.
|
||||
if isinstance(destination, VectorStore):
|
||||
# Check that the Vectorstore has required methods implemented
|
||||
methods = ["delete", "add_documents"]
|
||||
|
||||
for method in methods:
|
||||
if not hasattr(destination, method):
|
||||
msg = (
|
||||
f"Vectorstore {destination} does not have required method {method}"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
if type(destination).delete == VectorStore.delete:
|
||||
# Checking if the VectorStore has overridden the default delete method
|
||||
# implementation which just raises a NotImplementedError
|
||||
msg = "Vectorstore has not implemented the delete method"
|
||||
raise ValueError(msg)
|
||||
elif isinstance(destination, DocumentIndex):
|
||||
pass
|
||||
else:
|
||||
msg = (
|
||||
f"Vectorstore should be either a VectorStore or a DocumentIndex. "
|
||||
f"Got {type(destination)}."
|
||||
)
|
||||
raise TypeError(msg)
|
||||
|
||||
if isinstance(docs_source, BaseLoader):
|
||||
try:
|
||||
doc_iterator = docs_source.lazy_load()
|
||||
except NotImplementedError:
|
||||
doc_iterator = iter(docs_source.load())
|
||||
else:
|
||||
doc_iterator = iter(docs_source)
|
||||
|
||||
source_id_assigner = _get_source_id_assigner(source_id_key)
|
||||
|
||||
# Mark when the update started.
|
||||
index_start_dt = record_manager.get_time()
|
||||
num_added = 0
|
||||
num_skipped = 0
|
||||
num_updated = 0
|
||||
num_deleted = 0
|
||||
scoped_full_cleanup_source_ids: set[str] = set()
|
||||
|
||||
for doc_batch in _batch(batch_size, doc_iterator):
|
||||
# Track original batch size before deduplication
|
||||
original_batch_size = len(doc_batch)
|
||||
|
||||
hashed_docs = list(
|
||||
_deduplicate_in_order(
|
||||
[
|
||||
_get_document_with_hash(doc, key_encoder=key_encoder)
|
||||
for doc in doc_batch
|
||||
]
|
||||
)
|
||||
)
|
||||
# Count documents removed by within-batch deduplication
|
||||
num_skipped += original_batch_size - len(hashed_docs)
|
||||
|
||||
source_ids: Sequence[str | None] = [
|
||||
source_id_assigner(hashed_doc) for hashed_doc in hashed_docs
|
||||
]
|
||||
|
||||
if cleanup in {"incremental", "scoped_full"}:
|
||||
# Source IDs are required.
|
||||
for source_id, hashed_doc in zip(source_ids, hashed_docs, strict=False):
|
||||
if source_id is None:
|
||||
msg = (
|
||||
f"Source IDs are required when cleanup mode is "
|
||||
f"incremental or scoped_full. "
|
||||
f"Document that starts with "
|
||||
f"content: {hashed_doc.page_content[:100]} "
|
||||
f"was not assigned as source id."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
if cleanup == "scoped_full":
|
||||
scoped_full_cleanup_source_ids.add(source_id)
|
||||
# Source IDs cannot be None after for loop above.
|
||||
source_ids = cast("Sequence[str]", source_ids)
|
||||
|
||||
exists_batch = record_manager.exists(
|
||||
cast("Sequence[str]", [doc.id for doc in hashed_docs])
|
||||
)
|
||||
|
||||
# Filter out documents that already exist in the record store.
|
||||
uids = []
|
||||
docs_to_index = []
|
||||
uids_to_refresh = []
|
||||
seen_docs: set[str] = set()
|
||||
for hashed_doc, doc_exists in zip(hashed_docs, exists_batch, strict=False):
|
||||
hashed_id = cast("str", hashed_doc.id)
|
||||
if doc_exists:
|
||||
if force_update:
|
||||
seen_docs.add(hashed_id)
|
||||
else:
|
||||
uids_to_refresh.append(hashed_id)
|
||||
continue
|
||||
uids.append(hashed_id)
|
||||
docs_to_index.append(hashed_doc)
|
||||
|
||||
# Update refresh timestamp
|
||||
if uids_to_refresh:
|
||||
record_manager.update(uids_to_refresh, time_at_least=index_start_dt)
|
||||
num_skipped += len(uids_to_refresh)
|
||||
|
||||
# Be pessimistic and assume that all vector store write will fail.
|
||||
# First write to vector store
|
||||
if docs_to_index:
|
||||
if isinstance(destination, VectorStore):
|
||||
destination.add_documents(
|
||||
docs_to_index,
|
||||
ids=uids,
|
||||
batch_size=batch_size,
|
||||
**(upsert_kwargs or {}),
|
||||
)
|
||||
elif isinstance(destination, DocumentIndex):
|
||||
destination.upsert(
|
||||
docs_to_index,
|
||||
**(upsert_kwargs or {}),
|
||||
)
|
||||
|
||||
num_added += len(docs_to_index) - len(seen_docs)
|
||||
num_updated += len(seen_docs)
|
||||
|
||||
# And only then update the record store.
|
||||
# Update ALL records, even if they already exist since we want to refresh
|
||||
# their timestamp.
|
||||
record_manager.update(
|
||||
cast("Sequence[str]", [doc.id for doc in hashed_docs]),
|
||||
group_ids=source_ids,
|
||||
time_at_least=index_start_dt,
|
||||
)
|
||||
|
||||
# If source IDs are provided, we can do the deletion incrementally!
|
||||
if cleanup == "incremental":
|
||||
# Get the uids of the documents that were not returned by the loader.
|
||||
# mypy isn't good enough to determine that source IDs cannot be None
|
||||
# here due to a check that's happening above, so we check again.
|
||||
for source_id in source_ids:
|
||||
if source_id is None:
|
||||
msg = (
|
||||
"source_id cannot be None at this point. "
|
||||
"Reached unreachable code."
|
||||
)
|
||||
raise AssertionError(msg)
|
||||
|
||||
source_ids_ = cast("Sequence[str]", source_ids)
|
||||
|
||||
while uids_to_delete := record_manager.list_keys(
|
||||
group_ids=source_ids_, before=index_start_dt, limit=cleanup_batch_size
|
||||
):
|
||||
# Then delete from vector store.
|
||||
_delete(destination, uids_to_delete)
|
||||
# First delete from record store.
|
||||
record_manager.delete_keys(uids_to_delete)
|
||||
num_deleted += len(uids_to_delete)
|
||||
|
||||
if cleanup == "full" or (
|
||||
cleanup == "scoped_full" and scoped_full_cleanup_source_ids
|
||||
):
|
||||
delete_group_ids: Sequence[str] | None = None
|
||||
if cleanup == "scoped_full":
|
||||
delete_group_ids = list(scoped_full_cleanup_source_ids)
|
||||
while uids_to_delete := record_manager.list_keys(
|
||||
group_ids=delete_group_ids, before=index_start_dt, limit=cleanup_batch_size
|
||||
):
|
||||
# First delete from record store.
|
||||
_delete(destination, uids_to_delete)
|
||||
# Then delete from record manager.
|
||||
record_manager.delete_keys(uids_to_delete)
|
||||
num_deleted += len(uids_to_delete)
|
||||
|
||||
return {
|
||||
"num_added": num_added,
|
||||
"num_updated": num_updated,
|
||||
"num_skipped": num_skipped,
|
||||
"num_deleted": num_deleted,
|
||||
}
|
||||
|
||||
|
||||
# Define an asynchronous generator function
|
||||
async def _to_async_iterator(iterator: Iterable[T]) -> AsyncIterator[T]:
|
||||
"""Convert an iterable to an async iterator."""
|
||||
for item in iterator:
|
||||
yield item
|
||||
|
||||
|
||||
async def _adelete(
|
||||
vector_store: VectorStore | DocumentIndex,
|
||||
ids: list[str],
|
||||
) -> None:
|
||||
if isinstance(vector_store, VectorStore):
|
||||
delete_ok = await vector_store.adelete(ids)
|
||||
if delete_ok is not None and delete_ok is False:
|
||||
msg = "The delete operation to VectorStore failed."
|
||||
raise IndexingException(msg)
|
||||
elif isinstance(vector_store, DocumentIndex):
|
||||
delete_response = await vector_store.adelete(ids)
|
||||
if "num_failed" in delete_response and delete_response["num_failed"] > 0:
|
||||
msg = "The delete operation to DocumentIndex failed."
|
||||
raise IndexingException(msg)
|
||||
else:
|
||||
msg = (
|
||||
f"Vectorstore should be either a VectorStore or a DocumentIndex. "
|
||||
f"Got {type(vector_store)}."
|
||||
)
|
||||
raise TypeError(msg)
|
||||
|
||||
|
||||
async def aindex(
|
||||
docs_source: BaseLoader | Iterable[Document] | AsyncIterator[Document],
|
||||
record_manager: RecordManager,
|
||||
vector_store: VectorStore | DocumentIndex,
|
||||
*,
|
||||
batch_size: int = 100,
|
||||
cleanup: Literal["incremental", "full", "scoped_full"] | None = None,
|
||||
source_id_key: str | Callable[[Document], str] | None = None,
|
||||
cleanup_batch_size: int = 1_000,
|
||||
force_update: bool = False,
|
||||
key_encoder: Literal["sha1", "sha256", "sha512", "blake2b"]
|
||||
| Callable[[Document], str] = "sha1",
|
||||
upsert_kwargs: dict[str, Any] | None = None,
|
||||
) -> IndexingResult:
|
||||
"""Async index data from the loader into the vector store.
|
||||
|
||||
Indexing functionality uses a manager to keep track of which documents
|
||||
are in the vector store.
|
||||
|
||||
This allows us to keep track of which documents were updated, and which
|
||||
documents were deleted, which documents should be skipped.
|
||||
|
||||
For the time being, documents are indexed using their hashes, and users
|
||||
are not able to specify the uid of the document.
|
||||
|
||||
!!! warning "Behavior changed in `langchain-core` 0.3.25"
|
||||
|
||||
Added `scoped_full` cleanup mode.
|
||||
|
||||
!!! warning
|
||||
|
||||
* In full mode, the loader should be returning
|
||||
the entire dataset, and not just a subset of the dataset.
|
||||
Otherwise, the auto_cleanup will remove documents that it is not
|
||||
supposed to.
|
||||
* In incremental mode, if documents associated with a particular
|
||||
source id appear across different batches, the indexing API
|
||||
will do some redundant work. This will still result in the
|
||||
correct end state of the index, but will unfortunately not be
|
||||
100% efficient. For example, if a given document is split into 15
|
||||
chunks, and we index them using a batch size of 5, we'll have 3 batches
|
||||
all with the same source id. In general, to avoid doing too much
|
||||
redundant work select as big a batch size as possible.
|
||||
* The `scoped_full` mode is suitable if determining an appropriate batch size
|
||||
is challenging or if your data loader cannot return the entire dataset at
|
||||
once. This mode keeps track of source IDs in memory, which should be fine
|
||||
for most use cases. If your dataset is large (10M+ docs), you will likely
|
||||
need to parallelize the indexing process regardless.
|
||||
|
||||
Args:
|
||||
docs_source: Data loader or iterable of documents to index.
|
||||
record_manager: Timestamped set to keep track of which documents were
|
||||
updated.
|
||||
vector_store: `VectorStore` or DocumentIndex to index the documents into.
|
||||
batch_size: Batch size to use when indexing.
|
||||
cleanup: How to handle clean up of documents.
|
||||
|
||||
- incremental: Cleans up all documents that haven't been updated AND
|
||||
that are associated with source IDs that were seen during indexing.
|
||||
Clean up is done continuously during indexing helping to minimize the
|
||||
probability of users seeing duplicated content.
|
||||
- full: Delete all documents that have not been returned by the loader
|
||||
during this run of indexing.
|
||||
Clean up runs after all documents have been indexed.
|
||||
This means that users may see duplicated content during indexing.
|
||||
- scoped_full: Similar to Full, but only deletes all documents
|
||||
that haven't been updated AND that are associated with
|
||||
source IDs that were seen during indexing.
|
||||
- None: Do not delete any documents.
|
||||
source_id_key: Optional key that helps identify the original source
|
||||
of the document.
|
||||
cleanup_batch_size: Batch size to use when cleaning up documents.
|
||||
force_update: Force update documents even if they are present in the
|
||||
record manager. Useful if you are re-indexing with updated embeddings.
|
||||
key_encoder: Hashing algorithm to use for hashing the document content and
|
||||
metadata. Options include "blake2b", "sha256", and "sha512".
|
||||
|
||||
!!! version-added "Added in `langchain-core` 0.3.66"
|
||||
|
||||
key_encoder: Hashing algorithm to use for hashing the document.
|
||||
If not provided, a default encoder using SHA-1 will be used.
|
||||
SHA-1 is not collision-resistant, and a motivated attacker
|
||||
could craft two different texts that hash to the
|
||||
same cache key.
|
||||
|
||||
New applications should use one of the alternative encoders
|
||||
or provide a custom and strong key encoder function to avoid this risk.
|
||||
|
||||
When changing the key encoder, you must change the
|
||||
index as well to avoid duplicated documents in the cache.
|
||||
upsert_kwargs: Additional keyword arguments to pass to the add_documents
|
||||
method of the `VectorStore` or the upsert method of the DocumentIndex.
|
||||
For example, you can use this to specify a custom vector_field:
|
||||
upsert_kwargs={"vector_field": "embedding"}
|
||||
!!! version-added "Added in `langchain-core` 0.3.10"
|
||||
|
||||
Returns:
|
||||
Indexing result which contains information about how many documents
|
||||
were added, updated, deleted, or skipped.
|
||||
|
||||
Raises:
|
||||
ValueError: If cleanup mode is not one of 'incremental', 'full' or None
|
||||
ValueError: If cleanup mode is incremental and source_id_key is None.
|
||||
ValueError: If `VectorStore` does not have
|
||||
"adelete" and "aadd_documents" required methods.
|
||||
ValueError: If source_id_key is not None, but is not a string or callable.
|
||||
TypeError: If `vector_store` is not a `VectorStore` or DocumentIndex.
|
||||
AssertionError: If `source_id_key` is None when cleanup mode is
|
||||
incremental or `scoped_full` (should be unreachable).
|
||||
"""
|
||||
# Behavior is deprecated, but we keep it for backwards compatibility.
|
||||
# # Warn only once per process.
|
||||
if key_encoder == "sha1":
|
||||
_warn_about_sha1()
|
||||
|
||||
if cleanup not in {"incremental", "full", "scoped_full", None}:
|
||||
msg = (
|
||||
f"cleanup should be one of 'incremental', 'full', 'scoped_full' or None. "
|
||||
f"Got {cleanup}."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
if (cleanup in {"incremental", "scoped_full"}) and source_id_key is None:
|
||||
msg = (
|
||||
"Source id key is required when cleanup mode is incremental or scoped_full."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
destination = vector_store # Renaming internally for clarity
|
||||
|
||||
# If it's a vectorstore, let's check if it has the required methods.
|
||||
if isinstance(destination, VectorStore):
|
||||
# Check that the Vectorstore has required methods implemented
|
||||
# Check that the Vectorstore has required methods implemented
|
||||
methods = ["adelete", "aadd_documents"]
|
||||
|
||||
for method in methods:
|
||||
if not hasattr(destination, method):
|
||||
msg = (
|
||||
f"Vectorstore {destination} does not have required method {method}"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
if (
|
||||
type(destination).adelete == VectorStore.adelete
|
||||
and type(destination).delete == VectorStore.delete
|
||||
):
|
||||
# Checking if the VectorStore has overridden the default adelete or delete
|
||||
# methods implementation which just raises a NotImplementedError
|
||||
msg = "Vectorstore has not implemented the adelete or delete method"
|
||||
raise ValueError(msg)
|
||||
elif isinstance(destination, DocumentIndex):
|
||||
pass
|
||||
else:
|
||||
msg = (
|
||||
f"Vectorstore should be either a VectorStore or a DocumentIndex. "
|
||||
f"Got {type(destination)}."
|
||||
)
|
||||
raise TypeError(msg)
|
||||
async_doc_iterator: AsyncIterator[Document]
|
||||
if isinstance(docs_source, BaseLoader):
|
||||
try:
|
||||
async_doc_iterator = docs_source.alazy_load()
|
||||
except NotImplementedError:
|
||||
# Exception triggered when neither lazy_load nor alazy_load are implemented.
|
||||
# * The default implementation of alazy_load uses lazy_load.
|
||||
# * The default implementation of lazy_load raises NotImplementedError.
|
||||
# In such a case, we use the load method and convert it to an async
|
||||
# iterator.
|
||||
async_doc_iterator = _to_async_iterator(docs_source.load())
|
||||
elif hasattr(docs_source, "__aiter__"):
|
||||
async_doc_iterator = docs_source # type: ignore[assignment]
|
||||
else:
|
||||
async_doc_iterator = _to_async_iterator(docs_source)
|
||||
|
||||
source_id_assigner = _get_source_id_assigner(source_id_key)
|
||||
|
||||
# Mark when the update started.
|
||||
index_start_dt = await record_manager.aget_time()
|
||||
num_added = 0
|
||||
num_skipped = 0
|
||||
num_updated = 0
|
||||
num_deleted = 0
|
||||
scoped_full_cleanup_source_ids: set[str] = set()
|
||||
|
||||
async for doc_batch in _abatch(batch_size, async_doc_iterator):
|
||||
# Track original batch size before deduplication
|
||||
original_batch_size = len(doc_batch)
|
||||
|
||||
hashed_docs = list(
|
||||
_deduplicate_in_order(
|
||||
[
|
||||
_get_document_with_hash(doc, key_encoder=key_encoder)
|
||||
for doc in doc_batch
|
||||
]
|
||||
)
|
||||
)
|
||||
# Count documents removed by within-batch deduplication
|
||||
num_skipped += original_batch_size - len(hashed_docs)
|
||||
|
||||
source_ids: Sequence[str | None] = [
|
||||
source_id_assigner(doc) for doc in hashed_docs
|
||||
]
|
||||
|
||||
if cleanup in {"incremental", "scoped_full"}:
|
||||
# If the cleanup mode is incremental, source IDs are required.
|
||||
for source_id, hashed_doc in zip(source_ids, hashed_docs, strict=False):
|
||||
if source_id is None:
|
||||
msg = (
|
||||
f"Source IDs are required when cleanup mode is "
|
||||
f"incremental or scoped_full. "
|
||||
f"Document that starts with "
|
||||
f"content: {hashed_doc.page_content[:100]} "
|
||||
f"was not assigned as source id."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
if cleanup == "scoped_full":
|
||||
scoped_full_cleanup_source_ids.add(source_id)
|
||||
# Source IDs cannot be None after for loop above.
|
||||
source_ids = cast("Sequence[str]", source_ids)
|
||||
|
||||
exists_batch = await record_manager.aexists(
|
||||
cast("Sequence[str]", [doc.id for doc in hashed_docs])
|
||||
)
|
||||
|
||||
# Filter out documents that already exist in the record store.
|
||||
uids: list[str] = []
|
||||
docs_to_index: list[Document] = []
|
||||
uids_to_refresh = []
|
||||
seen_docs: set[str] = set()
|
||||
for hashed_doc, doc_exists in zip(hashed_docs, exists_batch, strict=False):
|
||||
hashed_id = cast("str", hashed_doc.id)
|
||||
if doc_exists:
|
||||
if force_update:
|
||||
seen_docs.add(hashed_id)
|
||||
else:
|
||||
uids_to_refresh.append(hashed_id)
|
||||
continue
|
||||
uids.append(hashed_id)
|
||||
docs_to_index.append(hashed_doc)
|
||||
|
||||
if uids_to_refresh:
|
||||
# Must be updated to refresh timestamp.
|
||||
await record_manager.aupdate(uids_to_refresh, time_at_least=index_start_dt)
|
||||
num_skipped += len(uids_to_refresh)
|
||||
|
||||
# Be pessimistic and assume that all vector store write will fail.
|
||||
# First write to vector store
|
||||
if docs_to_index:
|
||||
if isinstance(destination, VectorStore):
|
||||
await destination.aadd_documents(
|
||||
docs_to_index,
|
||||
ids=uids,
|
||||
batch_size=batch_size,
|
||||
**(upsert_kwargs or {}),
|
||||
)
|
||||
elif isinstance(destination, DocumentIndex):
|
||||
await destination.aupsert(
|
||||
docs_to_index,
|
||||
**(upsert_kwargs or {}),
|
||||
)
|
||||
num_added += len(docs_to_index) - len(seen_docs)
|
||||
num_updated += len(seen_docs)
|
||||
|
||||
# And only then update the record store.
|
||||
# Update ALL records, even if they already exist since we want to refresh
|
||||
# their timestamp.
|
||||
await record_manager.aupdate(
|
||||
cast("Sequence[str]", [doc.id for doc in hashed_docs]),
|
||||
group_ids=source_ids,
|
||||
time_at_least=index_start_dt,
|
||||
)
|
||||
|
||||
# If source IDs are provided, we can do the deletion incrementally!
|
||||
|
||||
if cleanup == "incremental":
|
||||
# Get the uids of the documents that were not returned by the loader.
|
||||
|
||||
# mypy isn't good enough to determine that source IDs cannot be None
|
||||
# here due to a check that's happening above, so we check again.
|
||||
for source_id in source_ids:
|
||||
if source_id is None:
|
||||
msg = (
|
||||
"source_id cannot be None at this point. "
|
||||
"Reached unreachable code."
|
||||
)
|
||||
raise AssertionError(msg)
|
||||
|
||||
source_ids_ = cast("Sequence[str]", source_ids)
|
||||
|
||||
while uids_to_delete := await record_manager.alist_keys(
|
||||
group_ids=source_ids_, before=index_start_dt, limit=cleanup_batch_size
|
||||
):
|
||||
# Then delete from vector store.
|
||||
await _adelete(destination, uids_to_delete)
|
||||
# First delete from record store.
|
||||
await record_manager.adelete_keys(uids_to_delete)
|
||||
num_deleted += len(uids_to_delete)
|
||||
|
||||
if cleanup == "full" or (
|
||||
cleanup == "scoped_full" and scoped_full_cleanup_source_ids
|
||||
):
|
||||
delete_group_ids: Sequence[str] | None = None
|
||||
if cleanup == "scoped_full":
|
||||
delete_group_ids = list(scoped_full_cleanup_source_ids)
|
||||
while uids_to_delete := await record_manager.alist_keys(
|
||||
group_ids=delete_group_ids, before=index_start_dt, limit=cleanup_batch_size
|
||||
):
|
||||
# First delete from record store.
|
||||
await _adelete(destination, uids_to_delete)
|
||||
# Then delete from record manager.
|
||||
await record_manager.adelete_keys(uids_to_delete)
|
||||
num_deleted += len(uids_to_delete)
|
||||
|
||||
return {
|
||||
"num_added": num_added,
|
||||
"num_updated": num_updated,
|
||||
"num_skipped": num_skipped,
|
||||
"num_deleted": num_deleted,
|
||||
}
|
||||
@@ -0,0 +1,661 @@
|
||||
"""Base classes for indexing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING, Any, TypedDict
|
||||
|
||||
from typing_extensions import override
|
||||
|
||||
from langchain_core._api import beta
|
||||
from langchain_core.retrievers import BaseRetriever
|
||||
from langchain_core.runnables import run_in_executor
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from langchain_core.documents import Document
|
||||
|
||||
|
||||
class RecordManager(ABC):
|
||||
"""Abstract base class representing the interface for a record manager.
|
||||
|
||||
The record manager abstraction is used by the langchain indexing API.
|
||||
|
||||
The record manager keeps track of which documents have been
|
||||
written into a `VectorStore` and when they were written.
|
||||
|
||||
The indexing API computes hashes for each document and stores the hash
|
||||
together with the write time and the source id in the record manager.
|
||||
|
||||
On subsequent indexing runs, the indexing API can check the record manager
|
||||
to determine which documents have already been indexed and which have not.
|
||||
|
||||
This allows the indexing API to avoid re-indexing documents that have
|
||||
already been indexed, and to only index new documents.
|
||||
|
||||
The main benefit of this abstraction is that it works across many vectorstores.
|
||||
To be supported, a `VectorStore` needs to only support the ability to add and
|
||||
delete documents by ID. Using the record manager, the indexing API will
|
||||
be able to delete outdated documents and avoid redundant indexing of documents
|
||||
that have already been indexed.
|
||||
|
||||
The main constraints of this abstraction are:
|
||||
|
||||
1. It relies on the time-stamps to determine which documents have been
|
||||
indexed and which have not. This means that the time-stamps must be
|
||||
monotonically increasing. The timestamp should be the timestamp
|
||||
as measured by the server to minimize issues.
|
||||
2. The record manager is currently implemented separately from the
|
||||
vectorstore, which means that the overall system becomes distributed
|
||||
and may create issues with consistency. For example, writing to
|
||||
record manager succeeds, but corresponding writing to `VectorStore` fails.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
namespace: str,
|
||||
) -> None:
|
||||
"""Initialize the record manager.
|
||||
|
||||
Args:
|
||||
namespace: The namespace for the record manager.
|
||||
"""
|
||||
self.namespace = namespace
|
||||
|
||||
@abstractmethod
|
||||
def create_schema(self) -> None:
|
||||
"""Create the database schema for the record manager."""
|
||||
|
||||
@abstractmethod
|
||||
async def acreate_schema(self) -> None:
|
||||
"""Asynchronously create the database schema for the record manager."""
|
||||
|
||||
@abstractmethod
|
||||
def get_time(self) -> float:
|
||||
"""Get the current server time as a high resolution timestamp!
|
||||
|
||||
It's important to get this from the server to ensure a monotonic clock,
|
||||
otherwise there may be data loss when cleaning up old documents!
|
||||
|
||||
Returns:
|
||||
The current server time as a float timestamp.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def aget_time(self) -> float:
|
||||
"""Asynchronously get the current server time as a high resolution timestamp.
|
||||
|
||||
It's important to get this from the server to ensure a monotonic clock,
|
||||
otherwise there may be data loss when cleaning up old documents!
|
||||
|
||||
Returns:
|
||||
The current server time as a float timestamp.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def update(
|
||||
self,
|
||||
keys: Sequence[str],
|
||||
*,
|
||||
group_ids: Sequence[str | None] | None = None,
|
||||
time_at_least: float | None = None,
|
||||
) -> None:
|
||||
"""Upsert records into the database.
|
||||
|
||||
Args:
|
||||
keys: A list of record keys to upsert.
|
||||
group_ids: A list of group IDs corresponding to the keys.
|
||||
time_at_least: Optional timestamp. Implementation can use this
|
||||
to optionally verify that the timestamp IS at least this time
|
||||
in the system that stores the data.
|
||||
|
||||
e.g., use to validate that the time in the postgres database
|
||||
is equal to or larger than the given timestamp, if not
|
||||
raise an error.
|
||||
|
||||
This is meant to help prevent time-drift issues since
|
||||
time may not be monotonically increasing!
|
||||
|
||||
Raises:
|
||||
ValueError: If the length of keys doesn't match the length of group_ids.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def aupdate(
|
||||
self,
|
||||
keys: Sequence[str],
|
||||
*,
|
||||
group_ids: Sequence[str | None] | None = None,
|
||||
time_at_least: float | None = None,
|
||||
) -> None:
|
||||
"""Asynchronously upsert records into the database.
|
||||
|
||||
Args:
|
||||
keys: A list of record keys to upsert.
|
||||
group_ids: A list of group IDs corresponding to the keys.
|
||||
time_at_least: Optional timestamp. Implementation can use this
|
||||
to optionally verify that the timestamp IS at least this time
|
||||
in the system that stores the data.
|
||||
|
||||
e.g., use to validate that the time in the postgres database
|
||||
is equal to or larger than the given timestamp, if not
|
||||
raise an error.
|
||||
|
||||
This is meant to help prevent time-drift issues since
|
||||
time may not be monotonically increasing!
|
||||
|
||||
Raises:
|
||||
ValueError: If the length of keys doesn't match the length of group_ids.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def exists(self, keys: Sequence[str]) -> list[bool]:
|
||||
"""Check if the provided keys exist in the database.
|
||||
|
||||
Args:
|
||||
keys: A list of keys to check.
|
||||
|
||||
Returns:
|
||||
A list of boolean values indicating the existence of each key.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def aexists(self, keys: Sequence[str]) -> list[bool]:
|
||||
"""Asynchronously check if the provided keys exist in the database.
|
||||
|
||||
Args:
|
||||
keys: A list of keys to check.
|
||||
|
||||
Returns:
|
||||
A list of boolean values indicating the existence of each key.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def list_keys(
|
||||
self,
|
||||
*,
|
||||
before: float | None = None,
|
||||
after: float | None = None,
|
||||
group_ids: Sequence[str] | None = None,
|
||||
limit: int | None = None,
|
||||
) -> list[str]:
|
||||
"""List records in the database based on the provided filters.
|
||||
|
||||
Args:
|
||||
before: Filter to list records updated before this time.
|
||||
after: Filter to list records updated after this time.
|
||||
group_ids: Filter to list records with specific group IDs.
|
||||
limit: optional limit on the number of records to return.
|
||||
|
||||
Returns:
|
||||
A list of keys for the matching records.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def alist_keys(
|
||||
self,
|
||||
*,
|
||||
before: float | None = None,
|
||||
after: float | None = None,
|
||||
group_ids: Sequence[str] | None = None,
|
||||
limit: int | None = None,
|
||||
) -> list[str]:
|
||||
"""Asynchronously list records in the database based on the provided filters.
|
||||
|
||||
Args:
|
||||
before: Filter to list records updated before this time.
|
||||
after: Filter to list records updated after this time.
|
||||
group_ids: Filter to list records with specific group IDs.
|
||||
limit: optional limit on the number of records to return.
|
||||
|
||||
Returns:
|
||||
A list of keys for the matching records.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def delete_keys(self, keys: Sequence[str]) -> None:
|
||||
"""Delete specified records from the database.
|
||||
|
||||
Args:
|
||||
keys: A list of keys to delete.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def adelete_keys(self, keys: Sequence[str]) -> None:
|
||||
"""Asynchronously delete specified records from the database.
|
||||
|
||||
Args:
|
||||
keys: A list of keys to delete.
|
||||
"""
|
||||
|
||||
|
||||
class _Record(TypedDict):
|
||||
group_id: str | None
|
||||
updated_at: float
|
||||
|
||||
|
||||
class InMemoryRecordManager(RecordManager):
|
||||
"""An in-memory record manager for testing purposes."""
|
||||
|
||||
def __init__(self, namespace: str) -> None:
|
||||
"""Initialize the in-memory record manager.
|
||||
|
||||
Args:
|
||||
namespace: The namespace for the record manager.
|
||||
"""
|
||||
super().__init__(namespace)
|
||||
# Each key points to a dictionary
|
||||
# of {'group_id': group_id, 'updated_at': timestamp}
|
||||
self.records: dict[str, _Record] = {}
|
||||
self.namespace = namespace
|
||||
|
||||
def create_schema(self) -> None:
|
||||
"""In-memory schema creation is simply ensuring the structure is initialized."""
|
||||
|
||||
async def acreate_schema(self) -> None:
|
||||
"""In-memory schema creation is simply ensuring the structure is initialized."""
|
||||
|
||||
@override
|
||||
def get_time(self) -> float:
|
||||
return time.time()
|
||||
|
||||
@override
|
||||
async def aget_time(self) -> float:
|
||||
return self.get_time()
|
||||
|
||||
def update(
|
||||
self,
|
||||
keys: Sequence[str],
|
||||
*,
|
||||
group_ids: Sequence[str | None] | None = None,
|
||||
time_at_least: float | None = None,
|
||||
) -> None:
|
||||
"""Upsert records into the database.
|
||||
|
||||
Args:
|
||||
keys: A list of record keys to upsert.
|
||||
group_ids: A list of group IDs corresponding to the keys.
|
||||
|
||||
time_at_least: Optional timestamp. Implementation can use this
|
||||
to optionally verify that the timestamp IS at least this time
|
||||
in the system that stores.
|
||||
E.g., use to validate that the time in the postgres database
|
||||
is equal to or larger than the given timestamp, if not
|
||||
raise an error.
|
||||
This is meant to help prevent time-drift issues since
|
||||
time may not be monotonically increasing!
|
||||
|
||||
Raises:
|
||||
ValueError: If the length of keys doesn't match the length of group
|
||||
ids.
|
||||
ValueError: If time_at_least is in the future.
|
||||
"""
|
||||
if group_ids and len(keys) != len(group_ids):
|
||||
msg = "Length of keys must match length of group_ids"
|
||||
raise ValueError(msg)
|
||||
for index, key in enumerate(keys):
|
||||
group_id = group_ids[index] if group_ids else None
|
||||
if time_at_least and time_at_least > self.get_time():
|
||||
msg = "time_at_least must be in the past"
|
||||
raise ValueError(msg)
|
||||
self.records[key] = {"group_id": group_id, "updated_at": self.get_time()}
|
||||
|
||||
async def aupdate(
|
||||
self,
|
||||
keys: Sequence[str],
|
||||
*,
|
||||
group_ids: Sequence[str | None] | None = None,
|
||||
time_at_least: float | None = None,
|
||||
) -> None:
|
||||
"""Async upsert records into the database.
|
||||
|
||||
Args:
|
||||
keys: A list of record keys to upsert.
|
||||
group_ids: A list of group IDs corresponding to the keys.
|
||||
|
||||
time_at_least: Optional timestamp. Implementation can use this
|
||||
to optionally verify that the timestamp IS at least this time
|
||||
in the system that stores.
|
||||
E.g., use to validate that the time in the postgres database
|
||||
is equal to or larger than the given timestamp, if not
|
||||
raise an error.
|
||||
This is meant to help prevent time-drift issues since
|
||||
time may not be monotonically increasing!
|
||||
"""
|
||||
self.update(keys, group_ids=group_ids, time_at_least=time_at_least)
|
||||
|
||||
def exists(self, keys: Sequence[str]) -> list[bool]:
|
||||
"""Check if the provided keys exist in the database.
|
||||
|
||||
Args:
|
||||
keys: A list of keys to check.
|
||||
|
||||
Returns:
|
||||
A list of boolean values indicating the existence of each key.
|
||||
"""
|
||||
return [key in self.records for key in keys]
|
||||
|
||||
async def aexists(self, keys: Sequence[str]) -> list[bool]:
|
||||
"""Async check if the provided keys exist in the database.
|
||||
|
||||
Args:
|
||||
keys: A list of keys to check.
|
||||
|
||||
Returns:
|
||||
A list of boolean values indicating the existence of each key.
|
||||
"""
|
||||
return self.exists(keys)
|
||||
|
||||
def list_keys(
|
||||
self,
|
||||
*,
|
||||
before: float | None = None,
|
||||
after: float | None = None,
|
||||
group_ids: Sequence[str] | None = None,
|
||||
limit: int | None = None,
|
||||
) -> list[str]:
|
||||
"""List records in the database based on the provided filters.
|
||||
|
||||
Args:
|
||||
before: Filter to list records updated before this time.
|
||||
|
||||
after: Filter to list records updated after this time.
|
||||
|
||||
group_ids: Filter to list records with specific group IDs.
|
||||
|
||||
limit: optional limit on the number of records to return.
|
||||
|
||||
|
||||
Returns:
|
||||
A list of keys for the matching records.
|
||||
"""
|
||||
result = []
|
||||
for key, data in self.records.items():
|
||||
if before and data["updated_at"] >= before:
|
||||
continue
|
||||
if after and data["updated_at"] <= after:
|
||||
continue
|
||||
if group_ids and data["group_id"] not in group_ids:
|
||||
continue
|
||||
result.append(key)
|
||||
if limit:
|
||||
return result[:limit]
|
||||
return result
|
||||
|
||||
async def alist_keys(
|
||||
self,
|
||||
*,
|
||||
before: float | None = None,
|
||||
after: float | None = None,
|
||||
group_ids: Sequence[str] | None = None,
|
||||
limit: int | None = None,
|
||||
) -> list[str]:
|
||||
"""Async list records in the database based on the provided filters.
|
||||
|
||||
Args:
|
||||
before: Filter to list records updated before this time.
|
||||
|
||||
after: Filter to list records updated after this time.
|
||||
|
||||
group_ids: Filter to list records with specific group IDs.
|
||||
|
||||
limit: optional limit on the number of records to return.
|
||||
|
||||
|
||||
Returns:
|
||||
A list of keys for the matching records.
|
||||
"""
|
||||
return self.list_keys(
|
||||
before=before, after=after, group_ids=group_ids, limit=limit
|
||||
)
|
||||
|
||||
def delete_keys(self, keys: Sequence[str]) -> None:
|
||||
"""Delete specified records from the database.
|
||||
|
||||
Args:
|
||||
keys: A list of keys to delete.
|
||||
"""
|
||||
for key in keys:
|
||||
if key in self.records:
|
||||
del self.records[key]
|
||||
|
||||
async def adelete_keys(self, keys: Sequence[str]) -> None:
|
||||
"""Async delete specified records from the database.
|
||||
|
||||
Args:
|
||||
keys: A list of keys to delete.
|
||||
"""
|
||||
self.delete_keys(keys)
|
||||
|
||||
|
||||
class UpsertResponse(TypedDict):
|
||||
"""A generic response for upsert operations.
|
||||
|
||||
The upsert response will be used by abstractions that implement an upsert
|
||||
operation for content that can be upserted by ID.
|
||||
|
||||
Upsert APIs that accept inputs with IDs and generate IDs internally
|
||||
will return a response that includes the IDs that succeeded and the IDs
|
||||
that failed.
|
||||
|
||||
If there are no failures, the failed list will be empty, and the order
|
||||
of the IDs in the succeeded list will match the order of the input documents.
|
||||
|
||||
If there are failures, the response becomes ill defined, and a user of the API
|
||||
cannot determine which generated ID corresponds to which input document.
|
||||
|
||||
It is recommended for users explicitly attach the IDs to the items being
|
||||
indexed to avoid this issue.
|
||||
"""
|
||||
|
||||
succeeded: list[str]
|
||||
"""The IDs that were successfully indexed."""
|
||||
failed: list[str]
|
||||
"""The IDs that failed to index."""
|
||||
|
||||
|
||||
class DeleteResponse(TypedDict, total=False):
|
||||
"""A generic response for delete operation.
|
||||
|
||||
The fields in this response are optional and whether the `VectorStore`
|
||||
returns them or not is up to the implementation.
|
||||
"""
|
||||
|
||||
num_deleted: int
|
||||
"""The number of items that were successfully deleted.
|
||||
|
||||
If returned, this should only include *actual* deletions.
|
||||
|
||||
If the ID did not exist to begin with,
|
||||
it should not be included in this count.
|
||||
"""
|
||||
|
||||
succeeded: Sequence[str]
|
||||
"""The IDs that were successfully deleted.
|
||||
|
||||
If returned, this should only include *actual* deletions.
|
||||
|
||||
If the ID did not exist to begin with,
|
||||
it should not be included in this list.
|
||||
"""
|
||||
|
||||
failed: Sequence[str]
|
||||
"""The IDs that failed to be deleted.
|
||||
|
||||
!!! warning
|
||||
Deleting an ID that does not exist is **NOT** considered a failure.
|
||||
"""
|
||||
|
||||
num_failed: int
|
||||
"""The number of items that failed to be deleted."""
|
||||
|
||||
|
||||
@beta(message="Added in 0.2.29. The abstraction is subject to change.")
|
||||
class DocumentIndex(BaseRetriever):
|
||||
"""A document retriever that supports indexing operations.
|
||||
|
||||
This indexing interface is designed to be a generic abstraction for storing and
|
||||
querying documents that has an ID and metadata associated with it.
|
||||
|
||||
The interface is designed to be agnostic to the underlying implementation of the
|
||||
indexing system.
|
||||
|
||||
The interface is designed to support the following operations:
|
||||
|
||||
1. Storing document in the index.
|
||||
2. Fetching document by ID.
|
||||
3. Searching for document using a query.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def upsert(self, items: Sequence[Document], /, **kwargs: Any) -> UpsertResponse:
|
||||
"""Upsert documents into the index.
|
||||
|
||||
The upsert functionality should utilize the ID field of the content object
|
||||
if it is provided. If the ID is not provided, the upsert method is free
|
||||
to generate an ID for the content.
|
||||
|
||||
When an ID is specified and the content already exists in the `VectorStore`,
|
||||
the upsert method should update the content with the new data. If the content
|
||||
does not exist, the upsert method should add the item to the `VectorStore`.
|
||||
|
||||
Args:
|
||||
items: Sequence of documents to add to the `VectorStore`.
|
||||
**kwargs: Additional keyword arguments.
|
||||
|
||||
Returns:
|
||||
A response object that contains the list of IDs that were
|
||||
successfully added or updated in the `VectorStore` and the list of IDs that
|
||||
failed to be added or updated.
|
||||
"""
|
||||
|
||||
async def aupsert(
|
||||
self, items: Sequence[Document], /, **kwargs: Any
|
||||
) -> UpsertResponse:
|
||||
"""Add or update documents in the `VectorStore`. Async version of `upsert`.
|
||||
|
||||
The upsert functionality should utilize the ID field of the item
|
||||
if it is provided. If the ID is not provided, the upsert method is free
|
||||
to generate an ID for the item.
|
||||
|
||||
When an ID is specified and the item already exists in the `VectorStore`,
|
||||
the upsert method should update the item with the new data. If the item
|
||||
does not exist, the upsert method should add the item to the `VectorStore`.
|
||||
|
||||
Args:
|
||||
items: Sequence of documents to add to the `VectorStore`.
|
||||
**kwargs: Additional keyword arguments.
|
||||
|
||||
Returns:
|
||||
A response object that contains the list of IDs that were
|
||||
successfully added or updated in the `VectorStore` and the list of IDs that
|
||||
failed to be added or updated.
|
||||
"""
|
||||
return await run_in_executor(
|
||||
None,
|
||||
self.upsert,
|
||||
items,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@abc.abstractmethod
|
||||
def delete(self, ids: list[str] | None = None, **kwargs: Any) -> DeleteResponse:
|
||||
"""Delete by IDs or other criteria.
|
||||
|
||||
Calling delete without any input parameters should raise a ValueError!
|
||||
|
||||
Args:
|
||||
ids: List of IDs to delete.
|
||||
**kwargs: Additional keyword arguments. This is up to the implementation.
|
||||
For example, can include an option to delete the entire index,
|
||||
or else issue a non-blocking delete etc.
|
||||
|
||||
Returns:
|
||||
A response object that contains the list of IDs that were
|
||||
successfully deleted and the list of IDs that failed to be deleted.
|
||||
"""
|
||||
|
||||
async def adelete(
|
||||
self, ids: list[str] | None = None, **kwargs: Any
|
||||
) -> DeleteResponse:
|
||||
"""Delete by IDs or other criteria. Async variant.
|
||||
|
||||
Calling adelete without any input parameters should raise a ValueError!
|
||||
|
||||
Args:
|
||||
ids: List of IDs to delete.
|
||||
**kwargs: Additional keyword arguments. This is up to the implementation.
|
||||
For example, can include an option to delete the entire index.
|
||||
|
||||
Returns:
|
||||
A response object that contains the list of IDs that were
|
||||
successfully deleted and the list of IDs that failed to be deleted.
|
||||
"""
|
||||
return await run_in_executor(
|
||||
None,
|
||||
self.delete,
|
||||
ids,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@abc.abstractmethod
|
||||
def get(
|
||||
self,
|
||||
ids: Sequence[str],
|
||||
/,
|
||||
**kwargs: Any,
|
||||
) -> list[Document]:
|
||||
"""Get documents by id.
|
||||
|
||||
Fewer documents may be returned than requested if some IDs are not found or
|
||||
if there are duplicated IDs.
|
||||
|
||||
Users should not assume that the order of the returned documents matches
|
||||
the order of the input IDs. Instead, users should rely on the ID field of the
|
||||
returned documents.
|
||||
|
||||
This method should **NOT** raise exceptions if no documents are found for
|
||||
some IDs.
|
||||
|
||||
Args:
|
||||
ids: List of IDs to get.
|
||||
**kwargs: Additional keyword arguments. These are up to the implementation.
|
||||
|
||||
Returns:
|
||||
List of documents that were found.
|
||||
"""
|
||||
|
||||
async def aget(
|
||||
self,
|
||||
ids: Sequence[str],
|
||||
/,
|
||||
**kwargs: Any,
|
||||
) -> list[Document]:
|
||||
"""Get documents by id.
|
||||
|
||||
Fewer documents may be returned than requested if some IDs are not found or
|
||||
if there are duplicated IDs.
|
||||
|
||||
Users should not assume that the order of the returned documents matches
|
||||
the order of the input IDs. Instead, users should rely on the ID field of the
|
||||
returned documents.
|
||||
|
||||
This method should **NOT** raise exceptions if no documents are found for
|
||||
some IDs.
|
||||
|
||||
Args:
|
||||
ids: List of IDs to get.
|
||||
**kwargs: Additional keyword arguments. These are up to the implementation.
|
||||
|
||||
Returns:
|
||||
List of documents that were found.
|
||||
"""
|
||||
return await run_in_executor(
|
||||
None,
|
||||
self.get,
|
||||
ids,
|
||||
**kwargs,
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user