local-rag-chatbot / utils.py
Julien Simon
refactor: Simplify codebase and consolidate tests
b39a3c2
Raw
History Blame Contribute Delete
5.29 kB
"""Utility functions for formatting and chat history."""
import os
from langchain_core.messages import AIMessage, HumanMessage
from config import CHAT_HISTORY_LIMIT
def get_document_source(doc):
"""Extract source filename from document metadata.
Args:
doc: Document object with metadata
Returns:
str: Source filename or "Unknown"
"""
return os.path.basename(doc.metadata.get("source", "Unknown"))
def get_top_chunk_index(docs_with_scores):
"""Find index of best-scoring document.
Handles both similarity scores (higher is better, <=1.0)
and distance scores (lower is better, >1.0).
Args:
docs_with_scores: List of (doc, score) tuples
Returns:
int: Index of best-scoring document, or 0 if empty/no scores
"""
if not docs_with_scores:
return 0
best_idx, best_score = 0, float("-inf")
for i, (_, score) in enumerate(docs_with_scores):
if score is None:
continue
normalized = score if score <= 1.0 else -score
if normalized > best_score:
best_score, best_idx = normalized, i
return best_idx
def format_chat_history(chat_history, limit=CHAT_HISTORY_LIMIT):
"""Format chat history for inclusion in prompts.
Args:
chat_history: List of message tuples, dicts, or Message objects
limit: Maximum number of recent messages to include
Returns:
str: Formatted chat history string
"""
if not chat_history:
return ""
history_parts = []
for msg in chat_history[-limit:]:
if isinstance(msg, tuple):
history_parts.append(f"Human: {msg[0]}\nAssistant: {msg[1]}")
elif isinstance(msg, dict):
# OpenAI-style message format
role = "Human" if msg.get("role") == "user" else "Assistant"
history_parts.append(f"{role}: {msg.get('content', '')}")
elif isinstance(msg, HumanMessage):
history_parts.append(f"Human: {msg.content}")
elif isinstance(msg, AIMessage):
history_parts.append(f"Assistant: {msg.content}")
return "\n".join(history_parts)
def messages_to_tuples(messages):
"""Convert OpenAI-style messages to tuples for the QA chain.
Args:
messages: List of dicts with 'role' and 'content' keys
Returns:
List of (user, assistant) tuples
"""
tuples = []
user_msg = None
for msg in messages:
if msg["role"] == "user":
user_msg = msg["content"]
elif msg["role"] == "assistant" and user_msg is not None:
tuples.append((user_msg, msg["content"]))
user_msg = None
return tuples
def format_context_with_highlight(
source_documents,
docs_with_scores=None,
rewritten_query=None,
hybrid_scores=None,
):
"""Format context with highlighting for the top matching chunk.
Args:
source_documents: List of document chunks
docs_with_scores: Optional list of (doc, score) tuples
rewritten_query: Optional rewritten query string
hybrid_scores: Optional list of (doc, fused_score, semantic_score, keyword_score) tuples
Returns:
Formatted context markdown string with highlighting for top chunk and sources list
"""
if not source_documents:
return ""
# Show rewritten query if available (compact)
query_info = ""
if rewritten_query:
query_info = f"**πŸ”„ Rewritten:** `{rewritten_query}`\n\n"
# Get unique sources for summary (compact) with hyperlinks
seen_sources = set()
sources_list = []
for doc in source_documents:
source = get_document_source(doc)
page = doc.metadata.get("page", "unknown")
source_key = f"{source}:{page}"
if source_key not in seen_sources:
sources_list.append(f"`{source}` (p.{page})")
seen_sources.add(source_key)
# Identify top chunk
top_chunk_idx = get_top_chunk_index(docs_with_scores)
# Compact sources header
sources_header = f"**Sources:** {', '.join(sources_list)}\n\n---\n\n"
# Format chunks more compactly
formatted_chunks = []
for i, doc in enumerate(source_documents):
source = get_document_source(doc)
page = doc.metadata.get("page", "unknown")
content = doc.page_content
# Compact header
is_top = i == top_chunk_idx
star = "⭐ " if is_top else ""
header = f"**{star}{source}** (page {page})"
# Add score info (compact)
score_info = ""
if hybrid_scores and i < len(hybrid_scores):
_, fused, sem, kw = hybrid_scores[i]
score_info = f" *[f:{fused:.2f} s:{sem:.2f} k:{kw:.2f}]*"
elif (
docs_with_scores
and i < len(docs_with_scores)
and docs_with_scores[i][1] is not None
):
score = docs_with_scores[i][1]
if score <= 1.0:
score_info = f" *[rel:{score:.3f}]*"
else:
score_info = f" *[dist:{score:.3f}]*"
# Compact content display
chunk_text = f"{header}{score_info}\n\n{content}\n\n---"
formatted_chunks.append(chunk_text)
return query_info + sources_header + "\n\n".join(formatted_chunks)