Skip to content

Chains

Legacy API

The chains module is being replaced by the pluggable adapter architecture. New code should use the adapter classes in extractors/, gatherers/, verifiers/, and formatters/ packages instead. See the Adapters API Reference for the modern interface.

Claim Extraction

SimpleClaimExtractionChain

from truthfulness_evaluator.llm.chains.extraction import SimpleClaimExtractionChain

extractor = SimpleClaimExtractionChain(model="gpt-4o-mini")
claims = await extractor.extract(
    document="Python was created in 1991...",
    source_path="README.md",
    max_claims=10
)

Output: list[Claim]

Uses structured output (ClaimExtractionOutput):

class ClaimExtractionOutput(BaseModel):
    claims: list[ExtractedClaim]

class ExtractedClaim(BaseModel):
    text: str
    claim_type: str  # "explicit", "implicit", "inferred"

TripletExtractionChain

from truthfulness_evaluator.llm.chains.extraction import TripletExtractionChain

extractor = TripletExtractionChain(model="gpt-4o-mini")
claims = await extractor.extract(document, source_path)

Extracts subject-relation-object triplets.

Verification

VerificationChain

from truthfulness_evaluator.llm.chains.verification import VerificationChain

verifier = VerificationChain(model_name="gpt-4o")
result = await verifier.verify(claim, evidence)

Output: VerificationResult

Uses structured output (VerificationOutput):

class VerificationOutput(BaseModel):
    verdict: str           # "SUPPORTS", "REFUTES", "NOT_ENOUGH_INFO"
    confidence: float      # 0.0 to 1.0
    reasoning: str
    key_evidence: str | None

Consensus

ConsensusChain

from truthfulness_evaluator.llm.chains.consensus import ConsensusChain

consensus = ConsensusChain(
    model_names=["gpt-4o", "gpt-4o-mini"],
    weights={"gpt-4o": 0.6, "gpt-4o-mini": 0.4},
    confidence_threshold=0.7
)

result = await consensus.verify(claim, evidence)

Each model votes; votes are tallied with the given weights. The leading verdict is committed only if it isn't tied for the lead and its weighted agreement fraction meets confidence_threshold — otherwise the result is NOT_ENOUGH_INFO. Reported confidence is that agreement fraction, not an average of the models' self-reported scores.

Evidence Processing

EvidenceProcessor

from truthfulness_evaluator.llm.chains.evidence import EvidenceProcessor

processor = EvidenceProcessor(model="gpt-4o-mini")

# Analyze evidence relevance
evidence, summary = await processor.analyze_evidence(claim, evidence_list)

# Synthesize multiple pieces
synthesis = await processor.synthesize_evidence(claim, evidence_list)

Output: Analyzed evidence with scores + summary text

Uses structured output (EvidenceAnalysisOutput):

class EvidenceAnalysisOutput(BaseModel):
    evidence_analysis: list[EvidenceAnalysisItem]
    summary: str

class EvidenceAnalysisItem(BaseModel):
    index: int
    relevance: float
    supports: bool | None
    credibility: float
    reasoning: str

Custom Chains

Build your own:

from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI

# Define output structure
class MyOutput(BaseModel):
    result: str
    confidence: float

# Create chain
prompt = ChatPromptTemplate.from_template("Verify: {claim}")
llm = ChatOpenAI(model="gpt-4o").with_structured_output(MyOutput)
chain = prompt | llm

# Use
result = await chain.ainvoke({"claim": "Python was created in 1991"})

API Reference

Claim Extraction

truthfulness_evaluator.llm.chains.extraction.SimpleClaimExtractionChain

Fallback claim extraction using structured LLM outputs.

