mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-30 04:15:45 +08:00
216 lines
6.5 KiB
Python
216 lines
6.5 KiB
Python
"""Knowledge retrieval utilities for local-agent runner."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import typing
|
|
from dataclasses import dataclass, field, replace
|
|
|
|
from langbot_plugin.api.proxies.runner import PermissionDeniedError, RunnerAPIProxy
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RagChunk:
|
|
"""Structured retrieval chunk kept by the runner before prompt rendering."""
|
|
|
|
kb_id: str
|
|
content: str
|
|
source_index: int
|
|
chunk_id: str | None = None
|
|
score: float | None = None
|
|
rerank_score: float | None = None
|
|
metadata: dict[str, typing.Any] = field(default_factory=dict)
|
|
|
|
|
|
async def retrieve_from_knowledge_bases(
|
|
api: RunnerAPIProxy,
|
|
kb_ids: list[str],
|
|
query_text: str,
|
|
top_k: int = 5,
|
|
rerank_model_id: str | None = None,
|
|
rerank_top_k: int = 5,
|
|
) -> str:
|
|
"""Retrieve context from authorized knowledge bases with optional reranking.
|
|
|
|
This compatibility wrapper returns only the stable model-facing text. Use
|
|
retrieve_rag_chunks() when the caller needs source metadata.
|
|
"""
|
|
return format_rag_chunks(
|
|
await retrieve_rag_chunks(
|
|
api=api,
|
|
kb_ids=kb_ids,
|
|
query_text=query_text,
|
|
top_k=top_k,
|
|
rerank_model_id=rerank_model_id,
|
|
rerank_top_k=rerank_top_k,
|
|
)
|
|
)
|
|
|
|
|
|
async def retrieve_rag_chunks(
|
|
api: RunnerAPIProxy,
|
|
kb_ids: list[str],
|
|
query_text: str,
|
|
top_k: int = 5,
|
|
rerank_model_id: str | None = None,
|
|
rerank_top_k: int = 5,
|
|
) -> list[RagChunk]:
|
|
"""Retrieve authorized knowledge chunks while preserving source metadata."""
|
|
if not kb_ids or not query_text:
|
|
return []
|
|
|
|
chunks: list[RagChunk] = []
|
|
|
|
for kb_id in kb_ids:
|
|
try:
|
|
results = await api.retrieve_knowledge(
|
|
kb_id=kb_id,
|
|
query_text=query_text,
|
|
top_k=top_k,
|
|
)
|
|
if results:
|
|
for entry in results:
|
|
chunk = _chunk_from_result(kb_id, len(chunks), entry)
|
|
if chunk is not None:
|
|
chunks.append(chunk)
|
|
except PermissionDeniedError:
|
|
logger.debug("Knowledge base is not authorized for this run: %s", kb_id)
|
|
continue
|
|
except Exception:
|
|
logger.warning("Knowledge base retrieval failed: %s", kb_id, exc_info=True)
|
|
continue
|
|
|
|
if not chunks:
|
|
return []
|
|
|
|
if rerank_model_id:
|
|
try:
|
|
scores = await api.invoke_rerank(
|
|
rerank_model_uuid=rerank_model_id,
|
|
query=query_text,
|
|
documents=[chunk.content for chunk in chunks],
|
|
top_k=rerank_top_k,
|
|
)
|
|
if scores:
|
|
reranked = _reranked_chunks(chunks, scores)
|
|
if reranked:
|
|
chunks = reranked
|
|
except PermissionDeniedError:
|
|
logger.debug("Rerank model is not authorized for this run: %s", rerank_model_id)
|
|
except Exception:
|
|
logger.warning("Knowledge rerank failed: %s", rerank_model_id, exc_info=True)
|
|
|
|
max_chunks = rerank_top_k if rerank_model_id else top_k
|
|
return chunks[: max(1, max_chunks)]
|
|
|
|
|
|
def format_rag_chunks(chunks: list[RagChunk]) -> str:
|
|
"""Render retrieval chunks for the model without volatile source metadata."""
|
|
if not chunks:
|
|
return ""
|
|
|
|
return json.dumps(
|
|
{
|
|
"chunks": [
|
|
{
|
|
"index": index,
|
|
"content": chunk.content,
|
|
}
|
|
for index, chunk in enumerate(chunks, start=1)
|
|
]
|
|
},
|
|
ensure_ascii=False,
|
|
)
|
|
|
|
|
|
def _chunk_from_result(kb_id: str, source_index: int, entry: typing.Any) -> RagChunk | None:
|
|
content = _extract_text_content(_model_or_mapping_get(entry, "content", ""))
|
|
if not content:
|
|
return None
|
|
|
|
metadata = _model_or_mapping_get(entry, "metadata", {})
|
|
if not isinstance(metadata, dict):
|
|
metadata = {}
|
|
|
|
return RagChunk(
|
|
kb_id=kb_id,
|
|
content=content,
|
|
source_index=source_index,
|
|
chunk_id=_optional_str(
|
|
_model_or_mapping_get(
|
|
entry,
|
|
"chunk_id",
|
|
_model_or_mapping_get(entry, "id", _model_or_mapping_get(entry, "document_id")),
|
|
)
|
|
),
|
|
score=_optional_float(_model_or_mapping_get(entry, "score", _model_or_mapping_get(entry, "similarity"))),
|
|
metadata=dict(metadata),
|
|
)
|
|
|
|
|
|
def _reranked_chunks(chunks: list[RagChunk], scores: typing.Any) -> list[RagChunk]:
|
|
reranked: list[RagChunk] = []
|
|
seen: set[int] = set()
|
|
|
|
for score_item in scores:
|
|
index = _model_or_mapping_get(score_item, "index")
|
|
if isinstance(index, bool) or not isinstance(index, int):
|
|
continue
|
|
if index < 0 or index >= len(chunks) or index in seen:
|
|
continue
|
|
seen.add(index)
|
|
reranked.append(
|
|
replace(
|
|
chunks[index],
|
|
rerank_score=_optional_float(
|
|
_model_or_mapping_get(
|
|
score_item,
|
|
"score",
|
|
_model_or_mapping_get(score_item, "relevance_score"),
|
|
)
|
|
),
|
|
)
|
|
)
|
|
|
|
return reranked
|
|
|
|
|
|
def _extract_text_content(content: typing.Any) -> str:
|
|
if isinstance(content, str):
|
|
return content
|
|
if isinstance(content, list):
|
|
text_parts: list[str] = []
|
|
for part in content:
|
|
if isinstance(part, dict):
|
|
if part.get("type") == "text" and isinstance(part.get("text"), str):
|
|
text_parts.append(part["text"])
|
|
elif isinstance(part, str):
|
|
text_parts.append(part)
|
|
elif getattr(part, "type", None) == "text" and isinstance(getattr(part, "text", None), str):
|
|
text_parts.append(part.text)
|
|
return " ".join(part for part in text_parts if part)
|
|
return ""
|
|
|
|
|
|
def _model_or_mapping_get(value: typing.Any, key: str, default: typing.Any = None) -> typing.Any:
|
|
if isinstance(value, dict):
|
|
return value.get(key, default)
|
|
return getattr(value, key, default)
|
|
|
|
|
|
def _optional_str(value: typing.Any) -> str | None:
|
|
if isinstance(value, str) and value:
|
|
return value
|
|
return None
|
|
|
|
|
|
def _optional_float(value: typing.Any) -> float | None:
|
|
if isinstance(value, bool):
|
|
return None
|
|
if isinstance(value, int | float):
|
|
return float(value)
|
|
return None
|