proofrag-ai 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- proofrag/__init__.py +0 -0
- proofrag/api.py +65 -0
- proofrag/cli.py +96 -0
- proofrag/config.py +108 -0
- proofrag/embeddings.py +53 -0
- proofrag/generator.py +275 -0
- proofrag/ingestion.py +379 -0
- proofrag/models.py +180 -0
- proofrag/okf.py +555 -0
- proofrag/pipeline.py +222 -0
- proofrag/retrieval.py +350 -0
- proofrag/verifier.py +272 -0
- proofrag_ai-0.1.0.dist-info/METADATA +48 -0
- proofrag_ai-0.1.0.dist-info/RECORD +18 -0
- proofrag_ai-0.1.0.dist-info/WHEEL +5 -0
- proofrag_ai-0.1.0.dist-info/entry_points.txt +2 -0
- proofrag_ai-0.1.0.dist-info/licenses/LICENSE +0 -0
- proofrag_ai-0.1.0.dist-info/top_level.txt +1 -0
proofrag/__init__.py
ADDED
|
File without changes
|
proofrag/api.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI, HTTPException
|
|
4
|
+
|
|
5
|
+
from .config import get_settings
|
|
6
|
+
from .models import ProofRAGRequest, ProofRAGResponse
|
|
7
|
+
from .pipeline import PipelineError, ProofRAG
|
|
8
|
+
|
|
9
|
+
settings = get_settings()
|
|
10
|
+
|
|
11
|
+
app = FastAPI(
|
|
12
|
+
title=settings.app_name,
|
|
13
|
+
version=settings.app_version,
|
|
14
|
+
description=(
|
|
15
|
+
"Evidence-backed RAG with verified citations "
|
|
16
|
+
"and safe refusal on insufficient evidence."
|
|
17
|
+
),
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
rag = ProofRAG()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.get("/health")
|
|
24
|
+
def health() -> dict[str, str]:
|
|
25
|
+
return {
|
|
26
|
+
"status": "ok",
|
|
27
|
+
"service": settings.app_name,
|
|
28
|
+
"version": settings.app_version,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@app.post("/ingest")
|
|
33
|
+
def ingest() -> dict[str, object]:
|
|
34
|
+
try:
|
|
35
|
+
documents = rag.ingest()
|
|
36
|
+
except PipelineError as exc:
|
|
37
|
+
raise HTTPException(
|
|
38
|
+
status_code=500,
|
|
39
|
+
detail=str(exc),
|
|
40
|
+
) from exc
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
"status": "ok",
|
|
44
|
+
"documents_indexed": len(documents),
|
|
45
|
+
"documents": [
|
|
46
|
+
document.filename
|
|
47
|
+
for document in documents
|
|
48
|
+
],
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.post(
|
|
53
|
+
"/ask",
|
|
54
|
+
response_model=ProofRAGResponse,
|
|
55
|
+
)
|
|
56
|
+
def ask(
|
|
57
|
+
request: ProofRAGRequest,
|
|
58
|
+
) -> ProofRAGResponse:
|
|
59
|
+
try:
|
|
60
|
+
return rag.ask(request)
|
|
61
|
+
except PipelineError as exc:
|
|
62
|
+
raise HTTPException(
|
|
63
|
+
status_code=500,
|
|
64
|
+
detail=str(exc),
|
|
65
|
+
) from exc
|
proofrag/cli.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.panel import Panel
|
|
6
|
+
|
|
7
|
+
from .pipeline import PipelineError, ProofRAG
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(
|
|
10
|
+
name="proofrag",
|
|
11
|
+
help="Evidence-backed RAG with verified citations.",
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@app.command()
|
|
18
|
+
def ingest() -> None:
|
|
19
|
+
"""Index documents into OKF and Chroma."""
|
|
20
|
+
|
|
21
|
+
rag = ProofRAG()
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
documents = rag.ingest()
|
|
25
|
+
except PipelineError as exc:
|
|
26
|
+
console.print(
|
|
27
|
+
f"[red]Error:[/red] {exc}"
|
|
28
|
+
)
|
|
29
|
+
raise typer.Exit(code=1)
|
|
30
|
+
|
|
31
|
+
console.print(
|
|
32
|
+
f"[green]Indexed {len(documents)} document(s).[/green]"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
for document in documents:
|
|
36
|
+
console.print(
|
|
37
|
+
f" • {document.filename}"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@app.command()
|
|
42
|
+
def ask(
|
|
43
|
+
question: str = typer.Argument(
|
|
44
|
+
...,
|
|
45
|
+
help="Question to ask over the indexed documents.",
|
|
46
|
+
),
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Ask a question and display verified evidence."""
|
|
49
|
+
|
|
50
|
+
rag = ProofRAG()
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
response = rag.ask(question)
|
|
54
|
+
except PipelineError as exc:
|
|
55
|
+
console.print(
|
|
56
|
+
f"[red]Error:[/red] {exc}"
|
|
57
|
+
)
|
|
58
|
+
raise typer.Exit(code=1)
|
|
59
|
+
|
|
60
|
+
console.print(
|
|
61
|
+
Panel(
|
|
62
|
+
response.answer,
|
|
63
|
+
title="ProofRAG Answer",
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
console.print(
|
|
68
|
+
f"Verified: {response.verified}"
|
|
69
|
+
)
|
|
70
|
+
console.print(
|
|
71
|
+
f"Refused: {response.refused}"
|
|
72
|
+
)
|
|
73
|
+
console.print(
|
|
74
|
+
f"Evidence: {response.evidence_count}"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
for citation in response.citations:
|
|
78
|
+
console.print()
|
|
79
|
+
console.print(
|
|
80
|
+
f"[bold]Source:[/bold] {citation.filename}"
|
|
81
|
+
)
|
|
82
|
+
console.print(
|
|
83
|
+
f"[bold]Page:[/bold] {citation.page_number}"
|
|
84
|
+
)
|
|
85
|
+
console.print(
|
|
86
|
+
f"[bold]Status:[/bold] "
|
|
87
|
+
f"{citation.verification_status.value}"
|
|
88
|
+
)
|
|
89
|
+
console.print(
|
|
90
|
+
f"[bold]Evidence:[/bold] "
|
|
91
|
+
f"{citation.snippet}"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
if __name__ == "__main__":
|
|
96
|
+
app()
|
proofrag/config.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from functools import lru_cache
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from pydantic import Field
|
|
7
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Settings(BaseSettings):
|
|
14
|
+
"""Application configuration for ProofRAG."""
|
|
15
|
+
|
|
16
|
+
model_config = SettingsConfigDict(
|
|
17
|
+
env_file=PROJECT_ROOT / ".env",
|
|
18
|
+
env_file_encoding="utf-8",
|
|
19
|
+
case_sensitive=False,
|
|
20
|
+
extra="ignore",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# ------------------------------------------------------------------
|
|
24
|
+
# Application
|
|
25
|
+
# ------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
app_name: str = "ProofRAG"
|
|
28
|
+
app_version: str = "0.1.0"
|
|
29
|
+
environment: str = "development"
|
|
30
|
+
|
|
31
|
+
# ------------------------------------------------------------------
|
|
32
|
+
# Paths
|
|
33
|
+
# ------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
data_dir: Path = PROJECT_ROOT / "data"
|
|
36
|
+
documents_dir: Path = PROJECT_ROOT / "data" / "documents"
|
|
37
|
+
okf_dir: Path = PROJECT_ROOT / "data" / "okf"
|
|
38
|
+
chroma_dir: Path = PROJECT_ROOT / "data" / "chroma"
|
|
39
|
+
|
|
40
|
+
# ------------------------------------------------------------------
|
|
41
|
+
# LLM
|
|
42
|
+
# ------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
groq_api_key: str = Field(default="", repr=False)
|
|
45
|
+
llm_model: str = "openai/gpt-oss-120b"
|
|
46
|
+
llm_temperature: float = 0.0
|
|
47
|
+
llm_timeout: int = 60
|
|
48
|
+
|
|
49
|
+
# ------------------------------------------------------------------
|
|
50
|
+
# Embeddings
|
|
51
|
+
# ------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
embedding_model: str = "sentence-transformers/all-MiniLM-L6-v2"
|
|
54
|
+
|
|
55
|
+
# ------------------------------------------------------------------
|
|
56
|
+
# Chunking
|
|
57
|
+
# ------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
chunk_size: int = 1000
|
|
60
|
+
chunk_overlap: int = 150
|
|
61
|
+
|
|
62
|
+
# ------------------------------------------------------------------
|
|
63
|
+
# Retrieval
|
|
64
|
+
# ------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
retrieval_top_k: int = 5
|
|
67
|
+
retrieval_score_threshold: float = 0.35
|
|
68
|
+
|
|
69
|
+
# ------------------------------------------------------------------
|
|
70
|
+
# Citation verification
|
|
71
|
+
# ------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
citation_keyword_threshold: float = 0.20
|
|
74
|
+
citation_embedding_threshold: float = 0.60
|
|
75
|
+
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
# API
|
|
78
|
+
# ------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
api_host: str = "127.0.0.1"
|
|
81
|
+
api_port: int = 8000
|
|
82
|
+
|
|
83
|
+
# ------------------------------------------------------------------
|
|
84
|
+
# Safety / limits
|
|
85
|
+
# ------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
max_question_length: int = 2000
|
|
88
|
+
max_context_chunks: int = 8
|
|
89
|
+
|
|
90
|
+
def ensure_directories(self) -> None:
|
|
91
|
+
"""Create required runtime directories if they do not exist."""
|
|
92
|
+
|
|
93
|
+
for directory in (
|
|
94
|
+
self.data_dir,
|
|
95
|
+
self.documents_dir,
|
|
96
|
+
self.okf_dir,
|
|
97
|
+
self.chroma_dir,
|
|
98
|
+
):
|
|
99
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@lru_cache(maxsize=1)
|
|
103
|
+
def get_settings() -> Settings:
|
|
104
|
+
"""Return the cached application settings."""
|
|
105
|
+
|
|
106
|
+
settings = Settings()
|
|
107
|
+
settings.ensure_directories()
|
|
108
|
+
return settings
|
proofrag/embeddings.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from functools import lru_cache
|
|
4
|
+
|
|
5
|
+
from langchain_huggingface import HuggingFaceEmbeddings
|
|
6
|
+
|
|
7
|
+
from .config import get_settings
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@lru_cache(maxsize=1)
|
|
11
|
+
def get_embedding_model() -> HuggingFaceEmbeddings:
|
|
12
|
+
"""
|
|
13
|
+
Create and cache the ProofRAG embedding model.
|
|
14
|
+
|
|
15
|
+
The model runs locally through sentence-transformers.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
settings = get_settings()
|
|
19
|
+
|
|
20
|
+
return HuggingFaceEmbeddings(
|
|
21
|
+
model_name=settings.embedding_model,
|
|
22
|
+
model_kwargs={
|
|
23
|
+
"device": "cpu",
|
|
24
|
+
},
|
|
25
|
+
encode_kwargs={
|
|
26
|
+
"normalize_embeddings": True,
|
|
27
|
+
},
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def embed_text(text: str) -> list[float]:
|
|
32
|
+
"""Generate an embedding for a single text."""
|
|
33
|
+
|
|
34
|
+
if not text.strip():
|
|
35
|
+
raise ValueError("Cannot embed empty text.")
|
|
36
|
+
|
|
37
|
+
return get_embedding_model().embed_query(text)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def embed_documents(
|
|
41
|
+
texts: list[str],
|
|
42
|
+
) -> list[list[float]]:
|
|
43
|
+
"""Generate embeddings for multiple documents."""
|
|
44
|
+
|
|
45
|
+
if not texts:
|
|
46
|
+
return []
|
|
47
|
+
|
|
48
|
+
if any(not text.strip() for text in texts):
|
|
49
|
+
raise ValueError(
|
|
50
|
+
"Document texts cannot contain empty strings."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
return get_embedding_model().embed_documents(texts)
|
proofrag/generator.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
|
|
7
|
+
from langchain_groq import ChatGroq
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
9
|
+
|
|
10
|
+
from .config import get_settings
|
|
11
|
+
from .models import Answer, Citation, Evidence, VerificationStatus
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GenerationError(Exception):
|
|
15
|
+
"""Raised when answer generation fails safely."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class GeneratedAnswer(BaseModel):
|
|
19
|
+
"""Strict internal contract for the LLM response."""
|
|
20
|
+
|
|
21
|
+
model_config = ConfigDict(extra="forbid")
|
|
22
|
+
|
|
23
|
+
answer: str = Field(min_length=1)
|
|
24
|
+
citations: list[str] = Field(default_factory=list)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class LLMClient(Protocol):
|
|
28
|
+
"""Minimal provider interface used by the generator."""
|
|
29
|
+
|
|
30
|
+
def invoke(self, messages: list[BaseMessage]) -> object:
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
SYSTEM_PROMPT = """You are ProofRAG, an evidence-grounded question answering system.
|
|
35
|
+
|
|
36
|
+
Your job is to answer the user's question using ONLY the supplied evidence.
|
|
37
|
+
|
|
38
|
+
STRICT RULES:
|
|
39
|
+
1. Use only information explicitly supported by the supplied evidence.
|
|
40
|
+
2. Never use outside knowledge.
|
|
41
|
+
3. Never invent facts, sources, filenames, page numbers, or citations.
|
|
42
|
+
4. Every factual claim in the answer must be supported by one or more supplied
|
|
43
|
+
evidence IDs.
|
|
44
|
+
5. The citations field must contain ONLY the exact evidence IDs supplied to you.
|
|
45
|
+
6. If the evidence does not adequately answer the question, return:
|
|
46
|
+
"Not found in the documents."
|
|
47
|
+
and use an empty citations list.
|
|
48
|
+
7. Do not guess or fill missing information.
|
|
49
|
+
8. Return ONLY valid JSON. Do not use Markdown fences.
|
|
50
|
+
|
|
51
|
+
Required JSON format:
|
|
52
|
+
{
|
|
53
|
+
"answer": "your evidence-grounded answer",
|
|
54
|
+
"citations": ["exact-evidence-id-1", "exact-evidence-id-2"]
|
|
55
|
+
}
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_generation_messages(
|
|
60
|
+
question: str,
|
|
61
|
+
evidence: list[Evidence],
|
|
62
|
+
) -> list[BaseMessage]:
|
|
63
|
+
"""Build the exact messages sent to the LLM."""
|
|
64
|
+
|
|
65
|
+
if not question.strip():
|
|
66
|
+
raise ValueError("Question cannot be empty.")
|
|
67
|
+
|
|
68
|
+
evidence_blocks: list[str] = []
|
|
69
|
+
|
|
70
|
+
for item in evidence:
|
|
71
|
+
page = (
|
|
72
|
+
str(item.page_number)
|
|
73
|
+
if item.page_number is not None
|
|
74
|
+
else "unknown"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
evidence_blocks.append(
|
|
78
|
+
"\n".join(
|
|
79
|
+
[
|
|
80
|
+
f"[EVIDENCE ID: {item.evidence_id}]",
|
|
81
|
+
f"Source file: {item.filename}",
|
|
82
|
+
f"Page: {page}",
|
|
83
|
+
"Supporting text:",
|
|
84
|
+
item.snippet,
|
|
85
|
+
]
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
evidence_text = "\n\n".join(evidence_blocks)
|
|
90
|
+
|
|
91
|
+
human_prompt = f"""User question:
|
|
92
|
+
{question}
|
|
93
|
+
|
|
94
|
+
Supplied evidence:
|
|
95
|
+
{evidence_text}
|
|
96
|
+
|
|
97
|
+
Answer the question using only the supplied evidence.
|
|
98
|
+
Return the required JSON object and nothing else.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
return [
|
|
102
|
+
SystemMessage(content=SYSTEM_PROMPT),
|
|
103
|
+
HumanMessage(content=human_prompt),
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class AnswerGenerator:
|
|
108
|
+
"""Generate evidence-grounded answers using an injected LLM client."""
|
|
109
|
+
|
|
110
|
+
def __init__(self, llm: LLMClient | None = None) -> None:
|
|
111
|
+
self._llm = llm
|
|
112
|
+
|
|
113
|
+
def _get_llm(self) -> LLMClient:
|
|
114
|
+
if self._llm is not None:
|
|
115
|
+
return self._llm
|
|
116
|
+
|
|
117
|
+
settings = get_settings()
|
|
118
|
+
|
|
119
|
+
if not settings.groq_api_key.strip():
|
|
120
|
+
raise GenerationError(
|
|
121
|
+
"GROQ_API_KEY is not configured."
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
return ChatGroq(
|
|
125
|
+
model=settings.llm_model,
|
|
126
|
+
temperature=settings.llm_temperature,
|
|
127
|
+
api_key=settings.groq_api_key,
|
|
128
|
+
timeout=settings.llm_timeout,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def generate(
|
|
132
|
+
self,
|
|
133
|
+
question: str,
|
|
134
|
+
evidence: list[Evidence],
|
|
135
|
+
) -> Answer:
|
|
136
|
+
"""Generate an answer from retrieved evidence."""
|
|
137
|
+
|
|
138
|
+
if not question.strip():
|
|
139
|
+
raise ValueError("Question cannot be empty.")
|
|
140
|
+
|
|
141
|
+
# Safe refusal before contacting the LLM.
|
|
142
|
+
if not evidence:
|
|
143
|
+
return Answer(
|
|
144
|
+
text="Not found in the documents.",
|
|
145
|
+
citations=[],
|
|
146
|
+
refused=True,
|
|
147
|
+
refusal_reason="No supporting evidence was retrieved.",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
messages = build_generation_messages(question, evidence)
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
response = self._get_llm().invoke(messages)
|
|
154
|
+
except GenerationError:
|
|
155
|
+
raise
|
|
156
|
+
except Exception as exc:
|
|
157
|
+
raise GenerationError(
|
|
158
|
+
"The answer generation provider failed."
|
|
159
|
+
) from exc
|
|
160
|
+
|
|
161
|
+
generated = _parse_response(response)
|
|
162
|
+
return _build_answer(generated, evidence)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _parse_response(response: object) -> GeneratedAnswer:
|
|
166
|
+
"""Parse and validate the LLM's JSON response."""
|
|
167
|
+
|
|
168
|
+
content = getattr(response, "content", response)
|
|
169
|
+
|
|
170
|
+
if isinstance(content, list):
|
|
171
|
+
parts: list[str] = []
|
|
172
|
+
|
|
173
|
+
for item in content:
|
|
174
|
+
if isinstance(item, str):
|
|
175
|
+
parts.append(item)
|
|
176
|
+
elif isinstance(item, dict) and "text" in item:
|
|
177
|
+
parts.append(str(item["text"]))
|
|
178
|
+
|
|
179
|
+
content = "".join(parts)
|
|
180
|
+
|
|
181
|
+
if not isinstance(content, str):
|
|
182
|
+
raise GenerationError(
|
|
183
|
+
"The LLM returned an unsupported response format."
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
raw = content.strip()
|
|
187
|
+
|
|
188
|
+
# Allow a fenced JSON response despite the strict prompt.
|
|
189
|
+
if raw.startswith("```") and raw.endswith("```"):
|
|
190
|
+
lines = raw.splitlines()
|
|
191
|
+
|
|
192
|
+
if lines and lines[0].strip().lower() in {
|
|
193
|
+
"```json",
|
|
194
|
+
"```",
|
|
195
|
+
}:
|
|
196
|
+
lines = lines[1:]
|
|
197
|
+
|
|
198
|
+
if lines and lines[-1].strip() == "```":
|
|
199
|
+
lines = lines[:-1]
|
|
200
|
+
|
|
201
|
+
raw = "\n".join(lines).strip()
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
payload = json.loads(raw)
|
|
205
|
+
except json.JSONDecodeError as exc:
|
|
206
|
+
raise GenerationError(
|
|
207
|
+
"The LLM returned invalid JSON."
|
|
208
|
+
) from exc
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
return GeneratedAnswer.model_validate(payload)
|
|
212
|
+
except Exception as exc:
|
|
213
|
+
raise GenerationError(
|
|
214
|
+
"The LLM response does not match the ProofRAG answer schema."
|
|
215
|
+
) from exc
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _build_answer(
|
|
219
|
+
generated: GeneratedAnswer,
|
|
220
|
+
evidence: list[Evidence],
|
|
221
|
+
) -> Answer:
|
|
222
|
+
"""Convert generated JSON into the public ProofRAG Answer model."""
|
|
223
|
+
|
|
224
|
+
evidence_by_id = {
|
|
225
|
+
item.evidence_id: item
|
|
226
|
+
for item in evidence
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
requested_ids = generated.citations
|
|
230
|
+
|
|
231
|
+
unknown_ids = [
|
|
232
|
+
evidence_id
|
|
233
|
+
for evidence_id in requested_ids
|
|
234
|
+
if evidence_id not in evidence_by_id
|
|
235
|
+
]
|
|
236
|
+
|
|
237
|
+
if unknown_ids:
|
|
238
|
+
raise GenerationError(
|
|
239
|
+
"The LLM returned citation IDs that were not supplied as evidence: "
|
|
240
|
+
+ ", ".join(unknown_ids)
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
# Preserve order while removing duplicate citation IDs.
|
|
244
|
+
unique_ids = list(dict.fromkeys(requested_ids))
|
|
245
|
+
|
|
246
|
+
citations: list[Citation] = []
|
|
247
|
+
|
|
248
|
+
for index, evidence_id in enumerate(unique_ids):
|
|
249
|
+
item = evidence_by_id[evidence_id]
|
|
250
|
+
|
|
251
|
+
citations.append(
|
|
252
|
+
Citation(
|
|
253
|
+
citation_id=f"citation-{index}-{evidence_id}",
|
|
254
|
+
evidence_id=item.evidence_id,
|
|
255
|
+
filename=item.filename,
|
|
256
|
+
page_number=item.page_number,
|
|
257
|
+
snippet=item.snippet,
|
|
258
|
+
verification_status=VerificationStatus.NOT_CHECKED,
|
|
259
|
+
keyword_score=0.0,
|
|
260
|
+
embedding_score=0.0,
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
refused = generated.answer.strip() == "Not found in the documents."
|
|
265
|
+
|
|
266
|
+
return Answer(
|
|
267
|
+
text=generated.answer.strip(),
|
|
268
|
+
citations=citations,
|
|
269
|
+
refused=refused,
|
|
270
|
+
refusal_reason=(
|
|
271
|
+
"The supplied evidence does not contain enough information."
|
|
272
|
+
if refused
|
|
273
|
+
else None
|
|
274
|
+
),
|
|
275
|
+
)
|