Source code in src/truthfulness_evaluator/llm/chains/extraction.py
class SimpleClaimExtractionChain:
    """Fallback claim extraction using structured LLM outputs."""

    def __init__(self, model: str = "gpt-4o-mini"):
        self.model = model
        self._llm = None

    @property
    def llm(self):
        """Lazy initialization of LLM with structured output."""
        if self._llm is None:
            base_llm = create_chat_model(self.model, temperature=0)
            # Use structured output
            self._llm = base_llm.with_structured_output(ClaimExtractionOutput)
        return self._llm

    async def extract(
        self, document: str, source_path: str, max_claims: Optional[int] = None
    ) -> list[Claim]:
        """Extract claims using structured LLM output."""
        # Strip example blocks before extraction to avoid false positives
        # from illustrative content in fenced code blocks and inline code.
        cleaned_document = strip_example_blocks(document)

        chain = CLAIM_EXTRACTION_PROMPT | self.llm

        try:
            result: ClaimExtractionOutput = await chain.ainvoke({"text": cleaned_document})

            claims = []
            for i, extracted in enumerate(result.claims):
                if max_claims and i >= max_claims:
                    break

                if extracted.text.strip():
                    claims.append(
                        Claim(
                            id=f"claim_{i:03d}",
                            text=extracted.text.strip(),
                            source_document=source_path,
                            claim_type=(
                                extracted.claim_type
                                if extracted.claim_type in ["explicit", "implicit", "inferred"]
                                else "explicit"
                            ),
                        )
                    )

            return claims

        except Exception as e:
            logger.warning(f"Simple extraction failed: {e}")
            return []

llm property

Lazy initialization of LLM with structured output.

extract(document, source_path, max_claims=None) async

Extract claims using structured LLM output.

Source code in src/truthfulness_evaluator/llm/chains/extraction.py
async def extract(
    self, document: str, source_path: str, max_claims: Optional[int] = None
) -> list[Claim]:
    """Extract claims using structured LLM output."""
    # Strip example blocks before extraction to avoid false positives
    # from illustrative content in fenced code blocks and inline code.
    cleaned_document = strip_example_blocks(document)

    chain = CLAIM_EXTRACTION_PROMPT | self.llm

    try:
        result: ClaimExtractionOutput = await chain.ainvoke({"text": cleaned_document})

        claims = []
        for i, extracted in enumerate(result.claims):
            if max_claims and i >= max_claims:
                break

            if extracted.text.strip():
                claims.append(
                    Claim(
                        id=f"claim_{i:03d}",
                        text=extracted.text.strip(),
                        source_document=source_path,
                        claim_type=(
                            extracted.claim_type
                            if extracted.claim_type in ["explicit", "implicit", "inferred"]
                            else "explicit"
                        ),
                    )
                )

        return claims

    except Exception as e:
        logger.warning(f"Simple extraction failed: {e}")
        return []

truthfulness_evaluator.llm.chains.extraction.TripletExtractionChain

Extract claims as subject-relation-object triplets using structured output.

Source code in src/truthfulness_evaluator/llm/chains/extraction.py
class TripletExtractionChain:
    """Extract claims as subject-relation-object triplets using structured output."""

    def __init__(self, model: str = "gpt-4o-mini"):
        self.model = model
        self._llm = None

    @property
    def llm(self):
        """Lazy initialization of LLM with structured output."""
        if self._llm is None:
            base_llm = create_chat_model(self.model, temperature=0)
            self._llm = base_llm.with_structured_output(TripletExtractionOutput)
        return self._llm

    async def extract(
        self, document: str, source_path: str, max_claims: Optional[int] = None
    ) -> list[Claim]:
        """Extract claims as triplets using structured output."""
        # Strip example blocks before extraction to avoid false positives
        # from illustrative content in fenced code blocks and inline code.
        cleaned_document = strip_example_blocks(document)

        chain = TRIPLET_EXTRACTION_PROMPT | self.llm

        try:
            result: TripletExtractionOutput = await chain.ainvoke({"text": cleaned_document})

            claims = []
            for i, triplet in enumerate(result.triplets):
                if max_claims and i >= max_claims:
                    break

                claim_text = f"{triplet.subject} {triplet.relation} {triplet.object}".strip()

                if claim_text:
                    claims.append(
                        Claim(
                            id=f"claim_{i:03d}",
                            text=claim_text,
                            source_document=source_path,
                            context=triplet.context,
                            claim_type="explicit",
                        )
                    )

            return claims

        except Exception as e:
            logger.warning(f"Triplet extraction failed: {e}")
            return []

llm property

Lazy initialization of LLM with structured output.

extract(document, source_path, max_claims=None) async

Extract claims as triplets using structured output.

