Rabbook / agents /services.py
Matcry's picture
Deploy snapshot
c76423f
Raw
History Blame Contribute Delete
8.05 kB
from dataclasses import dataclass
from rag.retrieve import (
answer_has_valid_citations,
build_citation_sources,
build_hit_debug,
check_grounding_evidence,
expand_with_context_window,
extract_citation_numbers,
extract_valid_source_numbers,
format_context,
generate_answer,
retrieve_documents_with_query_transform,
)
@dataclass
class AnswerResult:
answer: str
sources: list[dict]
citations: list[dict]
debug_data: dict | None
@dataclass
class ResearchResult:
synthesis: str
sources: list[dict] # {url, title, snippet, content}
note_id: str | None
debug_data: dict | None
def run_rag_graph_answer(*args, **kwargs):
from agents.rag_graph import run_rag_graph_answer as graph_runner
return graph_runner(*args, **kwargs)
def answer_query(
query,
*,
vectorstore,
chunk_registry,
reranker,
bm25_index,
llm,
retrieval_k,
rerank_candidate_k,
bm25_candidate_k,
context_window,
max_expanded_chunks,
min_grounded_rerank_score,
min_grounded_chunks,
grounded_fallback_message,
enable_query_transform,
selected_file="",
selected_file_type="",
page_start="",
page_end="",
debug_mode=False,
use_langgraph=False,
enable_research=False,
):
if use_langgraph:
result = run_rag_graph_answer(
query,
vectorstore=vectorstore,
chunk_registry=chunk_registry,
reranker=reranker,
bm25_index=bm25_index,
llm=llm,
retrieval_k=retrieval_k,
rerank_candidate_k=rerank_candidate_k,
bm25_candidate_k=bm25_candidate_k,
context_window=context_window,
max_expanded_chunks=max_expanded_chunks,
min_grounded_rerank_score=min_grounded_rerank_score,
min_grounded_chunks=min_grounded_chunks,
grounded_fallback_message=grounded_fallback_message,
enable_query_transform=enable_query_transform,
selected_file=selected_file,
selected_file_type=selected_file_type,
page_start=page_start,
page_end=page_end,
debug_mode=debug_mode,
enable_research=enable_research,
)
if debug_mode and result.debug_data is not None:
result.debug_data["pipeline_mode"] = "langgraph_rag"
return result
# Non-agentic path (Direct RAG) does not support research fallback yet
metadata_filter = build_metadata_filter(
selected_file=selected_file,
selected_file_type=selected_file_type,
page_start=page_start,
page_end=page_end,
)
retrieval_result = retrieve_documents_with_query_transform(
vectorstore,
query,
k=retrieval_k,
reranker=reranker,
bm25_index=bm25_index,
query_transformer=llm,
enable_query_transform=enable_query_transform,
candidate_k=rerank_candidate_k,
bm25_candidate_k=bm25_candidate_k,
metadata_filter=metadata_filter,
include_debug=debug_mode,
)
if debug_mode:
retrieved_documents, debug_data = retrieval_result
debug_data["metadata_filter"] = metadata_filter
debug_data["pipeline_mode"] = "direct_rag"
debug_data["grounding"] = {
"stage": "retrieval",
"passed": None,
"reason": "not_checked",
}
else:
retrieved_documents = retrieval_result
debug_data = None
expanded_documents = expand_with_context_window(
retrieved_documents,
chunk_registry,
window_size=context_window,
max_expanded_chunks=max_expanded_chunks,
)
if debug_mode:
debug_data["expanded_hits"] = build_hit_debug(expanded_documents)
debug_data["stage_counts"]["expanded_context"] = len(expanded_documents)
grounding = check_grounding_evidence(
retrieved_documents,
expanded_documents,
min_rerank_score=min_grounded_rerank_score,
min_expanded_chunks=min_grounded_chunks,
)
if debug_mode:
debug_data["grounding"].update(grounding)
debug_data["grounding"]["stage"] = "retrieval"
if not grounding["passed"]:
return AnswerResult(
answer=grounded_fallback_message,
sources=build_sources(retrieved_documents),
citations=[],
debug_data=debug_data,
)
context = format_context(expanded_documents)
answer = generate_answer(query, context, llm)
if not answer_is_grounded(answer, context):
if debug_mode:
debug_data["grounding"] = {
"stage": "answer",
"passed": False,
"reason": "citation_validation_failed",
"top_rerank_score": grounding["top_rerank_score"],
"retrieved_count": grounding["retrieved_count"],
"expanded_count": grounding["expanded_count"],
}
return AnswerResult(
answer=grounded_fallback_message,
sources=build_sources(retrieved_documents),
citations=[],
debug_data=debug_data,
)
if debug_mode:
debug_data["grounding"] = {
"stage": "answer",
"passed": True,
"reason": "answer_is_grounded",
"top_rerank_score": grounding["top_rerank_score"],
"retrieved_count": grounding["retrieved_count"],
"expanded_count": grounding["expanded_count"],
}
return AnswerResult(
answer=answer,
sources=build_sources(retrieved_documents),
citations=build_citations(expanded_documents, answer),
debug_data=debug_data,
)
def build_sources(documents):
return [
{
"source": doc.metadata.get("file_name", "Unknown"),
"page": doc.metadata.get("page"),
"chunk_id": doc.metadata.get("chunk_id", "unknown"),
"retrieval_score": format_score(doc.metadata.get("retrieval_score", score)),
"rerank_score": format_score(doc.metadata.get("rerank_score", score)),
"content": doc.page_content,
}
for doc, score in documents
]
def build_citations(documents, answer):
citations = build_citation_sources(documents)
used_numbers = set(extract_citation_numbers(answer))
filtered_citations = []
for item in citations:
if item["number"] not in used_numbers:
continue
item["retrieval_score"] = format_score(item.get("retrieval_score"))
item["rerank_score"] = format_score(item.get("rerank_score"))
filtered_citations.append(item)
return filtered_citations
def format_score(score):
if score is None:
return "n/a"
return f"{float(score):.4f}"
def answer_is_grounded(answer, context):
valid_sources = extract_valid_source_numbers(context)
return answer_has_valid_citations(answer, valid_sources)
def build_metadata_filter(
selected_file="",
selected_file_type="",
page_start="",
page_end="",
):
metadata_filter = {}
if selected_file:
metadata_filter["file_name"] = selected_file
if selected_file_type:
metadata_filter["file_type"] = selected_file_type
page_range = build_page_range(page_start, page_end)
if page_range is not None:
metadata_filter["page_range"] = page_range
return metadata_filter or None
def build_page_range(page_start, page_end):
start = parse_page_number(page_start)
end = parse_page_number(page_end)
if start is None and end is None:
return None
if start is not None and end is not None and start > end:
start, end = end, start
return {"start": start, "end": end}
def parse_page_number(value):
if value in (None, ""):
return None
page_number = int(str(value).strip())
if page_number < 1:
raise ValueError("Page filters must be 1 or greater.")
return page_number