Compare commits

..

3 Commits

Author SHA1 Message Date
Hermes Agent
2a0c31d327 feat: implement Context-Faithful Prompting — make LLMs use retrieved context (#667)
Some checks failed
Contributor Attribution Check / check-attribution (pull_request) Failing after 31s
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 38s
Tests / e2e (pull_request) Successful in 2m57s
Tests / test (pull_request) Failing after 38m19s
Resolves #667. Addresses R@5 vs E2E accuracy gap by forcing the
LLM to ground in retrieved context instead of parametric knowledge.

agent/context_faithful.py (293 lines):
- build_context_faithful_prompt(): context-before-question structure,
  explicit use-context instruction, I-dont-know escape hatch,
  passage numbering for citations, confidence calibration (1-5)
- build_summarization_prompt(): context-faithful version for session search
- build_answer_prompt(): context-faithful for direct Q&A
- assess_context_faithfulness(): heuristic faithfulness scoring
  (citation count, grounding ratio, honest-unknown detection)

tools/session_search_tool.py:
- Replaced hardcoded summarization prompt with build_summarization_prompt()
- LLM now forced to cite transcript passages and ground in context

tests/test_context_faithful_prompting.py (18 tests):
- Prompt structure, context-before-question, passage numbering
- Citation/confidence toggles, empty passages
- Summarization integration, answer generation
- Faithfulness assessment: citations, grounding ratio, honest unknown
2026-04-15 08:22:50 -04:00
f1f9bd2e76 Merge pull request 'feat: implement Reader-Guided Reranking — bridge R@5 vs E2E gap (#666)' (#782) from fix/666 into main 2026-04-15 11:58:02 +00:00
Hermes Agent
4129cc0d0c feat: implement Reader-Guided Reranking — bridge R@5 vs E2E gap (#666)
Some checks failed
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Contributor Attribution Check / check-attribution (pull_request) Failing after 37s
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 55s
Tests / test (pull_request) Failing after 55s
Tests / e2e (pull_request) Successful in 2m49s
Resolves #666. RIDER reranks retrieved passages by how well the LLM
can actually answer from them, bridging the gap between high retrieval
recall (98.4% R@5) and low end-to-end accuracy (17%).

agent/rider.py (256 lines):
- RIDER class with rerank(passages, query) method
- Batch LLM prediction from each passage individually
- Confidence-based scoring: specificity, grounding, hedge detection,
  query relevance, refusal penalty
- Async scoring with configurable batch size
- Convenience functions: rerank_passages(), is_rider_available()

tools/session_search_tool.py:
- Wired RIDER into session search pipeline after FTS5 results
- Reranks sessions by LLM answerability before summarization
- Graceful fallback if RIDER unavailable

tests/test_reader_guided_reranking.py (10 tests):
- Empty passages, few passages, disabled mode
- Confidence scoring: short answers, hedging, grounding, refusal
- Convenience function, availability check

Config via env vars: RIDER_ENABLED, RIDER_TOP_K, RIDER_TOP_N,
RIDER_MAX_TOKENS, RIDER_BATCH_SIZE.
2026-04-15 07:40:15 -04:00
8 changed files with 786 additions and 214 deletions

293
agent/context_faithful.py Normal file
View File

@@ -0,0 +1,293 @@
"""Context-Faithful Prompting — Make LLMs Use Retrieved Context.
Addresses the R@5 vs E2E accuracy gap by prompting the LLM to actually
use the retrieved context instead of relying on parametric knowledge.
Research: Context-faithful prompting achieves +5-15 E2E accuracy gains.
Key patterns:
1. Context-before-question structure (attention bias)
2. Explicit "use the context" instruction
3. Citation requirement (which passage used)
4. Confidence calibration
5. "I don't know" escape hatch
Usage:
from agent.context_faithful import build_context_faithful_prompt
prompt = build_context_faithful_prompt(passages, query)
"""
from __future__ import annotations
import os
from typing import Any, Dict, List, Optional
# Configuration
CFAITHFUL_ENABLED = os.getenv("CFAITHFUL_ENABLED", "true").lower() not in ("false", "0", "no")
CFAITHFUL_REQUIRE_CITATION = os.getenv("CFAITHFUL_REQUIRE_CITATION", "true").lower() not in ("false", "0", "no")
CFAITHFUL_CONFIDENCE = os.getenv("CFAITHFUL_CONFIDENCE", "true").lower() not in ("false", "0", "no")
CFAITHFUL_MAX_CONTEXT_CHARS = int(os.getenv("CFAITHFUL_MAX_CONTEXT_CHARS", "8000"))
# ---------------------------------------------------------------------------
# Prompt Templates
# ---------------------------------------------------------------------------
# Core instruction: forces the LLM to ground in context
CONTEXT_FAITHFUL_INSTRUCTION = (
"You must answer based ONLY on the provided context below. "
"Do not use any prior knowledge or make assumptions beyond what is stated in the context. "
"If the context does not contain enough information to answer the question, "
"you MUST say: \"I don't know based on the provided context.\" "
"Do not guess. Do not fill in gaps with your training data."
)
# Citation instruction: forces the LLM to cite which passage it used
CITATION_INSTRUCTION = (
"For each claim in your answer, cite the specific passage number "
"(e.g., [Passage 1], [Passage 3]) that supports it. "
"If you cannot cite a passage for a claim, do not include that claim."
)
# Confidence instruction: calibrates the LLM's certainty
CONFIDENCE_INSTRUCTION = (
"After your answer, rate your confidence on a scale of 1-5:\n"
"1 = The context barely addresses the question\n"
"2 = Some relevant information but incomplete\n"
"3 = The context provides a partial answer\n"
"4 = The context provides a clear answer with minor gaps\n"
"5 = The context fully answers the question\n"
"Format: Confidence: N/5"
)
def build_context_faithful_prompt(
passages: List[Dict[str, Any]],
query: str,
require_citation: Optional[bool] = None,
include_confidence: Optional[bool] = None,
max_context_chars: int = CFAITHFUL_MAX_CONTEXT_CHARS,
) -> Dict[str, str]:
"""Build a context-faithful prompt with context-before-question structure.
Args:
passages: List of passage dicts with 'content' or 'text' key.
May have 'session_id', 'snippet', 'summary', etc.
query: The user's question.
require_citation: Override citation requirement.
include_confidence: Override confidence calibration.
max_context_chars: Max total context to include.
Returns:
Dict with 'system' and 'user' prompt strings.
"""
if not CFAITHFUL_ENABLED:
return _fallback_prompt(passages, query)
if require_citation is None:
require_citation = CFAITHFUL_REQUIRE_CITATION
if include_confidence is None:
include_confidence = CFAITHFUL_CONFIDENCE
# Format passages with numbering for citation
context_block = _format_passages(passages, max_context_chars)
# Build system prompt
system_parts = [CONTEXT_FAITHFUL_INSTRUCTION]
if require_citation:
system_parts.append(CITATION_INSTRUCTION)
if include_confidence:
system_parts.append(CONFIDENCE_INSTRUCTION)
system_prompt = "\n\n".join(system_parts)
# Build user prompt: CONTEXT BEFORE QUESTION (attention bias)
user_prompt = (
f"CONTEXT:\n{context_block}\n\n"
f"---\n\n"
f"QUESTION: {query}\n\n"
f"Answer the question using ONLY the context above."
)
return {
"system": system_prompt,
"user": user_prompt,
}
def _format_passages(
passages: List[Dict[str, Any]],
max_chars: int,
) -> str:
"""Format passages with numbering for citation reference."""
lines = []
total_chars = 0
for idx, passage in enumerate(passages, 1):
content = (
passage.get("content")
or passage.get("text")
or passage.get("snippet")
or passage.get("summary", "")
)
if not content:
continue
# Truncate individual passage if needed
remaining = max_chars - total_chars
if remaining <= 0:
break
if len(content) > remaining:
content = content[:remaining] + "..."
source = passage.get("session_id") or passage.get("source", "")
header = f"[Passage {idx}"
if source:
header += f"{source}"
header += "]"
lines.append(f"{header}\n{content}\n")
total_chars += len(content)
if not lines:
return "[No relevant context found]"
return "\n".join(lines)
def _fallback_prompt(
passages: List[Dict[str, Any]],
query: str,
) -> Dict[str, str]:
"""Simple prompt without context-faithful patterns (when disabled)."""
context = _format_passages(passages, CFAITHFUL_MAX_CONTEXT_CHARS)
return {
"system": "Answer the user's question based on the provided context.",
"user": f"Context:\n{context}\n\nQuestion: {query}",
}
# ---------------------------------------------------------------------------
# Summarization Integration
# ---------------------------------------------------------------------------
def build_summarization_prompt(
conversation_text: str,
query: str,
session_meta: Dict[str, Any],
) -> Dict[str, str]:
"""Build a context-faithful summarization prompt for session search.
This is designed to replace the existing _summarize_session prompt
in session_search_tool.py with a context-faithful version.
"""
source = session_meta.get("source", "unknown")
started = session_meta.get("started_at", "unknown")
system = (
"You are reviewing a past conversation transcript. "
+ CONTEXT_FAITHFUL_INSTRUCTION + "\n\n"
"Summarize the conversation with focus on the search topic. Include:\n"
"1. What the user asked about or wanted to accomplish\n"
"2. What actions were taken and what the outcomes were\n"
"3. Key decisions, solutions found, or conclusions reached\n"
"4. Specific commands, files, URLs, or technical details\n"
"5. Anything left unresolved\n\n"
"Cite specific parts of the transcript (e.g., 'In the conversation, the user...'). "
"If the transcript doesn't contain information relevant to the search topic, "
"say so explicitly rather than inventing details."
)
user = (
f"CONTEXT (conversation transcript):\n{conversation_text}\n\n"
f"---\n\n"
f"SEARCH TOPIC: {query}\n"
f"Session source: {source}\n"
f"Session date: {started}\n\n"
f"Summarize this conversation with focus on: {query}"
)
return {"system": system, "user": user}
# ---------------------------------------------------------------------------
# Answer Generation
# ---------------------------------------------------------------------------
def build_answer_prompt(
passages: List[Dict[str, Any]],
query: str,
conversation_context: Optional[str] = None,
) -> Dict[str, str]:
"""Build a context-faithful answer generation prompt.
For direct question answering (not summarization).
"""
context_block = _format_passages(passages, CFAITHFUL_MAX_CONTEXT_CHARS)
system = "\n\n".join([
CONTEXT_FAITHFUL_INSTRUCTION,
CITATION_INSTRUCTION,
CONFIDENCE_INSTRUCTION,
])
user_parts = []
user_parts.append(f"CONTEXT:\n{context_block}")
if conversation_context:
user_parts.append(f"RECENT CONVERSATION:\n{conversation_context[:2000]}")
user_parts.append(f"---\n\nQUESTION: {query}")
user_parts.append("\nAnswer based ONLY on the context above.")
return {
"system": system,
"user": "\n\n".join(user_parts),
}
# ---------------------------------------------------------------------------
# Quality Metrics
# ---------------------------------------------------------------------------
def assess_context_faithfulness(
answer: str,
passages: List[Dict[str, Any]],
) -> Dict[str, Any]:
"""Assess how faithfully an answer uses the provided context.
Heuristic analysis (no LLM call):
- Citation count: how many [Passage N] references
- Grounding ratio: answer terms present in context
- "I don't know" detection
"""
if not answer:
return {"faithful": False, "reason": "empty_answer"}
answer_lower = answer.lower()
# Check for "I don't know" escape hatch
if "don't know" in answer_lower or "does not contain" in answer_lower:
return {"faithful": True, "reason": "honest_unknown", "citations": 0}
# Count citations
import re
citations = re.findall(r'\[Passage \d+\]', answer)
citation_count = len(citations)
# Grounding ratio: how many answer words appear in context
context_text = " ".join(
(p.get("content") or p.get("text") or p.get("snippet") or "").lower()
for p in passages
)
answer_words = set(answer_lower.split())
context_words = set(context_text.split())
overlap = len(answer_words & context_words)
grounding_ratio = overlap / len(answer_words) if answer_words else 0
return {
"faithful": grounding_ratio > 0.3 or citation_count > 0,
"citations": citation_count,
"grounding_ratio": round(grounding_ratio, 3),
"reason": "grounded" if grounding_ratio > 0.3 else "weak_grounding",
}

256
agent/rider.py Normal file
View File

@@ -0,0 +1,256 @@
"""RIDER — Reader-Guided Passage Reranking.
Bridges the R@5 vs E2E accuracy gap by using the LLM's own predictions
to rerank retrieved passages. Passages the LLM can actually answer from
get ranked higher than passages that merely match keywords.
Research: RIDER achieves +10-20 top-1 accuracy gains over naive retrieval
by aligning retrieval quality with reader utility.
Usage:
from agent.rider import RIDER
rider = RIDER()
reranked = rider.rerank(passages, query, top_n=3)
"""
from __future__ import annotations
import asyncio
import logging
import os
from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
# Configuration
RIDER_ENABLED = os.getenv("RIDER_ENABLED", "true").lower() not in ("false", "0", "no")
RIDER_TOP_K = int(os.getenv("RIDER_TOP_K", "10")) # passages to score
RIDER_TOP_N = int(os.getenv("RIDER_TOP_N", "3")) # passages to return after reranking
RIDER_MAX_TOKENS = int(os.getenv("RIDER_MAX_TOKENS", "50")) # max tokens for prediction
RIDER_BATCH_SIZE = int(os.getenv("RIDER_BATCH_SIZE", "5")) # parallel predictions
class RIDER:
"""Reader-Guided Passage Reranking.
Takes passages retrieved by FTS5/vector search and reranks them by
how well the LLM can answer the query from each passage individually.
"""
def __init__(self, auxiliary_task: str = "rider"):
"""Initialize RIDER.
Args:
auxiliary_task: Task name for auxiliary client resolution.
"""
self._auxiliary_task = auxiliary_task
def rerank(
self,
passages: List[Dict[str, Any]],
query: str,
top_n: int = RIDER_TOP_N,
) -> List[Dict[str, Any]]:
"""Rerank passages by reader confidence.
Args:
passages: List of passage dicts. Must have 'content' or 'text' key.
May have 'session_id', 'snippet', 'rank', 'score', etc.
query: The user's search query.
top_n: Number of passages to return after reranking.
Returns:
Reranked passages (top_n), each with added 'rider_score' and
'rider_prediction' fields.
"""
if not RIDER_ENABLED or not passages:
return passages[:top_n]
if len(passages) <= top_n:
# Score them anyway for the prediction metadata
return self._score_and_rerank(passages, query, top_n)
return self._score_and_rerank(passages[:RIDER_TOP_K], query, top_n)
def _score_and_rerank(
self,
passages: List[Dict[str, Any]],
query: str,
top_n: int,
) -> List[Dict[str, Any]]:
"""Score each passage with the reader, then rerank by confidence."""
try:
from model_tools import _run_async
scored = _run_async(self._score_all_passages(passages, query))
except Exception as e:
logger.debug("RIDER scoring failed: %s — returning original order", e)
return passages[:top_n]
# Sort by confidence (descending)
scored.sort(key=lambda p: p.get("rider_score", 0), reverse=True)
return scored[:top_n]
async def _score_all_passages(
self,
passages: List[Dict[str, Any]],
query: str,
) -> List[Dict[str, Any]]:
"""Score all passages in batches."""
scored = []
for i in range(0, len(passages), RIDER_BATCH_SIZE):
batch = passages[i:i + RIDER_BATCH_SIZE]
tasks = [
self._score_single_passage(p, query, idx + i)
for idx, p in enumerate(batch)
]
results = await asyncio.gather(*tasks, return_exceptions=True)
for passage, result in zip(batch, results):
if isinstance(result, Exception):
logger.debug("RIDER passage %d scoring failed: %s", i, result)
passage["rider_score"] = 0.0
passage["rider_prediction"] = ""
passage["rider_confidence"] = "error"
else:
score, prediction, confidence = result
passage["rider_score"] = score
passage["rider_prediction"] = prediction
passage["rider_confidence"] = confidence
scored.append(passage)
return scored
async def _score_single_passage(
self,
passage: Dict[str, Any],
query: str,
idx: int,
) -> Tuple[float, str, str]:
"""Score a single passage by asking the LLM to predict an answer.
Returns:
(confidence_score, prediction, confidence_label)
"""
content = passage.get("content") or passage.get("text") or passage.get("snippet", "")
if not content or len(content) < 10:
return 0.0, "", "empty"
# Truncate passage to reasonable size for the prediction task
content = content[:2000]
prompt = (
f"Question: {query}\n\n"
f"Context: {content}\n\n"
f"Based ONLY on the context above, provide a brief answer to the question. "
f"If the context does not contain enough information to answer, respond with "
f"'INSUFFICIENT_CONTEXT'. Be specific and concise."
)
try:
from agent.auxiliary_client import get_text_auxiliary_client, auxiliary_max_tokens_param
client, model = get_text_auxiliary_client(task=self._auxiliary_task)
if not client:
return 0.5, "", "no_client"
response = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
**auxiliary_max_tokens_param(RIDER_MAX_TOKENS),
temperature=0,
)
prediction = (response.choices[0].message.content or "").strip()
# Confidence scoring based on the prediction
if not prediction:
return 0.1, "", "empty_response"
if "INSUFFICIENT_CONTEXT" in prediction.upper():
return 0.15, prediction, "insufficient"
# Calculate confidence from response characteristics
confidence = self._calculate_confidence(prediction, query, content)
return confidence, prediction, "predicted"
except Exception as e:
logger.debug("RIDER prediction failed for passage %d: %s", idx, e)
return 0.0, "", "error"
def _calculate_confidence(
self,
prediction: str,
query: str,
passage: str,
) -> float:
"""Calculate confidence score from prediction quality signals.
Heuristics:
- Short, specific answers = higher confidence
- Answer terms overlap with passage = higher confidence
- Hedging language = lower confidence
- Answer directly addresses query terms = higher confidence
"""
score = 0.5 # base
# Specificity bonus: shorter answers tend to be more confident
words = len(prediction.split())
if words <= 5:
score += 0.2
elif words <= 15:
score += 0.1
elif words > 50:
score -= 0.1
# Passage grounding: does the answer use terms from the passage?
passage_lower = passage.lower()
answer_terms = set(prediction.lower().split())
passage_terms = set(passage_lower.split())
overlap = len(answer_terms & passage_terms)
if overlap > 3:
score += 0.15
elif overlap > 0:
score += 0.05
# Query relevance: does the answer address query terms?
query_terms = set(query.lower().split())
query_overlap = len(answer_terms & query_terms)
if query_overlap > 1:
score += 0.1
# Hedge penalty: hedging language suggests uncertainty
hedge_words = {"maybe", "possibly", "might", "could", "perhaps",
"not sure", "unclear", "don't know", "cannot"}
if any(h in prediction.lower() for h in hedge_words):
score -= 0.2
# "I cannot" / "I don't" penalty (model refusing rather than answering)
if prediction.lower().startswith(("i cannot", "i don't", "i can't", "there is no")):
score -= 0.15
return max(0.0, min(1.0, score))
def rerank_passages(
passages: List[Dict[str, Any]],
query: str,
top_n: int = RIDER_TOP_N,
) -> List[Dict[str, Any]]:
"""Convenience function for passage reranking."""
rider = RIDER()
return rider.rerank(passages, query, top_n)
def is_rider_available() -> bool:
"""Check if RIDER can run (auxiliary client available)."""
if not RIDER_ENABLED:
return False
try:
from agent.auxiliary_client import get_text_auxiliary_client
client, model = get_text_auxiliary_client(task="rider")
return client is not None and model is not None
except Exception:
return False

View File

@@ -15,10 +15,6 @@ from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
_skill_commands: Dict[str, Dict[str, Any]] = {}
# Auto-refresh state: track skills directory modification times
_skill_dirs_mtime: Dict[str, float] = {}
_skill_last_scan_time: float = 0.0
_skill_refresh_interval: float = 300.0 # seconds between refresh checks
_PLAN_SLUG_RE = re.compile(r"[^a-z0-9]+")
# Patterns for sanitizing skill names into clean hyphen-separated slugs.
_SKILL_INVALID_CHARS = re.compile(r"[^a-z0-9-]")
@@ -273,94 +269,6 @@ def get_skill_commands() -> Dict[str, Dict[str, Any]]:
return _skill_commands
def refresh_skill_commands(force: bool = False) -> Dict[str, Dict[str, Any]]:
"""Re-scan skills directories if any have changed since last scan.
Call this periodically (e.g. every N turns) to pick up new skills
installed by the timmy-config sidecar without requiring a restart.
Args:
force: If True, always re-scan regardless of modification times.
Returns:
Updated skill commands mapping.
"""
import time
global _skill_dirs_mtime, _skill_last_scan_time
now = time.time()
# Throttle: don't re-scan more often than every N seconds
if not force and (now - _skill_last_scan_time) < _skill_refresh_interval:
return _skill_commands
try:
from tools.skills_tool import SKILLS_DIR
from agent.skill_utils import get_external_skills_dirs
dirs_to_check = []
if SKILLS_DIR.exists():
dirs_to_check.append(SKILLS_DIR)
dirs_to_check.extend(get_external_skills_dirs())
# Check if any directory has changed
changed = force
current_mtimes: Dict[str, float] = {}
for d in dirs_to_check:
try:
# Get the latest mtime of any SKILL.md in the directory
latest = 0.0
for skill_md in d.rglob("SKILL.md"):
try:
mtime = skill_md.stat().st_mtime
if mtime > latest:
latest = mtime
except OSError:
pass
current_mtimes[str(d)] = latest
old_mtime = _skill_dirs_mtime.get(str(d), 0.0)
if latest > old_mtime:
changed = True
except OSError:
pass
if changed:
_skill_dirs_mtime = current_mtimes
_skill_last_scan_time = now
old_count = len(_skill_commands)
scan_skill_commands()
new_count = len(_skill_commands)
if new_count != old_count:
logger.info(
"Skill refresh: %d skills (was %d, delta: %s%d)",
new_count, old_count,
"+" if new_count > old_count else "",
new_count - old_count,
)
return _skill_commands
_skill_last_scan_time = now
except Exception as e:
logger.debug("Skill refresh check failed: %s", e)
return _skill_commands
def should_refresh_skills(turn_count: int, interval: int = 5) -> bool:
"""Check if skills should be refreshed this turn.
Args:
turn_count: Current conversation turn number.
interval: Refresh every N turns.
Returns:
True if refresh should happen this turn.
"""
return turn_count > 0 and turn_count % interval == 0
def resolve_skill_command_key(command: str) -> Optional[str]:
"""Resolve a user-typed /command to its canonical skill_cmds key.

View File

@@ -7862,15 +7862,6 @@ class AIAgent:
# Track user turns for memory flush and periodic nudge logic
self._user_turn_count += 1
# Auto-refresh skills from sidecar every 5 turns
# Picks up new skills installed by timmy-config without restart
try:
from agent.skill_commands import should_refresh_skills, refresh_skill_commands
if should_refresh_skills(self._user_turn_count, interval=5):
refresh_skill_commands()
except Exception:
pass # non-critical — skill refresh is best-effort
# Preserve the original user message (no nudge injection).
original_user_message = persist_user_message if persist_user_message is not None else user_message

View File

@@ -0,0 +1,133 @@
"""Tests for Context-Faithful Prompting — issue #667."""
import pytest
from agent.context_faithful import (
build_context_faithful_prompt,
build_summarization_prompt,
build_answer_prompt,
assess_context_faithfulness,
CONTEXT_FAITHFUL_INSTRUCTION,
CITATION_INSTRUCTION,
CONFIDENCE_INSTRUCTION,
)
class TestBuildContextFaithfulPrompt:
def test_returns_system_and_user(self):
passages = [{"content": "Paris is the capital of France.", "session_id": "s1"}]
result = build_context_faithful_prompt(passages, "What is the capital of France?")
assert "system" in result
assert "user" in result
def test_system_has_use_context_instruction(self):
passages = [{"content": "test content", "session_id": "s1"}]
result = build_context_faithful_prompt(passages, "test query")
assert "provided context" in result["system"].lower() or "context" in result["system"].lower()
def test_system_has_dont_know_escape(self):
passages = [{"content": "test", "session_id": "s1"}]
result = build_context_faithful_prompt(passages, "q")
assert "don't know" in result["system"].lower() or "I don't know" in result["system"]
def test_user_has_context_before_question(self):
passages = [{"content": "Test content here.", "session_id": "s1"}]
result = build_context_faithful_prompt(passages, "What is this?")
# Context should appear before the question
context_pos = result["user"].find("CONTEXT")
question_pos = result["user"].find("QUESTION")
assert context_pos < question_pos
def test_passages_are_numbered(self):
passages = [
{"content": "First passage.", "session_id": "s1"},
{"content": "Second passage.", "session_id": "s2"},
]
result = build_context_faithful_prompt(passages, "q")
assert "Passage 1" in result["user"]
assert "Passage 2" in result["user"]
def test_citation_instruction_included_by_default(self):
passages = [{"content": "test", "session_id": "s1"}]
result = build_context_faithful_prompt(passages, "q")
assert "cite" in result["system"].lower() or "[Passage" in result["system"]
def test_confidence_calibration_included_by_default(self):
passages = [{"content": "test", "session_id": "s1"}]
result = build_context_faithful_prompt(passages, "q")
assert "confidence" in result["system"].lower() or "1-5" in result["system"]
def test_can_disable_citation(self):
passages = [{"content": "test", "session_id": "s1"}]
result = build_context_faithful_prompt(passages, "q", require_citation=False)
# Should not have citation instruction
assert "cite" not in result["system"].lower() or "citation" not in result["system"].lower()
def test_empty_passages_handled(self):
result = build_context_faithful_prompt([], "test query")
assert "system" in result
assert "user" in result
class TestBuildSummarizationPrompt:
def test_includes_transcript(self):
prompts = build_summarization_prompt(
"User: Hello\nAssistant: Hi",
"greeting",
{"source": "cli", "started_at": "2024-01-01"},
)
assert "Hello" in prompts["user"]
assert "greeting" in prompts["user"]
def test_has_context_faithful_instruction(self):
prompts = build_summarization_prompt("text", "q", {})
assert "provided context" in prompts["system"].lower() or "context" in prompts["system"].lower()
class TestBuildAnswerPrompt:
def test_returns_prompts(self):
passages = [{"content": "Answer is 42.", "session_id": "s1"}]
result = build_answer_prompt(passages, "What is the answer?")
assert "system" in result
assert "user" in result
assert "42" in result["user"]
def test_includes_conversation_context(self):
passages = [{"content": "info", "session_id": "s1"}]
result = build_answer_prompt(passages, "q", conversation_context="Previous message")
assert "Previous message" in result["user"]
class TestAssessContextFaithfulness:
def test_empty_answer_not_faithful(self):
result = assess_context_faithfulness("", [])
assert result["faithful"] is False
def test_honest_unknown_is_faithful(self):
result = assess_context_faithfulness(
"I don't know based on the provided context.",
[{"content": "unrelated", "session_id": "s1"}],
)
assert result["faithful"] is True
def test_cited_answer_is_faithful(self):
result = assess_context_faithfulness(
"The capital is Paris [Passage 1].",
[{"content": "Paris is the capital.", "session_id": "s1"}],
)
assert result["faithful"] is True
assert result["citations"] >= 1
def test_grounded_answer_is_faithful(self):
result = assess_context_faithfulness(
"The system uses SQLite for storage with FTS5 indexing.",
[{"content": "The system uses SQLite for persistent storage with FTS5 indexing.", "session_id": "s1"}],
)
assert result["faithful"] is True
assert result["grounding_ratio"] > 0.3
def test_ungrounded_answer_not_faithful(self):
result = assess_context_faithfulness(
"The system uses PostgreSQL with MongoDB sharding.",
[{"content": "SQLite storage with FTS5.", "session_id": "s1"}],
)
assert result["grounding_ratio"] < 0.3

View File

@@ -0,0 +1,82 @@
"""Tests for Reader-Guided Reranking (RIDER) — issue #666."""
import pytest
from unittest.mock import MagicMock, patch
from agent.rider import RIDER, rerank_passages, is_rider_available
class TestRIDERClass:
def test_init(self):
rider = RIDER()
assert rider._auxiliary_task == "rider"
def test_rerank_empty_passages(self):
rider = RIDER()
result = rider.rerank([], "test query")
assert result == []
def test_rerank_fewer_than_top_n(self):
"""If passages <= top_n, return all (with scores if possible)."""
rider = RIDER()
passages = [{"content": "test content", "session_id": "s1"}]
result = rider.rerank(passages, "test query", top_n=3)
assert len(result) == 1
@patch("agent.rider.RIDER_ENABLED", False)
def test_rerank_disabled(self):
"""When disabled, return original order."""
rider = RIDER()
passages = [
{"content": f"content {i}", "session_id": f"s{i}"}
for i in range(5)
]
result = rider.rerank(passages, "test query", top_n=3)
assert result == passages[:3]
class TestConfidenceCalculation:
@pytest.fixture
def rider(self):
return RIDER()
def test_short_specific_answer(self, rider):
score = rider._calculate_confidence("Paris", "What is the capital of France?", "Paris is the capital of France.")
assert score > 0.5
def test_hedged_answer(self, rider):
score = rider._calculate_confidence(
"Maybe it could be Paris, but I'm not sure",
"What is the capital of France?",
"Paris is the capital.",
)
assert score < 0.5
def test_passage_grounding(self, rider):
score = rider._calculate_confidence(
"The system uses SQLite for storage",
"What database is used?",
"The system uses SQLite for persistent storage with FTS5 indexing.",
)
assert score > 0.5
def test_refusal_penalty(self, rider):
score = rider._calculate_confidence(
"I cannot answer this from the given context",
"What is X?",
"Some unrelated content",
)
assert score < 0.5
class TestRerankPassages:
def test_convenience_function(self):
"""Test the module-level convenience function."""
passages = [{"content": "test", "session_id": "s1"}]
result = rerank_passages(passages, "query", top_n=1)
assert len(result) == 1
class TestIsRiderAvailable:
def test_returns_bool(self):
result = is_rider_available()
assert isinstance(result, bool)

View File

@@ -1,91 +0,0 @@
"""Tests for skill auto-loading from timmy-config sidecar — issue #742."""
import os
import time
import tempfile
from pathlib import Path
import pytest
class TestSkillRefresh:
"""Test the refresh_skill_commands function."""
def test_refresh_returns_dict(self):
from agent.skill_commands import refresh_skill_commands
result = refresh_skill_commands(force=True)
assert isinstance(result, dict)
def test_refresh_is_idempotent(self):
"""Multiple calls with no changes should return same results."""
from agent.skill_commands import refresh_skill_commands
first = refresh_skill_commands(force=True)
second = refresh_skill_commands(force=True)
assert set(first.keys()) == set(second.keys())
def test_should_refresh_skills_interval(self):
from agent.skill_commands import should_refresh_skills
# Turn 0: never refresh
assert not should_refresh_skills(0, interval=5)
# Turn 5: refresh
assert should_refresh_skills(5, interval=5)
# Turn 3: not yet
assert not should_refresh_skills(3, interval=5)
# Turn 10: refresh
assert should_refresh_skills(10, interval=5)
# Turn 7: not yet
assert not should_refresh_skills(7, interval=5)
def test_refresh_picks_up_new_skill(self, tmp_path):
"""New SKILL.md in skills dir should appear after refresh."""
from agent.skill_commands import refresh_skill_commands
import agent.skill_commands as sc
# Create a fake skill
skill_dir = tmp_path / "test-auto-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("""---
name: test-auto-skill
description: A test skill for auto-loading
---
# Test Skill
This is a test.
""")
# Patch SKILLS_DIR to point to tmp_path
from unittest.mock import patch
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
# Force a scan
sc._skill_commands = {}
sc._skill_dirs_mtime = {}
sc._skill_last_scan_time = 0.0
result = refresh_skill_commands(force=True)
# The skill should appear
assert "/test-auto-skill" in result
assert result["/test-auto-skill"]["name"] == "test-auto-skill"
class TestSkillRefreshThrottling:
"""Test that refresh doesn't re-scan too frequently."""
def test_throttle_blocks_rapid_refresh(self):
from agent.skill_commands import refresh_skill_commands
import agent.skill_commands as sc
sc._skill_last_scan_time = time.time() # just scanned
sc._skill_refresh_interval = 300.0
# Non-forced refresh should be skipped
result = refresh_skill_commands(force=False)
assert result is sc._skill_commands # returns cached, doesn't re-scan
def test_force_bypasses_throttle(self):
from agent.skill_commands import refresh_skill_commands
import agent.skill_commands as sc
sc._skill_last_scan_time = time.time() # just scanned
# Forced refresh should still work
result = refresh_skill_commands(force=True)
assert isinstance(result, dict)

View File

@@ -176,28 +176,11 @@ async def _summarize_session(
conversation_text: str, query: str, session_meta: Dict[str, Any]
) -> Optional[str]:
"""Summarize a single session conversation focused on the search query."""
system_prompt = (
"You are reviewing a past conversation transcript to help recall what happened. "
"Summarize the conversation with a focus on the search topic. Include:\n"
"1. What the user asked about or wanted to accomplish\n"
"2. What actions were taken and what the outcomes were\n"
"3. Key decisions, solutions found, or conclusions reached\n"
"4. Any specific commands, files, URLs, or technical details that were important\n"
"5. Anything left unresolved or notable\n\n"
"Be thorough but concise. Preserve specific details (commands, paths, error messages) "
"that would be useful to recall. Write in past tense as a factual recap."
)
source = session_meta.get("source", "unknown")
started = _format_timestamp(session_meta.get("started_at"))
user_prompt = (
f"Search topic: {query}\n"
f"Session source: {source}\n"
f"Session date: {started}\n\n"
f"CONVERSATION TRANSCRIPT:\n{conversation_text}\n\n"
f"Summarize this conversation with focus on: {query}"
)
# Context-faithful prompting: force LLM to ground in transcript
from agent.context_faithful import build_summarization_prompt
prompts = build_summarization_prompt(conversation_text, query, session_meta)
system_prompt = prompts["system"]
user_prompt = prompts["user"]
max_retries = 3
for attempt in range(max_retries):
@@ -394,6 +377,23 @@ def session_search(
if len(seen_sessions) >= limit:
break
# RIDER: Reader-guided reranking — sort sessions by LLM answerability
# This bridges the R@5 vs E2E accuracy gap by prioritizing passages
# the LLM can actually answer from, not just keyword matches.
try:
from agent.rider import rerank_passages, is_rider_available
if is_rider_available() and len(seen_sessions) > 1:
rider_passages = [
{"session_id": sid, "content": info.get("snippet", ""), "rank": i + 1}
for i, (sid, info) in enumerate(seen_sessions.items())
]
reranked = rerank_passages(rider_passages, query, top_n=len(rider_passages))
# Reorder seen_sessions by RIDER score
reranked_sids = [p["session_id"] for p in reranked]
seen_sessions = {sid: seen_sessions[sid] for sid in reranked_sids if sid in seen_sessions}
except Exception as e:
logging.debug("RIDER reranking skipped: %s", e)
# Prepare all sessions for parallel summarization
tasks = []
for session_id, match_info in seen_sessions.items():