Source code in src/truthfulness_evaluator/llm/chains/extraction.py
async def extract(
    self, document: str, source_path: str, max_claims: Optional[int] = None
) -> list[Claim]:
    """Extract claims as triplets using structured output."""
    # Strip example blocks before extraction to avoid false positives
    # from illustrative content in fenced code blocks and inline code.
    cleaned_document = strip_example_blocks(document)

    chain = TRIPLET_EXTRACTION_PROMPT | self.llm

    try:
        result: TripletExtractionOutput = await chain.ainvoke({"text": cleaned_document})

        claims = []
        for i, triplet in enumerate(result.triplets):
            if max_claims and i >= max_claims:
                break

            claim_text = f"{triplet.subject} {triplet.relation} {triplet.object}".strip()

            if claim_text:
                claims.append(
                    Claim(
                        id=f"claim_{i:03d}",
                        text=claim_text,
                        source_document=source_path,
                        context=triplet.context,
                        claim_type="explicit",
                    )
                )

        return claims

    except Exception as e:
        logger.warning(f"Triplet extraction failed: {e}")
        return []

Verification

truthfulness_evaluator.llm.chains.verification.VerificationChain

Single-model verification chain with structured outputs.

Source code in src/truthfulness_evaluator/llm/chains/verification.py
class VerificationChain:
    """Single-model verification chain with structured outputs."""

    def __init__(self, model_name: str = "gpt-4o"):
        self.model_name = model_name
        self._llm = None

    @property
    def llm(self):
        """Lazy initialization of LLM with structured output."""
        if self._llm is None:
            base_llm = create_chat_model(self.model_name, temperature=0)
            # Use structured output
            self._llm = base_llm.with_structured_output(VerificationOutput)
        return self._llm

    async def verify(self, claim: Claim, evidence: list[Evidence]) -> VerificationResult:
        """Verify a claim against evidence using structured output."""

        # Build evidence text with relevance indicators
        if evidence:
            evidence_parts = []
            for i, e in enumerate(evidence[:4], 1):  # Top 4 evidence items
                support_indicator = ""
                if e.supports_claim is True:
                    support_indicator = "[SUPPORTS]"
                elif e.supports_claim is False:
                    support_indicator = "[REFUTES]"
                else:
                    support_indicator = "[NEUTRAL]"

                evidence_parts.append(
                    f"\n--- Evidence {i} ({e.source_type}) {support_indicator} ---\n"
                    f"Source: {e.source}\n"
                    f"Relevance: {e.relevance_score:.0%}\n"
                    f"Content: {e.content[:600]}"
                )

            evidence_text = "\n".join(evidence_parts)
        else:
            evidence_text = "No evidence provided."

        chain = VERIFICATION_PROMPT | self.llm

        try:
            result: VerificationOutput = await chain.ainvoke(
                {"claim": claim.text, "evidence": evidence_text}
            )

            # Normalize verdict
            verdict = result.verdict.upper()
            if verdict not in ["SUPPORTS", "REFUTES", "NOT_ENOUGH_INFO"]:
                verdict = "NOT_ENOUGH_INFO"

            # Combine reasoning with key evidence
            full_explanation = result.reasoning
            if result.key_evidence:
                full_explanation += f"\n\nKey evidence: {result.key_evidence}"

            # Adjust confidence based on evidence quality
            confidence = max(0.0, min(1.0, result.confidence))
            if not evidence:
                confidence = min(confidence, 0.3)

            return VerificationResult(
                claim_id=claim.id,
                verdict=verdict,
                confidence=confidence,
                evidence=evidence,
                explanation=full_explanation,
                model_votes={self.model_name: verdict},
            )

        except Exception as e:
            # Fallback result on error
            return VerificationResult(
                claim_id=claim.id,
                verdict="NOT_ENOUGH_INFO",
                confidence=0.0,
                evidence=evidence,
                explanation=f"Verification failed: {str(e)}",
                model_votes={self.model_name: "NOT_ENOUGH_INFO"},
            )

llm property

Lazy initialization of LLM with structured output.

verify(claim, evidence) async

Verify a claim against evidence using structured output.

