Download src/fgao/rag/pipeline.py from Angshul/FGAO-API: direct link, hf CLI and curl.
- Browser
- Download file 19.8 kB
-
https://huggingface.co/spaces/Angshul/FGAO-API/resolve/main/src/fgao/rag/pipeline.py
- Command line
-
hf download hf://spaces/Angshul/FGAO-API/src/fgao/rag/pipeline.py
-
curl -L -o pipeline.py https://huggingface.co/spaces/Angshul/FGAO-API/resolve/main/src/fgao/rag/pipeline.py
19.8 kB
| """FGAO-orchestrated RAG (the evaluated v2 RAG policy). | |
| Goals (mandatory, checked mechanically, never taken from the generator's claim) | |
| answered (w 0.2) a non-abstaining answer exists | |
| grounded (w 0.3) the normalised answer occurs in a passage it was generated from | |
| supported (w 0.5) the support checker accepted the current answer, reading | |
| the passage that contains it | |
| Operations offered to the controller | |
| answer(src, graded=False) retrieve from ``src`` (once per query, cached), generate | |
| answer(src, graded=True) retrieve, grade relevance, generate from the kept passages | |
| verify support check of the current answer | |
| Each answer operation runs at most once per query (LLM calls are | |
| deterministic, so repeating one cannot help); verify runs once per distinct | |
| answer. Answer operations on sources of the same ``family`` share feedback: | |
| when the KB variants keep failing, FGAO's estimate for all of them drops and it | |
| escalates to the other family (e.g. web search). | |
| Output: the answer only if all three goals are verified, otherwise an | |
| abstention (``answer is None``); ``grounded_answer`` keeps the best grounded but | |
| unverified answer for callers that prefer coverage. | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| from collections import Counter | |
| from concurrent.futures import ThreadPoolExecutor | |
| from dataclasses import dataclass, field | |
| from typing import Any, AsyncIterator, Callable, Dict, Iterator, List, Optional, Sequence, Set, Tuple, Union | |
| from ..controller import FGAOConfig | |
| from ..workflow import CompiledWorkflow, Step, ToolResult, Workflow, WorkflowResult | |
| from .components import LLMGenerator, LLMGrader, LLMRewriter, LLMSupportChecker, acall | |
| from .documents import Document, contains_answer | |
| from .llms import as_llm | |
| from .retrievers import aretrieve, as_retriever | |
| class Costs: | |
| """Declared operation costs (same units as the budget).""" | |
| llm: float = 2.0 | |
| kb: float = 1.0 | |
| kb_deep: float = 1.5 | |
| web: float = 4.0 | |
| class Priors: | |
| """Declared effect priors of the evaluated v2 RAG task (fixed a priori, never tuned).""" | |
| answer_answered: float = 0.8 | |
| answer_grounded: float = 0.7 | |
| graded_answered: float = 0.75 | |
| graded_grounded: float = 0.75 | |
| answer_harms_support: float = 0.9 | |
| answer_produces_unverified: float = 0.8 | |
| verify_supported: float = 0.8 | |
| class Weights: | |
| answered: float = 0.2 | |
| grounded: float = 0.3 | |
| supported: float = 0.5 | |
| class Source: | |
| """A retrieval source the controller may choose. | |
| cost declared retrieval cost (for ``rewrite=True`` include the rewrite call) | |
| family comparability group; sources of one family share feedback | |
| rewrite rewrite the question into a search query before retrieving | |
| """ | |
| name: str | |
| retriever: Any | |
| k: int = 5 | |
| cost: float = 1.0 | |
| family: str = "kb" | |
| rewrite: bool = False | |
| def default_sources(kb: Any, web: Any = None, costs: Costs = Costs()) -> List[Source]: | |
| """The evaluated source layout: KB top-5, KB top-10, rewritten-query KB top-5, web top-5.""" | |
| kb = as_retriever(kb) | |
| out = [ | |
| Source("kb5", kb, k=5, cost=costs.kb, family="kb"), | |
| Source("kb10", kb, k=10, cost=costs.kb_deep, family="kb"), | |
| Source("rw", kb, k=5, cost=costs.llm + costs.kb, family="kb", rewrite=True), | |
| ] | |
| if web is not None: | |
| out.append(Source("web", as_retriever(web), k=5, cost=costs.web, family="web")) | |
| return out | |
| # ============================================================================= | |
| class RAGEpisode: | |
| """Mutable per-query state the operations act on.""" | |
| def __init__(self, question: str) -> None: | |
| self.question = question | |
| self.docs: Dict[str, List[Document]] = {} | |
| self.graded: Dict[str, List[Document]] = {} | |
| self.answer: str = "" | |
| self.answer_docs: List[Document] = [] | |
| self.supported = False | |
| self.checked: Optional[str] = None # answer last sent to the support check | |
| self.tried: Set[Tuple[str, str, int]] = set() | |
| self.calls: Counter = Counter() | |
| self.queries: Dict[str, str] = {} | |
| def grounded(self) -> bool: | |
| return bool(self.answer) and any(contains_answer(d.text, self.answer) for d in self.answer_docs) | |
| def support_passage(self) -> Optional[Document]: | |
| return next((d for d in self.answer_docs if contains_answer(d.text, self.answer)), None) | |
| class RAGResult: | |
| question: str | |
| answer: Optional[str] # None = abstained | |
| status: str | |
| values: Dict[str, float] | |
| grounded_answer: Optional[str] | |
| evidence: List[Document] | |
| support_passage: Optional[Document] | |
| support: Optional[bool] # support-check result for the final answer; None if it was never checked | |
| cost: float | |
| calls: Dict[str, int] | |
| steps: List[Step] = field(default_factory=list) | |
| retrieved: Dict[str, List[Document]] = field(default_factory=dict) | |
| def abstained(self) -> bool: | |
| return self.answer is None | |
| def verified(self) -> bool: | |
| return self.answer is not None | |
| def trace(self) -> List[Dict[str, Any]]: | |
| return [s.to_dict() for s in self.steps] | |
| def explain(self) -> str: | |
| lines = [f"Q: {self.question}"] | |
| for s in self.steps: | |
| d = s.to_dict() | |
| vals = ", ".join(f"{k}={v:g}" for k, v in d["values"].items()) | |
| lines.append(f" {d['step']}. {d['action']} Q={d['Q']:.3f} gain={d['predicted_gain']:.3f} " | |
| f"cont={d['continuation']:.3f} cost={d['cost']:g} left={d['budget_left']:g} -> {vals}" | |
| + (f" [{d['error']}]" if d["error"] else "")) | |
| lines.append(f" stop: {self.status}; answer: {self.answer if self.answer is not None else '(abstain)'}") | |
| return "\n".join(lines) | |
| def to_dict(self) -> Dict[str, Any]: | |
| return { | |
| "question": self.question, "answer": self.answer, "abstained": self.abstained, "status": self.status, | |
| "values": dict(self.values), "grounded_answer": self.grounded_answer, | |
| "evidence": [d.to_dict() for d in self.evidence], | |
| "support_passage": self.support_passage.to_dict() if self.support_passage else None, | |
| "support": self.support, "cost": self.cost, "calls": dict(self.calls), "trace": self.trace(), | |
| } | |
| # ============================================================================= | |
| class FGAORAG: | |
| """FGAO v2 as a RAG orchestrator. | |
| Parameters | |
| ---------- | |
| sources : list of ``Source`` (see ``default_sources`` / ``from_retrievers``). | |
| llm : default LLM for the four operations; anything ``as_llm`` accepts. | |
| generator, grader, rewriter, support_checker : override single operations. | |
| costs, priors, weights : declared costs, effect priors and goal weights. | |
| budget : per-query budget (default 16). | |
| config : ``FGAOConfig`` (default: the evaluated v2 configuration). | |
| on_error: "raise" (default) propagates an operation's exception (after any | |
| retries in a ``resilient`` wrapper); "record" charges the | |
| operation's declared cost, records the error in the trace and | |
| lets FGAO continue with the remaining operations. | |
| """ | |
| def __init__( | |
| self, | |
| sources: Sequence[Source], | |
| llm: Any = None, | |
| *, | |
| generator: Optional[Callable] = None, | |
| grader: Optional[Callable] = None, | |
| rewriter: Optional[Callable] = None, | |
| support_checker: Optional[Callable] = None, | |
| costs: Costs = Costs(), | |
| priors: Priors = Priors(), | |
| weights: Weights = Weights(), | |
| budget: float = 16.0, | |
| config: Optional[FGAOConfig] = None, | |
| on_error: str = "raise", | |
| ) -> None: | |
| if not sources: | |
| raise ValueError("at least one source is required") | |
| names = [s.name for s in sources] | |
| if len(set(names)) != len(names): | |
| raise ValueError("source names must be unique") | |
| self.sources = [Source(s.name, as_retriever(s.retriever), s.k, s.cost, s.family, s.rewrite) for s in sources] | |
| self._src = {s.name: s for s in self.sources} | |
| need = {"generator": generator, "grader": grader, "support_checker": support_checker} | |
| if any(s.rewrite for s in self.sources): | |
| need["rewriter"] = rewriter | |
| missing = [k for k, v in need.items() if v is None] | |
| if llm is None and missing: | |
| raise ValueError(f"llm is required for the default {', '.join(missing)}") | |
| base = as_llm(llm) if llm is not None else None | |
| self.llm = base | |
| self.generator = generator or LLMGenerator(base) | |
| self.grader = grader or LLMGrader(base) | |
| self.rewriter = rewriter or (LLMRewriter(base) if base is not None else None) | |
| self.support_checker = support_checker or LLMSupportChecker(base) | |
| self.costs, self.priors, self.weights = costs, priors, weights | |
| self.budget = float(budget) | |
| self.config = config or FGAOConfig() | |
| self.on_error = on_error | |
| self.app: CompiledWorkflow = self._build().compile(self.config, budget=self.budget, on_error=on_error) | |
| def from_retrievers(cls, kb: Any, llm: Any = None, *, web: Any = None, costs: Costs = Costs(), | |
| **kw: Any) -> "FGAORAG": | |
| """The evaluated layout over one knowledge base and an optional web/second source.""" | |
| return cls(default_sources(kb, web, costs), llm, costs=costs, **kw) | |
| # ------------------------------------------------------------ workflow | |
| def _build(self) -> Workflow: | |
| P, W, C = self.priors, self.weights, self.costs | |
| wf = Workflow("fgao-rag") | |
| wf.add_goal("answered", lambda e: float(bool(e.answer)), weight=W.answered) | |
| wf.add_goal("grounded", lambda e: float(e.grounded()), weight=W.grounded) | |
| wf.add_goal("supported", lambda e: float(e.supported and e.grounded()), weight=W.supported) | |
| for s in self.sources: | |
| for g in (0, 1): | |
| wf.add_predicate(f"untried:ans:{s.name}:{g}", (lambda key: lambda e: key not in e.tried)(("ans", s.name, g))) | |
| wf.add_predicate("unverified", lambda e: bool(e.answer) and e.checked != e.answer) | |
| for s in self.sources: | |
| for g in (False, True): | |
| wf.add_tool( | |
| "answer", self._answer, afn=self._aanswer, args={"src": s.name, "graded": g}, | |
| cost=s.cost + C.llm * (2 if g else 1), | |
| gain={"answered": P.graded_answered if g else P.answer_answered, | |
| "grounded": P.graded_grounded if g else P.answer_grounded}, | |
| harm={"supported": P.answer_harms_support}, | |
| produces={"unverified": P.answer_produces_unverified}, | |
| requires=[f"untried:ans:{s.name}:{int(g)}"], | |
| group=("answer", s.family), | |
| ) | |
| wf.add_tool("verify", self._verify, afn=self._averify, cost=C.llm, gain={"supported": P.verify_supported}, | |
| requires=["unverified"], requires_goals=["answered", "grounded"]) | |
| return wf | |
| def _ensure_docs(self, ep: RAGEpisode, name: str) -> None: | |
| if name in ep.docs: | |
| return | |
| s = self._src[name] | |
| query = ep.question | |
| if s.rewrite: | |
| ep.calls["rewrite"] += 1 | |
| query = self.rewriter(ep.question) or ep.question | |
| ep.queries[name] = query | |
| ep.calls[f"retrieve:{name}"] += 1 | |
| ep.docs[name] = list(s.retriever.retrieve(query, s.k)) | |
| def _answer(self, ep: RAGEpisode, src: str, graded: bool) -> Optional[ToolResult]: | |
| ep.tried.add(("ans", src, int(graded))) | |
| self._ensure_docs(ep, src) | |
| docs = ep.docs.get(src, []) | |
| if graded and docs: | |
| if src not in ep.graded: | |
| ep.calls["grade"] += 1 | |
| scores = self.grader(ep.question, docs) | |
| ep.graded[src] = [d for d, sc in zip(docs, scores) if sc >= 1] | |
| docs = ep.graded[src] | |
| if not docs: | |
| return ToolResult(error="no documents") | |
| ep.calls["generate"] += 1 | |
| ans = self.generator(ep.question, docs) or "" | |
| if ans and ans != ep.answer: # an abstention never overwrites an existing answer | |
| ep.answer, ep.answer_docs, ep.supported = ans, list(docs), False | |
| elif ans and ans == ep.answer and not ep.grounded(): | |
| ep.answer_docs = list(docs) | |
| return None | |
| def _verify(self, ep: RAGEpisode) -> Optional[ToolResult]: | |
| hit = ep.support_passage() | |
| if hit is None or not ep.answer: | |
| return ToolResult(error="nothing to verify") | |
| ep.calls["verify"] += 1 | |
| ep.checked = ep.answer | |
| ep.supported = bool(self.support_checker(ep.question, hit, ep.answer)) | |
| return None | |
| # async twins (identical logic; components are awaited natively when they support it) | |
| async def _aensure_docs(self, ep: RAGEpisode, name: str) -> None: | |
| if name in ep.docs: | |
| return | |
| s = self._src[name] | |
| query = ep.question | |
| if s.rewrite: | |
| ep.calls["rewrite"] += 1 | |
| query = (await acall(self.rewriter, ep.question)) or ep.question | |
| ep.queries[name] = query | |
| ep.calls[f"retrieve:{name}"] += 1 | |
| ep.docs[name] = list(await aretrieve(s.retriever, query, s.k)) | |
| async def _aanswer(self, ep: RAGEpisode, src: str, graded: bool) -> Optional[ToolResult]: | |
| ep.tried.add(("ans", src, int(graded))) | |
| await self._aensure_docs(ep, src) | |
| docs = ep.docs.get(src, []) | |
| if graded and docs: | |
| if src not in ep.graded: | |
| ep.calls["grade"] += 1 | |
| scores = await acall(self.grader, ep.question, docs) | |
| ep.graded[src] = [d for d, sc in zip(docs, scores) if sc >= 1] | |
| docs = ep.graded[src] | |
| if not docs: | |
| return ToolResult(error="no documents") | |
| ep.calls["generate"] += 1 | |
| ans = (await acall(self.generator, ep.question, docs)) or "" | |
| if ans and ans != ep.answer: | |
| ep.answer, ep.answer_docs, ep.supported = ans, list(docs), False | |
| elif ans and ans == ep.answer and not ep.grounded(): | |
| ep.answer_docs = list(docs) | |
| return None | |
| async def _averify(self, ep: RAGEpisode) -> Optional[ToolResult]: | |
| hit = ep.support_passage() | |
| if hit is None or not ep.answer: | |
| return ToolResult(error="nothing to verify") | |
| ep.calls["verify"] += 1 | |
| ep.checked = ep.answer | |
| ep.supported = bool(await acall(self.support_checker, ep.question, hit, ep.answer)) | |
| return None | |
| # ------------------------------------------------------------- running | |
| def _question(x: Union[str, Dict[str, Any]]) -> str: | |
| if isinstance(x, str): | |
| return x | |
| if isinstance(x, dict): | |
| for k in ("question", "query", "input"): | |
| if k in x: | |
| return str(x[k]) | |
| raise TypeError("input must be a question string or a dict with 'question'") | |
| def _result(self, ep: RAGEpisode, wr: WorkflowResult) -> RAGResult: | |
| grounded = ep.grounded() | |
| return RAGResult( | |
| question=ep.question, | |
| answer=ep.answer if wr.success else None, | |
| status=wr.status, | |
| values=dict(wr.values), | |
| grounded_answer=ep.answer if grounded else None, | |
| evidence=list(ep.answer_docs), | |
| support_passage=ep.support_passage() if grounded else None, | |
| support=(ep.supported if ep.answer and ep.checked == ep.answer else None), | |
| cost=wr.cost, | |
| calls=dict(ep.calls), | |
| steps=list(wr.steps), | |
| retrieved={k: list(v) for k, v in ep.docs.items()}, | |
| ) | |
| def stream(self, question: Union[str, Dict[str, Any]], *, seed: int = 0, budget: Optional[float] = None, | |
| callbacks: Sequence[Callable[[Step], None]] = ()) -> Iterator[Union[Step, RAGResult]]: | |
| """Yield every executed ``Step`` as it happens, then the ``RAGResult``.""" | |
| ep = RAGEpisode(self._question(question)) | |
| for item in self.app.stream(ep, seed=seed, budget=budget, callbacks=callbacks): | |
| yield self._result(ep, item) if isinstance(item, WorkflowResult) else item | |
| def invoke(self, question: Union[str, Dict[str, Any]], *, seed: int = 0, budget: Optional[float] = None, | |
| callbacks: Sequence[Callable[[Step], None]] = ()) -> RAGResult: | |
| out = None | |
| for out in self.stream(question, seed=seed, budget=budget, callbacks=callbacks): | |
| pass | |
| return out # type: ignore[return-value] | |
| __call__ = invoke | |
| def batch(self, questions: Sequence[Union[str, Dict[str, Any]]], *, max_concurrency: int = 8, | |
| **kw: Any) -> List[RAGResult]: | |
| """Answer many questions concurrently (threads); results keep input order.""" | |
| if max_concurrency <= 1: | |
| return [self.invoke(q, **kw) for q in questions] | |
| with ThreadPoolExecutor(max_workers=max_concurrency) as ex: | |
| return list(ex.map(lambda q: self.invoke(q, **kw), questions)) | |
| async def astream(self, question: Union[str, Dict[str, Any]], *, seed: int = 0, budget: Optional[float] = None, | |
| callbacks: Sequence[Callable[[Step], None]] = ()) -> AsyncIterator[Union[Step, RAGResult]]: | |
| """Native async ``stream``: same decisions; LLM and retriever calls are awaited.""" | |
| ep = RAGEpisode(self._question(question)) | |
| async for item in self.app.astream(ep, seed=seed, budget=budget, callbacks=callbacks): | |
| yield self._result(ep, item) if isinstance(item, WorkflowResult) else item | |
| async def ainvoke(self, question: Union[str, Dict[str, Any]], *, seed: int = 0, budget: Optional[float] = None, | |
| callbacks: Sequence[Callable[[Step], None]] = ()) -> RAGResult: | |
| out = None | |
| async for out in self.astream(question, seed=seed, budget=budget, callbacks=callbacks): | |
| pass | |
| return out # type: ignore[return-value] | |
| async def abatch(self, questions: Sequence[Union[str, Dict[str, Any]]], *, max_concurrency: int = 64, | |
| **kw: Any) -> List[RAGResult]: | |
| """Answer many questions concurrently on the event loop; results keep input order.""" | |
| sem = asyncio.Semaphore(max(1, max_concurrency)) | |
| async def one(q): | |
| async with sem: | |
| return await self.ainvoke(q, **kw) | |
| return list(await asyncio.gather(*(one(q) for q in questions))) | |
| # -------------------------------------------------------- integrations | |
| def as_runnable(self): | |
| """A LangChain ``Runnable``: input a question (str or {'question': ...}), output ``RAGResult.to_dict()``.""" | |
| from ..integrations.langchain import rag_runnable | |
| return rag_runnable(self) | |
| def as_tool(self, name: str = "fgao_rag", description: Optional[str] = None): | |
| """A LangChain tool an agent can call with a question.""" | |
| from ..integrations.langchain import rag_tool | |
| return rag_tool(self, name, description) | |
| def as_node(self, *, question_key: str = "question", answer_key: str = "answer", | |
| result_key: Optional[str] = "fgao"): | |
| """A LangGraph node: reads ``state[question_key]``, writes the answer (and the full result).""" | |
| from ..integrations.langgraph import rag_node | |
| return rag_node(self, question_key=question_key, answer_key=answer_key, result_key=result_key) | |