ae67bff5a3
Self-hosted platform for building, testing, and shipping LangChain/LangGraph agents. Deep-agent sub-agents on the canvas, a live tracing/observability timeline, auto-provisioned built-in tools with import/export, per-environment tool variables, streamed evaluations, and per-user auth token forwarding.
314 lines
13 KiB
Python
314 lines
13 KiB
Python
"""Audit fixes for the RAG relevance floor + ingestion/retrieval gaps.
|
|
|
|
Covers, per the feature audit:
|
|
- effective cosine grounding floor (calibrated for the default BGE embedder) end to end,
|
|
- min_score thresholding the TRUE cosine in hybrid mode (not the fused rank),
|
|
- source-provenance citations persisted on chunks,
|
|
- crawl honoring robots.txt + max_depth,
|
|
- CSV/JSON per-record parsing + binary-upload rejection + HTML stripping,
|
|
- chunk_size clamped to the embedder's input limit,
|
|
- long-term-memory recall similarity floor.
|
|
|
|
Model-backed tests use the local fastembed embedder and skip cleanly when it can't load
|
|
(offline), mirroring the other knowledge tests.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
from forge.knowledge.crawl import MAX_DEPTH_CAP, MAX_PAGES_CAP
|
|
from forge.knowledge.embeddings import DEFAULT_MIN_SCORE, DEFAULT_RERANK_MIN_SCORE, _max_input_chars
|
|
from forge.knowledge.store import Hit, citation_for
|
|
from forge.nodes.rag import _passes_floor
|
|
from forge.routers.knowledge import _csv_to_text, _decode_upload, _json_to_text
|
|
|
|
|
|
def _require_embedder():
|
|
"""Return the local BGE embedder or skip (offline / model not cached)."""
|
|
pytest.importorskip("fastembed")
|
|
from forge.knowledge.embeddings import resolve_embedder
|
|
|
|
try:
|
|
e = resolve_embedder(None)
|
|
except Exception: # noqa: BLE001
|
|
pytest.skip("fastembed model could not be loaded (offline)")
|
|
if getattr(e, "name", "") != "BAAI/bge-small-en-v1.5":
|
|
pytest.skip("fastembed model unavailable")
|
|
return e
|
|
|
|
|
|
# --- calibrated floor defaults (pure) ---
|
|
|
|
def test_default_floors_are_calibrated():
|
|
# BGE unrelated pairs measure ~0.4-0.52, related ~0.75+, so the floor must sit between.
|
|
assert 0.55 <= DEFAULT_MIN_SCORE <= 0.7
|
|
assert 0.0 < DEFAULT_RERANK_MIN_SCORE < DEFAULT_MIN_SCORE
|
|
|
|
|
|
# --- min_score on the right scale (pure) ---
|
|
|
|
def test_passes_floor_uses_cosine_not_fused_rank():
|
|
# vector-only: Hit.score IS the cosine.
|
|
assert _passes_floor(Hit("a", "t", 0.70, {}), 0.6, hybrid=False) is True
|
|
assert _passes_floor(Hit("a", "t", 0.50, {}), 0.6, hybrid=False) is False
|
|
# hybrid: Hit.score is the fused rank (top≈1.0); the floor must use vector_score.
|
|
assert _passes_floor(Hit("a", "t", 1.0, {}, vector_score=0.50), 0.6, hybrid=True) is False
|
|
assert _passes_floor(Hit("a", "t", 1.0, {}, vector_score=0.70), 0.6, hybrid=True) is True
|
|
# a BM25-only hit has no cosine -> kept (a strong exact-term match isn't floored out).
|
|
assert _passes_floor(Hit("a", "t", 0.9, {}, vector_score=None), 0.6, hybrid=True) is True
|
|
|
|
|
|
# --- citations from chunk provenance (pure) ---
|
|
|
|
def test_citation_for_prefers_page_then_source():
|
|
assert citation_for({"page_url": "https://x.test/pricing", "page_title": "pricing"}) == "pricing — https://x.test/pricing"
|
|
assert citation_for({"page_url": "https://x.test/p"}) == "https://x.test/p"
|
|
assert citation_for({"source_name": "help", "source_uri": "https://x.test/help"}) == "help — https://x.test/help"
|
|
assert citation_for({"source_name": "manual"}) == "manual"
|
|
assert citation_for({}) == ""
|
|
assert citation_for(None) == ""
|
|
|
|
|
|
# --- BM25 cache split: index build + score parity (pure) ---
|
|
|
|
def test_bm25_build_and_score_match_one_shot():
|
|
pytest.importorskip("rank_bm25")
|
|
from forge.knowledge.hybrid import bm25_rank, bm25_scores, build_bm25
|
|
|
|
docs = [
|
|
("d1", "general refund and shipping policy details"),
|
|
("d2", "error code XJ9000 means a payment gateway timeout"),
|
|
("d3", "how to contact our support team"),
|
|
]
|
|
idx = build_bm25(docs)
|
|
assert bm25_scores(idx, "XJ9000 gateway timeout")[0] == "d2"
|
|
# The cached-index path must match the one-shot bm25_rank exactly.
|
|
assert bm25_scores(idx, "XJ9000 gateway timeout") == bm25_rank("XJ9000 gateway timeout", docs)
|
|
assert bm25_scores(idx, "zzz qqq") == []
|
|
assert bm25_scores(None, "anything") == []
|
|
assert build_bm25([]) is None
|
|
|
|
|
|
# --- chunk_size vs embedder token limit (pure) ---
|
|
|
|
def test_max_input_chars_by_model():
|
|
assert _max_input_chars("BAAI/bge-small-en-v1.5") == 512 * 4
|
|
assert _max_input_chars("text-embedding-3-small") == 8191 * 4
|
|
assert _max_input_chars("some-unknown-model") == 512 * 4 # conservative default
|
|
|
|
|
|
# --- CSV/JSON per-record parsing + upload guards (pure) ---
|
|
|
|
def test_csv_parsed_into_header_qualified_records():
|
|
out = _csv_to_text("name,plan,seats\nAcme,enterprise,50\nBeta,free,3")
|
|
assert "name: Acme" in out and "plan: enterprise" in out and "seats: 50" in out
|
|
assert "name: Beta" in out
|
|
assert "\n\n" in out # records separated so the chunker can split on record boundaries
|
|
|
|
|
|
def test_csv_without_data_rows_falls_back():
|
|
assert _csv_to_text("only one line") == "only one line"
|
|
|
|
|
|
def test_json_list_of_objects_becomes_records():
|
|
out = _json_to_text('[{"q": "hi", "a": "there"}, {"q": "bye", "a": "now"}]')
|
|
assert "q: hi | a: there" in out
|
|
assert "q: bye | a: now" in out
|
|
assert "\n\n" in out
|
|
|
|
|
|
def test_json_single_list_value_is_expanded():
|
|
out = _json_to_text('{"items": [{"k": 1}, {"k": 2}]}')
|
|
assert "k: 1" in out and "k: 2" in out
|
|
|
|
|
|
def test_json_invalid_falls_back_to_raw():
|
|
assert _json_to_text("not json {{{") == "not json {{{"
|
|
|
|
|
|
def test_decode_upload_rejects_binary_extension():
|
|
with pytest.raises(HTTPException) as ei:
|
|
_decode_upload("report.docx", b"PK\x03\x04 not really text")
|
|
assert ei.value.status_code == 422
|
|
|
|
|
|
def test_decode_upload_rejects_null_byte_binary():
|
|
with pytest.raises(HTTPException):
|
|
_decode_upload("mystery.dat", b"text\x00\x00\x01\x02 binary")
|
|
|
|
|
|
def test_decode_upload_strips_html():
|
|
out = _decode_upload("page.html", b"<html><body><h1>Hi</h1><p>There</p></body></html>")
|
|
assert "Hi" in out and "There" in out
|
|
assert "<h1>" not in out and "<body>" not in out
|
|
|
|
|
|
def test_decode_upload_plain_text_and_csv_dispatch():
|
|
assert _decode_upload("notes.txt", b"just some notes") == "just some notes"
|
|
out = _decode_upload("data.csv", b"a,b\n1,2")
|
|
assert "a: 1" in out and "b: 2" in out
|
|
|
|
|
|
# --- crawl: robots.txt + depth (monkeypatched network, offline) ---
|
|
|
|
def test_crawl_caps_are_bounded():
|
|
assert MAX_PAGES_CAP <= 500 and MAX_DEPTH_CAP <= 10
|
|
|
|
|
|
async def test_crawl_honors_robots_and_max_depth(monkeypatch):
|
|
import forge.util.ssrf as ssrf
|
|
from forge.knowledge import crawl as crawl_mod
|
|
|
|
class _Resp:
|
|
def __init__(self, text: str, status: int = 200) -> None:
|
|
self.text = text
|
|
self.status_code = status
|
|
|
|
site = {
|
|
"https://acme.test/robots.txt": _Resp("User-agent: *\nDisallow: /private\n"),
|
|
"https://acme.test/": _Resp('<a href="/a">A</a> <a href="/private">P</a> <a href="/b">B</a>'),
|
|
"https://acme.test/a": _Resp('<a href="/c">C</a> alpha body'),
|
|
"https://acme.test/b": _Resp("bee body"),
|
|
"https://acme.test/c": _Resp("cee body"),
|
|
"https://acme.test/private": _Resp("secret body"),
|
|
}
|
|
|
|
async def fake_get(client, url, **kw):
|
|
if url in site:
|
|
return site[url]
|
|
raise RuntimeError("404")
|
|
|
|
monkeypatch.setattr(ssrf, "guarded_get", fake_get)
|
|
pages = await crawl_mod.crawl_site("https://acme.test/", max_pages=50, max_depth=1, delay=0.0)
|
|
|
|
crawled = set(pages) # exact-URL membership (not substring) so this stays a set lookup
|
|
assert "https://acme.test/" in crawled
|
|
assert "https://acme.test/a" in crawled and "https://acme.test/b" in crawled
|
|
assert "https://acme.test/private" not in crawled # robots.txt Disallow honored
|
|
assert "https://acme.test/c" not in crawled # one hop beyond max_depth=1
|
|
|
|
|
|
# --- model-backed end-to-end ---
|
|
|
|
async def test_offtopic_query_is_floored_and_on_topic_cites(tmp_path):
|
|
_require_embedder()
|
|
from langchain_core.messages import SystemMessage
|
|
|
|
from forge.config import settings
|
|
from forge.db.base import SessionLocal
|
|
from forge.engine.context import CompileContext
|
|
from forge.nodes.rag import retrieval_factory
|
|
from forge.services.knowledge import KnowledgeService
|
|
|
|
settings.chroma_path = str(tmp_path / "chroma_floor")
|
|
t, p = "t_floor", "p_floor"
|
|
async with SessionLocal() as s:
|
|
src = await KnowledgeService.create_source(
|
|
s, t, p, kind="text", name="refunds",
|
|
text="Refunds are issued to the original payment method within 5-7 business days.",
|
|
)
|
|
await KnowledgeService.ingest(s, src)
|
|
|
|
node = retrieval_factory({"announce_empty": True, "top_k": 3}, CompileContext(tenant_id=t, project_id=p))
|
|
off = await node({"messages": [{"role": "user", "content": "what is the capital of France?"}]})
|
|
assert isinstance(off["messages"][-1], SystemMessage)
|
|
assert "no relevant" in off["messages"][-1].content.lower() # off-topic floored -> empty note
|
|
|
|
on = await node({"messages": [{"role": "user", "content": "how long do refunds take?"}]})
|
|
body = on["messages"][-1].content
|
|
assert "KNOWLEDGE BASE context" in body
|
|
assert "Refunds" in body
|
|
assert "refunds" in body.lower().split("] ")[0] # citation label carries the source name
|
|
|
|
|
|
async def test_hybrid_hit_carries_true_cosine_vector_score(tmp_path):
|
|
_require_embedder()
|
|
from forge.config import settings
|
|
from forge.db.base import SessionLocal
|
|
from forge.services.knowledge import KnowledgeService
|
|
|
|
settings.chroma_path = str(tmp_path / "chroma_hy_vs")
|
|
t, p = "t_hyvs", "p_hyvs"
|
|
async with SessionLocal() as s:
|
|
for i, txt in enumerate([
|
|
"Refunds go to the original card within five business days.",
|
|
"Error code XJ9000 indicates a payment gateway timeout; retry after 30 seconds.",
|
|
"Cancel an order from the Orders page before it ships.",
|
|
]):
|
|
src = await KnowledgeService.create_source(s, t, p, kind="text", name=f"d{i}", text=txt)
|
|
await KnowledgeService.ingest(s, src)
|
|
hits = await KnowledgeService.search(s, t, p, "XJ9000 timeout", top_k=3, hybrid=True)
|
|
assert hits
|
|
assert hits[0].score == 1.0 # fused rank still normalized to 1.0 at the top
|
|
# vector_score is the underlying cosine (0..1), a different scale from the fused score.
|
|
assert any(h.vector_score is not None for h in hits)
|
|
assert all(h.vector_score is None or 0.0 <= h.vector_score <= 1.0 for h in hits)
|
|
|
|
|
|
async def test_ingest_persists_source_citation_metadata(tmp_path):
|
|
_require_embedder()
|
|
from forge.config import settings
|
|
from forge.db.base import SessionLocal
|
|
from forge.services.knowledge import KnowledgeService
|
|
|
|
settings.chroma_path = str(tmp_path / "chroma_cite")
|
|
t, p = "t_cite", "p_cite"
|
|
async with SessionLocal() as s:
|
|
src = await KnowledgeService.create_source(
|
|
s, t, p, kind="text", name="Refund Policy",
|
|
text="Refunds are issued to the original payment method within five business days.",
|
|
)
|
|
await KnowledgeService.ingest(s, src)
|
|
hits = await KnowledgeService.search(s, t, p, "refund timing", top_k=2)
|
|
assert hits
|
|
assert hits[0].metadata.get("source_name") == "Refund Policy"
|
|
assert hits[0].metadata.get("embedding_model") == "BAAI/bge-small-en-v1.5"
|
|
assert citation_for(hits[0].metadata) == "Refund Policy"
|
|
|
|
|
|
async def test_ingest_clamps_chunk_size_to_embedder_limit(tmp_path):
|
|
_require_embedder()
|
|
from forge.config import settings
|
|
from forge.db.base import SessionLocal
|
|
from forge.models import Project
|
|
from forge.services.knowledge import KnowledgeService
|
|
|
|
settings.chroma_path = str(tmp_path / "chroma_clamp")
|
|
async with SessionLocal() as s:
|
|
proj = Project(tenant_id="t_cl", name="Cl", slug="clamp", config={"rag_defaults": {"chunk_size": 100000}})
|
|
s.add(proj)
|
|
await s.commit()
|
|
await s.refresh(proj)
|
|
src = await KnowledgeService.create_source(s, "t_cl", proj.id, kind="text", name="big", text="word " * 800)
|
|
src = await KnowledgeService.ingest(s, src)
|
|
assert src.status == "ready"
|
|
assert src.chunk_size <= _max_input_chars("BAAI/bge-small-en-v1.5") # clamped down
|
|
assert (src.meta or {}).get("chunk_size_requested") == 100000 # original recorded
|
|
|
|
|
|
async def test_memory_recall_similarity_floor(tmp_path):
|
|
_require_embedder()
|
|
from forge.config import settings
|
|
from forge.db.base import SessionLocal
|
|
from forge.services.memory import MemoryService
|
|
|
|
settings.chroma_path = str(tmp_path / "chroma_memfloor")
|
|
t, p = "t_memf", "p_memf"
|
|
async with SessionLocal() as s:
|
|
await MemoryService.remember(s, t, p, "Our refund window is 30 days.")
|
|
|
|
original = settings.memory_recall_min_score
|
|
try:
|
|
settings.memory_recall_min_score = 0.6
|
|
async with SessionLocal() as s:
|
|
off = await MemoryService.recall(s, t, p, "the capital of France", top_k=5)
|
|
assert off == [] # unrelated memory filtered by the floor
|
|
settings.memory_recall_min_score = 0.0
|
|
async with SessionLocal() as s:
|
|
on = await MemoryService.recall(s, t, p, "the capital of France", top_k=5)
|
|
assert on # floor off (default) -> nearest returned regardless of distance
|
|
finally:
|
|
settings.memory_recall_min_score = original
|