Source code in src/truthfulness_evaluator/llm/chains/verification.py
async def verify(self, claim: Claim, evidence: list[Evidence]) -> VerificationResult:
    """Verify a claim against evidence using structured output."""

    # Build evidence text with relevance indicators
    if evidence:
        evidence_parts = []
        for i, e in enumerate(evidence[:4], 1):  # Top 4 evidence items
            support_indicator = ""
            if e.supports_claim is True:
                support_indicator = "[SUPPORTS]"
            elif e.supports_claim is False:
                support_indicator = "[REFUTES]"
            else:
                support_indicator = "[NEUTRAL]"

            evidence_parts.append(
                f"\n--- Evidence {i} ({e.source_type}) {support_indicator} ---\n"
                f"Source: {e.source}\n"
                f"Relevance: {e.relevance_score:.0%}\n"
                f"Content: {e.content[:600]}"
            )

        evidence_text = "\n".join(evidence_parts)
    else:
        evidence_text = "No evidence provided."

    chain = VERIFICATION_PROMPT | self.llm

    try:
        result: VerificationOutput = await chain.ainvoke(
            {"claim": claim.text, "evidence": evidence_text}
        )

        # Normalize verdict
        verdict = result.verdict.upper()
        if verdict not in ["SUPPORTS", "REFUTES", "NOT_ENOUGH_INFO"]:
            verdict = "NOT_ENOUGH_INFO"

        # Combine reasoning with key evidence
        full_explanation = result.reasoning
        if result.key_evidence:
            full_explanation += f"\n\nKey evidence: {result.key_evidence}"

        # Adjust confidence based on evidence quality
        confidence = max(0.0, min(1.0, result.confidence))
        if not evidence:
            confidence = min(confidence, 0.3)

        return VerificationResult(
            claim_id=claim.id,
            verdict=verdict,
            confidence=confidence,
            evidence=evidence,
            explanation=full_explanation,
            model_votes={self.model_name: verdict},
        )

    except Exception as e:
        # Fallback result on error
        return VerificationResult(
            claim_id=claim.id,
            verdict="NOT_ENOUGH_INFO",
            confidence=0.0,
            evidence=evidence,
            explanation=f"Verification failed: {str(e)}",
            model_votes={self.model_name: "NOT_ENOUGH_INFO"},
        )

Consensus

truthfulness_evaluator.llm.chains.consensus.ConsensusChain

Multi-model consensus via weighted, agreement-based voting.

Source code in src/truthfulness_evaluator/llm/chains/consensus.py
class ConsensusChain:
    """Multi-model consensus via weighted, agreement-based voting."""

    def __init__(
        self,
        model_names: list[str],
        weights: dict[str, float] | None = None,
        confidence_threshold: float = 0.7,
    ):
        self.model_names = model_names
        self.weights = weights or {m: 1.0 / len(model_names) for m in model_names}
        # Minimum weighted agreement for the leading verdict to be committed;
        # below it (or on a tie) the ensemble abstains. With N equal-weight
        # models a value of 0.7 effectively requires near-unanimity (e.g. 3
        # models must all agree, since 2/3 = 0.67 < 0.7); lower it to accept
        # simple majorities.
        self.confidence_threshold = confidence_threshold
        self._chains: list[VerificationChain] | None = None

    @property
    def chains(self) -> list[VerificationChain]:
        """Lazy initialization of the per-model verification chains."""
        if self._chains is None:
            self._chains = [VerificationChain(m) for m in self.model_names]
        return self._chains

    async def verify(self, claim: Claim, evidence: list[Evidence]) -> VerificationResult:
        """Verify a claim by polling every model and aggregating by agreement."""
        results = await asyncio.gather(*[chain.verify(claim, evidence) for chain in self.chains])

        votes = {self.model_names[i]: r.verdict for i, r in enumerate(results)}
        self_reported = {self.model_names[i]: r.confidence for i, r in enumerate(results)}

        # Weighted tally of verdicts.
        tally: Counter = Counter()
        for model, verdict in votes.items():
            tally[verdict] += self.weights.get(model, 1.0 / len(self.model_names))

        total_weight = sum(tally.values()) or 1.0
        ranked = tally.most_common()
        leading_verdict, leading_weight = ranked[0]
        agreement = leading_weight / total_weight

        # A shared top weight is unresolved agreement -> abstain.
        tie = len(ranked) > 1 and ranked[1][1] == leading_weight
        abstained = tie or agreement < self.confidence_threshold
        final_verdict = ABSTAIN if abstained else leading_verdict

        # Confidence reflects consensus strength, not the models' unreliable
        # self-reported scores.
        confidence = round(agreement, 4)

        all_evidence: list[Evidence] = []
        for r in results:
            all_evidence.extend(r.evidence)

        vote_lines = [
            f"{m}: {votes[m]} (self-reported {self_reported[m]:.0%})" for m in self.model_names
        ]
        explanation_parts = [
            f"Consensus: {final_verdict} ({agreement:.0%} weighted agreement)",
            "Model votes:",
            *vote_lines,
        ]
        if final_verdict == ABSTAIN and leading_verdict != ABSTAIN:
            reason = "tie" if tie else f"agreement below {self.confidence_threshold:.0%} threshold"
            explanation_parts.append(f"(Abstained: {reason}.)")

        logger.debug(
            "Consensus %s at %.0f%% agreement; votes=%s", final_verdict, agreement * 100, votes
        )

        return VerificationResult(
            claim_id=claim.id,
            verdict=final_verdict,
            confidence=confidence,
            evidence=all_evidence[:5],
            explanation="\n".join(explanation_parts),
            model_votes=votes,
        )

