640 lines
23 KiB
Python
640 lines
23 KiB
Python
"""FastAPI routes for peer resources and peer-scoped operations."""
|
|
|
|
import json
|
|
import logging
|
|
from collections.abc import AsyncIterator
|
|
from contextlib import suppress
|
|
from time import perf_counter
|
|
|
|
from fastapi import APIRouter, Body, Depends, Path, Query, Response
|
|
from fastapi.responses import StreamingResponse
|
|
from fastapi_pagination import Page
|
|
from fastapi_pagination.ext.sqlalchemy import apaginate
|
|
from pydantic import BaseModel
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from src import crud, schemas
|
|
from src.config import settings
|
|
from src.crud.message import get_peer_session_names
|
|
from src.crud.session import is_peer_in_session
|
|
from src.dependencies import db, read_db, tracked_db
|
|
from src.dialectic.chat import agentic_chat, agentic_chat_stream
|
|
from src.embedding_client import embedding_client
|
|
from src.exceptions import (
|
|
AuthenticationException,
|
|
ResourceNotFoundException,
|
|
ValidationException,
|
|
)
|
|
from src.security import JWTParams, require_auth
|
|
from src.telemetry import prometheus_metrics
|
|
from src.telemetry.events import EmbeddingCallPurpose, GetContextEvent, emit
|
|
from src.utils.filter import extract_session_allowlist
|
|
from src.utils.schema_conversion import json_response_schema_to_pydantic
|
|
from src.utils.scopes import is_scope_peer_name, validate_no_scope_peer_names
|
|
from src.utils.search import search
|
|
from src.utils.types import embedding_call_purpose
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(
|
|
prefix="/workspaces/{workspace_id}/peers",
|
|
tags=["peers"],
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/list",
|
|
response_model=Page[schemas.Peer],
|
|
dependencies=[Depends(require_auth(workspace_name="workspace_id"))],
|
|
)
|
|
async def get_peers(
|
|
workspace_id: str = Path(...),
|
|
options: schemas.PeerGet | None = Body(
|
|
None, description="Filtering options for the peers list"
|
|
),
|
|
reverse: bool = Query(False, description="Whether to reverse the order of results"),
|
|
db: AsyncSession = read_db,
|
|
):
|
|
"""Get all Peers for a Workspace, paginated with optional filters.
|
|
|
|
Scope peers are excluded by default; set `kind` to "scope" for scope peers
|
|
only, or "all" for everything.
|
|
"""
|
|
filter_param = None
|
|
if options and hasattr(options, "filters"):
|
|
filter_param = options.filters
|
|
if filter_param == {}:
|
|
filter_param = None
|
|
|
|
return await apaginate(
|
|
db,
|
|
await crud.get_peers(
|
|
workspace_name=workspace_id,
|
|
filters=filter_param,
|
|
reverse=reverse,
|
|
kind=options.kind if options else None,
|
|
),
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"",
|
|
response_model=schemas.Peer,
|
|
)
|
|
async def get_or_create_peer(
|
|
response: Response,
|
|
workspace_id: str = Path(...),
|
|
peer: schemas.PeerCreate = Body(..., description="Peer creation parameters"),
|
|
jwt_params: JWTParams = Depends(require_auth()),
|
|
db: AsyncSession = db,
|
|
):
|
|
"""
|
|
Get a Peer by ID or create a new Peer with the given ID.
|
|
|
|
If peer_id is provided as a query parameter, it uses that (must match JWT workspace_id).
|
|
Otherwise, it uses the peer_id from the JWT.
|
|
"""
|
|
# validate workspace query param
|
|
if not jwt_params.ad and jwt_params.w is not None and jwt_params.w != workspace_id:
|
|
raise AuthenticationException("Unauthorized access to resource")
|
|
|
|
if peer.name:
|
|
if not jwt_params.ad and jwt_params.p is not None and jwt_params.p != peer.name:
|
|
raise AuthenticationException("Unauthorized access to resource")
|
|
else:
|
|
# Use peer_id from JWT
|
|
if not jwt_params.p:
|
|
raise AuthenticationException("Peer ID not found in query parameter or JWT")
|
|
peer.name = jwt_params.p
|
|
|
|
# The scope namespace is reserved: scope peers are only created through
|
|
# the scopes facade (POST /workspaces/{workspace_id}/scopes).
|
|
validate_no_scope_peer_names(
|
|
[peer.name],
|
|
action="Use the scopes routes to create scopes.",
|
|
)
|
|
|
|
result = await crud.get_or_create_peers(
|
|
db, workspace_name=workspace_id, peers=[peer]
|
|
)
|
|
await db.commit()
|
|
await result.post_commit()
|
|
response.status_code = 201 if result.created else 200
|
|
return result.resource[0]
|
|
|
|
|
|
@router.put(
|
|
"/{peer_id}",
|
|
response_model=schemas.Peer,
|
|
dependencies=[
|
|
Depends(require_auth(workspace_name="workspace_id", peer_name="peer_id"))
|
|
],
|
|
)
|
|
async def update_peer(
|
|
workspace_id: str = Path(...),
|
|
peer_id: str = Path(...),
|
|
peer: schemas.PeerUpdate = Body(..., description="Updated peer parameters"),
|
|
db: AsyncSession = db,
|
|
):
|
|
"""Update a Peer's metadata and/or configuration.
|
|
|
|
Three-way on the reserved namespace: a real scope is refused here (this route
|
|
replaces `configuration` wholesale, so it must never touch a facade-managed
|
|
peer); an existing *unflagged* peer that merely occupies the namespace is a
|
|
normal peer and updates fine; and a reserved-prefix name that does not exist
|
|
is refused by ``get_or_create_peers``' create-path validation rather than
|
|
being minted.
|
|
"""
|
|
await crud.reject_scope_peers(
|
|
db, workspace_id, [peer_id], action="Use the scopes routes to manage scopes."
|
|
)
|
|
updated_peer = await crud.update_peer(
|
|
db, workspace_name=workspace_id, peer_name=peer_id, peer=peer
|
|
)
|
|
return updated_peer
|
|
|
|
|
|
@router.post(
|
|
"/{peer_id}/sessions",
|
|
response_model=Page[schemas.Session],
|
|
dependencies=[
|
|
Depends(require_auth(workspace_name="workspace_id", peer_name="peer_id"))
|
|
],
|
|
)
|
|
async def get_sessions_for_peer(
|
|
workspace_id: str = Path(...),
|
|
peer_id: str = Path(...),
|
|
options: schemas.SessionGet | None = Body(
|
|
None, description="Filtering options for the sessions list"
|
|
),
|
|
reverse: bool = Query(False, description="Whether to reverse the order of results"),
|
|
db: AsyncSession = read_db,
|
|
):
|
|
"""Get all Sessions for a Peer, paginated with optional filters."""
|
|
filter_param = None
|
|
|
|
if options and hasattr(options, "filters"):
|
|
filter_param = options.filters
|
|
if filter_param == {}:
|
|
filter_param = None
|
|
|
|
return await apaginate(
|
|
db,
|
|
await crud.get_sessions_for_peer(
|
|
workspace_name=workspace_id,
|
|
peer_name=peer_id,
|
|
filters=filter_param,
|
|
reverse=reverse,
|
|
),
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/{peer_id}/chat",
|
|
responses={
|
|
200: {
|
|
"content": {
|
|
"application/json": {
|
|
"schema": schemas.DialecticResponse.model_json_schema()
|
|
},
|
|
"text/event-stream": {},
|
|
},
|
|
},
|
|
},
|
|
)
|
|
async def chat(
|
|
workspace_id: str = Path(...),
|
|
peer_id: str = Path(...),
|
|
options: schemas.DialecticOptions = Body(...),
|
|
jwt_params: JWTParams = Depends(
|
|
require_auth(workspace_name="workspace_id", peer_name="peer_id")
|
|
),
|
|
):
|
|
"""
|
|
Query a Peer's representation using natural language. Performs agentic search and reasoning to comprehensively
|
|
answer the query based on all latent knowledge gathered about the peer from their messages and conclusions.
|
|
"""
|
|
# Scope peers are never observed, so no representation of them exists to
|
|
# query. Covers the path-level observer too: a scope `peer_id` no longer
|
|
# errors out downstream now that crud.get_peer takes a plain name, and a
|
|
# scope peer as the path-level observer is a Phase 2b concern.
|
|
scope_candidates = [
|
|
n for n in (peer_id, options.target) if n is not None and is_scope_peer_name(n)
|
|
]
|
|
if scope_candidates:
|
|
async with tracked_db("peers.chat.scope_check", read_only=True) as s_db:
|
|
await crud.reject_scope_peers(
|
|
s_db,
|
|
workspace_id,
|
|
scope_candidates,
|
|
action=(
|
|
"No representation is formed of a scope, so a scope cannot "
|
|
"be a chat observer or target."
|
|
),
|
|
)
|
|
|
|
# The session id arrives in the body, so require_auth can't gate on it. A
|
|
# peer-scoped key may only scope a chat to a session its peer belongs to;
|
|
# without this check it could read any session's messages (the dialectic
|
|
# injects session history) by naming it here. Workspace/admin tokens
|
|
# (jwt_params.p is None) are unaffected.
|
|
if jwt_params.p is not None and options.session_id:
|
|
async with tracked_db("peers.chat.is_peer_in_session", read_only=True) as s_db:
|
|
if not await is_peer_in_session(
|
|
s_db, workspace_id, options.session_id, jwt_params.p
|
|
):
|
|
raise AuthenticationException("JWT not permissioned for this resource")
|
|
|
|
# Parse the session allowlist from filters (422 on unsupported keys/shapes,
|
|
# and on a session_id the allowlist doesn't cover).
|
|
session_allowlist = extract_session_allowlist(
|
|
options.filters, must_include=options.session_id
|
|
)
|
|
# A peer-scoped key may only name sessions its peer belongs to — the
|
|
# allowlist reaches message recall the same way session_id does above.
|
|
# `active_only` matches the is_peer_in_session check above, so both gates
|
|
# answer the same question for a peer that has left a session.
|
|
if jwt_params.p is not None and session_allowlist is not None:
|
|
async with tracked_db("peers.chat.session_scope_auth", read_only=True) as s_db:
|
|
member_sessions = set(
|
|
await get_peer_session_names(
|
|
s_db, workspace_id, jwt_params.p, active_only=True
|
|
)
|
|
)
|
|
if not set(session_allowlist) <= member_sessions:
|
|
raise AuthenticationException("JWT not permissioned for this resource")
|
|
|
|
# Convert the caller's JSON Schema so malformed schemas fail immediately with 422
|
|
response_model: type[BaseModel] | None = None
|
|
if options.response_format is not None:
|
|
try:
|
|
response_model = json_response_schema_to_pydantic(options.response_format)
|
|
except ValueError as e:
|
|
raise ValidationException(f"Invalid response_format: {e}") from None
|
|
|
|
# Get or create the peer to ensure it exists
|
|
async with tracked_db("peers.chat.get_or_create_peer") as peer_db:
|
|
peers_result = await crud.get_or_create_peers(
|
|
peer_db,
|
|
workspace_name=workspace_id,
|
|
peers=[schemas.PeerSpec(name=peer_id)],
|
|
)
|
|
await peer_db.commit()
|
|
await peers_result.post_commit()
|
|
|
|
if options.stream:
|
|
# Stream the response using Server-Sent Events
|
|
|
|
async def format_sse_stream(
|
|
chunks: AsyncIterator[str],
|
|
) -> AsyncIterator[str]:
|
|
"""Format chunks as SSE events."""
|
|
async for chunk in chunks:
|
|
yield f"data: {json.dumps({'delta': {'content': chunk}, 'done': False})}\n\n"
|
|
yield f"data: {json.dumps({'done': True})}\n\n"
|
|
|
|
# Prometheus metrics
|
|
if settings.METRICS.ENABLED:
|
|
prometheus_metrics.record_dialectic_call(
|
|
workspace_name=workspace_id,
|
|
reasoning_level=options.reasoning_level,
|
|
)
|
|
|
|
return StreamingResponse(
|
|
format_sse_stream(
|
|
agentic_chat_stream(
|
|
workspace_name=workspace_id,
|
|
session_name=options.session_id,
|
|
query=options.query,
|
|
observer=peer_id,
|
|
observed=options.target if options.target is not None else peer_id,
|
|
reasoning_level=options.reasoning_level,
|
|
session_allowlist=session_allowlist,
|
|
response_model=response_model,
|
|
)
|
|
),
|
|
media_type="text/event-stream",
|
|
)
|
|
|
|
response = await agentic_chat(
|
|
workspace_name=workspace_id,
|
|
session_name=options.session_id,
|
|
query=options.query,
|
|
observer=peer_id,
|
|
# if target is given, that's the observed peer. otherwise, observer==observed
|
|
# and it's answered from the omniscient Honcho perspective
|
|
observed=options.target if options.target is not None else peer_id,
|
|
reasoning_level=options.reasoning_level,
|
|
session_allowlist=session_allowlist,
|
|
response_model=response_model,
|
|
)
|
|
|
|
# Prometheus metrics
|
|
if settings.METRICS.ENABLED:
|
|
prometheus_metrics.record_dialectic_call(
|
|
workspace_name=workspace_id,
|
|
reasoning_level=options.reasoning_level,
|
|
)
|
|
|
|
return schemas.DialecticResponse(content=response if response else None)
|
|
|
|
|
|
@router.post(
|
|
"/{peer_id}/representation",
|
|
response_model=schemas.RepresentationResponse,
|
|
dependencies=[
|
|
Depends(require_auth(workspace_name="workspace_id", peer_name="peer_id"))
|
|
],
|
|
)
|
|
async def get_representation(
|
|
workspace_id: str = Path(...),
|
|
peer_id: str = Path(...),
|
|
options: schemas.PeerRepresentationGet = Body(
|
|
..., description="Options for getting the peer representation"
|
|
),
|
|
):
|
|
"""Get a curated subset of a Peer's Representation. A Representation is always a subset of the total
|
|
knowledge about the Peer. The subset can be scoped and filtered in various ways.
|
|
|
|
|
|
If a session_id is provided in the body, we get the Representation of the Peer scoped to that Session.
|
|
If a target is provided, we get the Representation of the target from the perspective of the Peer.
|
|
If no target is provided, we get the omniscient Honcho Representation of the Peer.
|
|
"""
|
|
# Scope peers are never observed, so no representation of them exists.
|
|
# Covers the path-level observer as well as the target.
|
|
scope_candidates = [
|
|
n for n in (peer_id, options.target) if n is not None and is_scope_peer_name(n)
|
|
]
|
|
if scope_candidates:
|
|
async with tracked_db(
|
|
"peers.representation.scope_check", read_only=True
|
|
) as s_db:
|
|
await crud.reject_scope_peers(
|
|
s_db,
|
|
workspace_id,
|
|
scope_candidates,
|
|
action=(
|
|
"No representation is formed of a scope, so a scope cannot "
|
|
"be a representation observer or target."
|
|
),
|
|
)
|
|
|
|
# Parse the session allowlist from filters (422 on unsupported keys/shapes,
|
|
# and on a session_id the allowlist doesn't cover).
|
|
session_allowlist = extract_session_allowlist(
|
|
options.filters, must_include=options.session_id
|
|
)
|
|
|
|
try:
|
|
embedding: list[float] | None = None
|
|
if options.search_query:
|
|
with (
|
|
suppress(Exception),
|
|
embedding_call_purpose(
|
|
EmbeddingCallPurpose.SEARCH_MEMORY.value,
|
|
workspace_name=workspace_id,
|
|
parent_category="api",
|
|
),
|
|
):
|
|
embedding = await embedding_client.embed(options.search_query)
|
|
|
|
# If no target specified, get global representation (omniscient Honcho perspective)
|
|
representation = await crud.get_working_representation(
|
|
workspace_id,
|
|
observer=peer_id,
|
|
observed=options.target if options.target is not None else peer_id,
|
|
session_allowlist=[options.session_id]
|
|
if options.session_id is not None
|
|
else session_allowlist,
|
|
include_semantic_query=options.search_query,
|
|
embedding=embedding,
|
|
semantic_search_top_k=options.search_top_k,
|
|
semantic_search_max_distance=options.search_max_distance,
|
|
include_most_derived=options.include_most_frequent
|
|
if options.include_most_frequent is not None
|
|
else False,
|
|
max_observations=options.max_conclusions
|
|
if options.max_conclusions is not None
|
|
else settings.DERIVER.WORKING_REPRESENTATION_MAX_OBSERVATIONS,
|
|
parent_category="api",
|
|
)
|
|
return schemas.RepresentationResponse(
|
|
representation=representation.format_as_markdown()
|
|
)
|
|
except ValueError as e:
|
|
logger.warning(f"Failed to get representation for peer {peer_id}: {str(e)}")
|
|
raise ResourceNotFoundException("Peer or session not found") from e
|
|
|
|
|
|
@router.get(
|
|
"/{peer_id}/card",
|
|
response_model=schemas.PeerCardResponse,
|
|
dependencies=[
|
|
Depends(require_auth(workspace_name="workspace_id", peer_name="peer_id"))
|
|
],
|
|
)
|
|
async def get_peer_card(
|
|
workspace_id: str = Path(...),
|
|
peer_id: str = Path(..., description="ID of the observer peer"),
|
|
target: str | None = Query(
|
|
None,
|
|
description="Optional target peer to retrieve a card for, from the observer's perspective. If not provided, returns the observer's own card",
|
|
),
|
|
db: AsyncSession = read_db,
|
|
):
|
|
"""Get a peer card for a specific peer relationship.
|
|
|
|
Returns the peer card that the observer peer has for the target peer if it exists.
|
|
If no target is specified, returns the observer's own peer card.
|
|
"""
|
|
# If no target specified, get the observer's own card
|
|
observed = target if target is not None else peer_id
|
|
|
|
peer_card = await crud.get_peer_card(
|
|
db, workspace_id, observer=peer_id, observed=observed
|
|
)
|
|
return schemas.PeerCardResponse(peer_card=peer_card)
|
|
|
|
|
|
@router.put(
|
|
"/{peer_id}/card",
|
|
response_model=schemas.PeerCardResponse,
|
|
dependencies=[
|
|
Depends(require_auth(workspace_name="workspace_id", peer_name="peer_id"))
|
|
],
|
|
)
|
|
async def set_peer_card(
|
|
workspace_id: str = Path(...),
|
|
peer_id: str = Path(..., description="ID of the observer peer"),
|
|
peer_card_data: schemas.PeerCardSet = Body(
|
|
..., description="Peer card data to set"
|
|
),
|
|
target: str | None = Query(
|
|
None,
|
|
description="Optional target peer to set a card for, from the observer's perspective. If not provided, sets the observer's own card",
|
|
),
|
|
db: AsyncSession = db,
|
|
):
|
|
"""Set a peer card for a specific peer relationship.
|
|
|
|
Sets the peer card that the observer peer has for the target peer.
|
|
If no target is specified, sets the observer's own peer card.
|
|
"""
|
|
# If no target specified, set the observer's own card
|
|
observed = target if target is not None else peer_id
|
|
|
|
await crud.set_peer_card(
|
|
db,
|
|
workspace_id,
|
|
peer_card=peer_card_data.peer_card,
|
|
observer=peer_id,
|
|
observed=observed,
|
|
)
|
|
|
|
return schemas.PeerCardResponse(peer_card=peer_card_data.peer_card)
|
|
|
|
|
|
@router.get(
|
|
"/{peer_id}/context",
|
|
response_model=schemas.PeerContext,
|
|
dependencies=[
|
|
Depends(require_auth(workspace_name="workspace_id", peer_name="peer_id"))
|
|
],
|
|
)
|
|
async def get_peer_context(
|
|
workspace_id: str = Path(...),
|
|
peer_id: str = Path(..., description="ID of the observer peer"),
|
|
target: str | None = Query(
|
|
None,
|
|
description="Optional target peer to get context for, from the observer's perspective. If not provided, returns the observer's own context (self-observation)",
|
|
),
|
|
search_query: str | None = Query(
|
|
None,
|
|
description="Optional query to curate the representation around semantic search results",
|
|
),
|
|
search_top_k: int | None = Query(
|
|
None,
|
|
ge=1,
|
|
le=100,
|
|
description="Only used if `search_query` is provided. Number of semantic-search-retrieved conclusions to include",
|
|
),
|
|
search_max_distance: float | None = Query(
|
|
None,
|
|
ge=0.0,
|
|
le=1.0,
|
|
description="Only used if `search_query` is provided. Maximum distance for semantically relevant conclusions",
|
|
),
|
|
include_most_frequent: bool = Query(
|
|
default=True,
|
|
description="Whether to include the most frequent conclusions in the representation",
|
|
),
|
|
max_conclusions: int | None = Query(
|
|
None,
|
|
ge=1,
|
|
le=100,
|
|
description="Maximum number of conclusions to include in the representation",
|
|
),
|
|
):
|
|
"""
|
|
Get context for a peer, including their representation and peer card.
|
|
|
|
This endpoint returns a curated subset of the representation and peer card for a peer.
|
|
If a target is specified, returns the context for the target from the
|
|
observer peer's perspective. If no target is specified, returns the
|
|
peer's own context (self-observation).
|
|
|
|
This is useful for getting all the context needed about a peer without
|
|
making multiple API calls.
|
|
"""
|
|
# If no target specified, get the peer's own context (self-observation)
|
|
observed = target if target is not None else peer_id
|
|
context_started = perf_counter()
|
|
|
|
try:
|
|
embedding: list[float] | None = None
|
|
if search_query:
|
|
with (
|
|
suppress(Exception),
|
|
embedding_call_purpose(
|
|
EmbeddingCallPurpose.SEARCH_MEMORY.value,
|
|
workspace_name=workspace_id,
|
|
parent_category="api",
|
|
),
|
|
):
|
|
embedding = await embedding_client.embed(search_query)
|
|
|
|
# Get the working representation
|
|
representation = await crud.get_working_representation(
|
|
workspace_id,
|
|
observer=peer_id,
|
|
observed=observed,
|
|
session_allowlist=None, # Peer context is global, not session-scoped
|
|
include_semantic_query=search_query,
|
|
embedding=embedding,
|
|
semantic_search_top_k=search_top_k,
|
|
semantic_search_max_distance=search_max_distance,
|
|
include_most_derived=include_most_frequent,
|
|
max_observations=max_conclusions
|
|
if max_conclusions is not None
|
|
else settings.DERIVER.WORKING_REPRESENTATION_MAX_OBSERVATIONS,
|
|
parent_category="api",
|
|
)
|
|
|
|
async with tracked_db(
|
|
"peers.get_peer_context.peer_card", read_only=True
|
|
) as card_db:
|
|
peer_card = await crud.get_peer_card(
|
|
card_db, workspace_id, observer=peer_id, observed=observed
|
|
)
|
|
|
|
response = schemas.PeerContext(
|
|
peer_id=peer_id,
|
|
target_id=observed,
|
|
representation=representation.format_as_markdown(),
|
|
peer_card=peer_card,
|
|
)
|
|
emit(
|
|
GetContextEvent(
|
|
workspace_name=workspace_id,
|
|
context_scope="peer",
|
|
peer_name=peer_id,
|
|
target_name=observed,
|
|
has_representation=bool(response.representation),
|
|
has_peer_card=peer_card is not None,
|
|
search_query_provided=search_query is not None,
|
|
search_top_k=search_top_k,
|
|
search_max_distance=search_max_distance,
|
|
include_most_frequent=include_most_frequent,
|
|
max_conclusions=max_conclusions,
|
|
total_duration_ms=(perf_counter() - context_started) * 1000,
|
|
)
|
|
)
|
|
return response
|
|
except ValueError as e:
|
|
logger.warning(f"Failed to get context for peer {peer_id}: {str(e)}")
|
|
raise ResourceNotFoundException("Peer not found") from e
|
|
|
|
|
|
@router.post(
|
|
"/{peer_id}/search",
|
|
response_model=list[schemas.Message],
|
|
dependencies=[
|
|
Depends(require_auth(workspace_name="workspace_id", peer_name="peer_id"))
|
|
],
|
|
)
|
|
async def search_peer(
|
|
workspace_id: str = Path(...),
|
|
peer_id: str = Path(...),
|
|
body: schemas.MessageSearchOptions = Body(
|
|
...,
|
|
description="Message search parameters. Use `limit` to control the number of results returned.",
|
|
),
|
|
):
|
|
"""Search a Peer's messages, optionally filtered by various criteria."""
|
|
# take user-provided filter and add workspace_id and peer_id to it
|
|
filters = body.filters or {}
|
|
filters["workspace_id"] = workspace_id
|
|
filters["peer_id"] = peer_id
|
|
return await search(body.query, filters=filters, limit=body.limit)
|