Source code for evidence_fetcher.models
"""Public data models for normalized evidence results and feedback."""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import asdict, dataclass, replace
from enum import StrEnum
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from evidence_fetcher.ranking import FeedbackAwareEvidenceRanker
[docs]
@dataclass(frozen=True, slots=True)
class Evidence:
"""A normalized search result returned by evidence-fetcher."""
title: str
url: str
snippet: str
source: str
rank: int | None = None
def to_dict(self) -> dict[str, object]:
"""Return a JSON-serializable representation of the evidence."""
return asdict(self)
[docs]
class FeedbackLabel(StrEnum):
"""A relevance judgment for an evidence result."""
MORE = "more"
RELEVANT = "relevant"
LESS = "less"
IRRELEVANT = "irrelevant"
UNSURE = "unsure"
[docs]
@dataclass(frozen=True, slots=True)
class RelevanceFeedback:
"""User feedback for one evidence result."""
url: str
label: FeedbackLabel
[docs]
@dataclass(frozen=True, slots=True)
class SearchSession:
"""State needed for a future retrieve, rank, feedback, rerank workflow."""
query: str
candidates: tuple[Evidence, ...]
ranked_results: tuple[Evidence, ...]
feedback: tuple[RelevanceFeedback, ...] = ()
def add_feedback(self, feedback: RelevanceFeedback) -> SearchSession:
"""Return a session with one validated feedback entry appended."""
resolved_url = _resolve_evidence_url(feedback.url, self.candidates)
if resolved_url is None:
raise ValueError("feedback URL is not present in the search session")
resolved_feedback = replace(feedback, url=resolved_url)
return replace(self, feedback=(*self.feedback, resolved_feedback))
def with_feedback(self, url: str, label: FeedbackLabel) -> SearchSession:
"""Return a session with feedback for the evidence identified by URL."""
return self.add_feedback(RelevanceFeedback(url=url, label=label))
def rerank(
self,
ranker: FeedbackAwareEvidenceRanker | None = None,
) -> SearchSession:
"""Return a session with ranked results revised by accumulated feedback."""
from evidence_fetcher.ranking import TfidfEvidenceRanker
evidence_ranker = ranker if ranker is not None else TfidfEvidenceRanker()
ranked = evidence_ranker.rerank(self.query, self.candidates, self.feedback)
return replace(self, ranked_results=tuple(ranked))
def with_evidence(self, evidence: Sequence[Evidence]) -> SearchSession:
"""Return a session with new evidence merged by canonical URL."""
seen_urls = {_canonical_url_key(item.url) for item in self.candidates}
candidates = list(self.candidates)
ranked_results = list(self.ranked_results)
for item in evidence:
key = _canonical_url_key(item.url)
if key in seen_urls:
continue
seen_urls.add(key)
candidates.append(item)
ranked_results.append(item)
return replace(
self,
candidates=tuple(candidates),
ranked_results=tuple(ranked_results),
)
def _canonical_url_key(url: str) -> str:
return url.rstrip("/").casefold()
def _resolve_evidence_url(url: str, evidence: Sequence[Evidence]) -> str | None:
key = _canonical_url_key(url)
for item in evidence:
if _canonical_url_key(item.url) == key:
return item.url
return None