chains: list[VerificationChain] property

Lazy initialization of the per-model verification chains.

verify(claim, evidence) async

Verify a claim by polling every model and aggregating by agreement.

Source code in src/truthfulness_evaluator/llm/chains/consensus.py
async def verify(self, claim: Claim, evidence: list[Evidence]) -> VerificationResult:
    """Verify a claim by polling every model and aggregating by agreement."""
    results = await asyncio.gather(*[chain.verify(claim, evidence) for chain in self.chains])

    votes = {self.model_names[i]: r.verdict for i, r in enumerate(results)}
    self_reported = {self.model_names[i]: r.confidence for i, r in enumerate(results)}

    # Weighted tally of verdicts.
    tally: Counter = Counter()
    for model, verdict in votes.items():
        tally[verdict] += self.weights.get(model, 1.0 / len(self.model_names))

    total_weight = sum(tally.values()) or 1.0
    ranked = tally.most_common()
    leading_verdict, leading_weight = ranked[0]
    agreement = leading_weight / total_weight

    # A shared top weight is unresolved agreement -> abstain.
    tie = len(ranked) > 1 and ranked[1][1] == leading_weight
    abstained = tie or agreement < self.confidence_threshold
    final_verdict = ABSTAIN if abstained else leading_verdict

    # Confidence reflects consensus strength, not the models' unreliable
    # self-reported scores.
    confidence = round(agreement, 4)

    all_evidence: list[Evidence] = []
    for r in results:
        all_evidence.extend(r.evidence)

    vote_lines = [
        f"{m}: {votes[m]} (self-reported {self_reported[m]:.0%})" for m in self.model_names
    ]
    explanation_parts = [
        f"Consensus: {final_verdict} ({agreement:.0%} weighted agreement)",
        "Model votes:",
        *vote_lines,
    ]
    if final_verdict == ABSTAIN and leading_verdict != ABSTAIN:
        reason = "tie" if tie else f"agreement below {self.confidence_threshold:.0%} threshold"
        explanation_parts.append(f"(Abstained: {reason}.)")

    logger.debug(
        "Consensus %s at %.0f%% agreement; votes=%s", final_verdict, agreement * 100, votes
    )

    return VerificationResult(
        claim_id=claim.id,
        verdict=final_verdict,
        confidence=confidence,
        evidence=all_evidence[:5],
        explanation="\n".join(explanation_parts),
        model_votes=votes,
    )

Evidence Processing

truthfulness_evaluator.llm.chains.evidence.EvidenceProcessor

Process and analyze evidence for claims using structured outputs.

Source code in src/truthfulness_evaluator/llm/chains/evidence.py
class EvidenceProcessor:
    """Process and analyze evidence for claims using structured outputs."""

    def __init__(self, model: str = "gpt-4o-mini"):
        self.model = model
        self._llm = None

    @property
    def llm(self):
        """Lazy initialization of LLM with structured output."""
        if self._llm is None:
            base_llm = create_chat_model(self.model, temperature=0)
            self._llm = base_llm.with_structured_output(EvidenceAnalysisOutput)
        return self._llm

    async def analyze_evidence(
        self, claim: Claim, evidence_list: list[Evidence]
    ) -> tuple[list[Evidence], str]:
        """
        Analyze evidence and determine which pieces are relevant.

        Returns:
            Tuple of (filtered_evidence, analysis_summary)
        """
        if not evidence_list:
            return [], "No evidence provided"

        # Build evidence text
        evidence_text = "\n\n---\n\n".join(
            [
                f"[{i}] Source: {e.source}\nType: {e.source_type}\nContent: {e.content[:800]}"
                for i, e in enumerate(evidence_list[:5])  # Top 5 pieces
            ]
        )

        chain = EVIDENCE_ANALYSIS_PROMPT | self.llm

        try:
            result: EvidenceAnalysisOutput = await chain.ainvoke(
                {"claim": claim.text, "evidence": evidence_text}
            )

            # Update evidence with analysis
            for analysis in result.evidence_analysis:
                idx = analysis.index
                if 0 <= idx < len(evidence_list):
                    evidence_list[idx].relevance_score = max(0.0, min(1.0, analysis.relevance))
                    evidence_list[idx].supports_claim = analysis.supports
                    evidence_list[idx].credibility_score = max(0.0, min(1.0, analysis.credibility))

            # Sort by relevance
            evidence_list.sort(key=lambda e: e.relevance_score, reverse=True)

            return evidence_list, result.summary

        except Exception as e:
            # If analysis fails, return original evidence
            return evidence_list, f"Analysis failed: {str(e)}"

    async def synthesize_evidence(self, claim: Claim, evidence_list: list[Evidence]) -> str:
        """
        Synthesize multiple pieces of evidence into a coherent summary.

        Returns:
            Summary text
        """
        if not evidence_list:
            return "No evidence available"

        # Filter to high-relevance evidence
        good_evidence = [e for e in evidence_list if e.relevance_score >= 0.5][:3]

        if not good_evidence:
            return "No highly relevant evidence found"

        # Simple synthesis without LLM for speed
        parts = []
        for e in good_evidence:
            support = (
                "supports"
                if e.supports_claim
                else "refutes" if e.supports_claim is False else "is neutral on"
            )
            parts.append(
                f"{e.source} ({e.source_type}) {support} the claim with {e.relevance_score:.0%} relevance"
            )

        return "; ".join(parts)

llm property

Lazy initialization of LLM with structured output.

analyze_evidence(claim, evidence_list) async

Analyze evidence and determine which pieces are relevant.

Returns:

Type Description
tuple[list[Evidence], str]

Tuple of (filtered_evidence, analysis_summary)

Source code in src/truthfulness_evaluator/llm/chains/evidence.py
async def analyze_evidence(
    self, claim: Claim, evidence_list: list[Evidence]
) -> tuple[list[Evidence], str]:
    """
    Analyze evidence and determine which pieces are relevant.

    Returns:
        Tuple of (filtered_evidence, analysis_summary)
    """
    if not evidence_list:
        return [], "No evidence provided"

    # Build evidence text
    evidence_text = "\n\n---\n\n".join(
        [
            f"[{i}] Source: {e.source}\nType: {e.source_type}\nContent: {e.content[:800]}"
            for i, e in enumerate(evidence_list[:5])  # Top 5 pieces
        ]
    )

    chain = EVIDENCE_ANALYSIS_PROMPT | self.llm

    try:
        result: EvidenceAnalysisOutput = await chain.ainvoke(
            {"claim": claim.text, "evidence": evidence_text}
        )

        # Update evidence with analysis
        for analysis in result.evidence_analysis:
            idx = analysis.index
            if 0 <= idx < len(evidence_list):
                evidence_list[idx].relevance_score = max(0.0, min(1.0, analysis.relevance))
                evidence_list[idx].supports_claim = analysis.supports
                evidence_list[idx].credibility_score = max(0.0, min(1.0, analysis.credibility))

        # Sort by relevance
        evidence_list.sort(key=lambda e: e.relevance_score, reverse=True)

        return evidence_list, result.summary

    except Exception as e:
        # If analysis fails, return original evidence
        return evidence_list, f"Analysis failed: {str(e)}"

synthesize_evidence(claim, evidence_list) async

Synthesize multiple pieces of evidence into a coherent summary.

Returns:

Type Description
str

Summary text

Source code in src/truthfulness_evaluator/llm/chains/evidence.py
async def synthesize_evidence(self, claim: Claim, evidence_list: list[Evidence]) -> str:
    """
    Synthesize multiple pieces of evidence into a coherent summary.

    Returns:
        Summary text
    """
    if not evidence_list:
        return "No evidence available"

    # Filter to high-relevance evidence
    good_evidence = [e for e in evidence_list if e.relevance_score >= 0.5][:3]

    if not good_evidence:
        return "No highly relevant evidence found"

    # Simple synthesis without LLM for speed
    parts = []
    for e in good_evidence:
        support = (
            "supports"
            if e.supports_claim
            else "refutes" if e.supports_claim is False else "is neutral on"
        )
        parts.append(
            f"{e.source} ({e.source_type}) {support} the claim with {e.relevance_score:.0%} relevance"
        )

    return "; ".join(parts)