truthbench 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.
- truthbench/__init__.py +4 -0
- truthbench/cli.py +46 -0
- truthbench/llms/__init__.py +0 -0
- truthbench/llms/openai.py +16 -0
- truthbench/models.py +51 -0
- truthbench/pipeline.py +174 -0
- truthbench/readers/__init__.py +0 -0
- truthbench/readers/json_reader.py +52 -0
- truthbench/steps/__init__.py +0 -0
- truthbench/steps/blacklist.py +60 -0
- truthbench/steps/counter.py +46 -0
- truthbench/steps/factual.py +228 -0
- truthbench/steps/filter.py +61 -0
- truthbench/steps/noise.py +198 -0
- truthbench/steps/paraphrase.py +72 -0
- truthbench/steps/rank.py +129 -0
- truthbench/truth_pipeline.py +54 -0
- truthbench-0.1.0.dist-info/METADATA +374 -0
- truthbench-0.1.0.dist-info/RECORD +21 -0
- truthbench-0.1.0.dist-info/WHEEL +4 -0
- truthbench-0.1.0.dist-info/entry_points.txt +3 -0
truthbench/__init__.py
ADDED
truthbench/cli.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import pathlib
|
|
3
|
+
|
|
4
|
+
import truthbench
|
|
5
|
+
from truthbench.models import Report, Tracker, Sample
|
|
6
|
+
from truthbench.readers.json_reader import JsonReader
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main() -> None:
|
|
10
|
+
parser = argparse.ArgumentParser(description="Run truthbench pipeline")
|
|
11
|
+
parser.add_argument(
|
|
12
|
+
"--output-dir", "-o", required=True, type=pathlib.Path,
|
|
13
|
+
help="Directory where to place the output dataset and the execution report"
|
|
14
|
+
)
|
|
15
|
+
parser.add_argument(
|
|
16
|
+
"--input-file", "-i", required=True, type=pathlib.Path,
|
|
17
|
+
help="Input json dataset containing questions and ground truths"
|
|
18
|
+
)
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"--keep", "-k", default=.8, type=float,
|
|
21
|
+
help="Percentage of factual data to preserve"
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"--num-levels", "-l", default=5, type=int,
|
|
25
|
+
help="Number of perturbation levels to produce A0-AX"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
args = parser.parse_args()
|
|
29
|
+
|
|
30
|
+
pipeline = truthbench.truth_pipeline(keep=args.keep, num_levels=args.num_levels)
|
|
31
|
+
samples, tracker = pipeline.run(JsonReader(args.input_file))
|
|
32
|
+
|
|
33
|
+
report = Report(report=Tracker(**tracker), questions=[Sample(**s) for s in samples])
|
|
34
|
+
dataset = report.to_dataset()
|
|
35
|
+
|
|
36
|
+
args.output_dir.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
|
|
38
|
+
with open(args.output_dir / "report.json", "w", encoding="utf-8") as f:
|
|
39
|
+
f.write(report.model_dump_json(indent=4))
|
|
40
|
+
|
|
41
|
+
with open(args.output_dir / "dataset.json", "w", encoding="utf-8") as f:
|
|
42
|
+
f.write(dataset.model_dump_json(indent=4))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
if __name__ == "__main__":
|
|
46
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from typing import Dict, List
|
|
2
|
+
|
|
3
|
+
from openai import OpenAI
|
|
4
|
+
|
|
5
|
+
from truthbench.pipeline import LLM
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class GPT(LLM):
|
|
9
|
+
|
|
10
|
+
def __init__(self, client: OpenAI, model: str = "gpt-4o"):
|
|
11
|
+
self._client = client
|
|
12
|
+
self._model = model
|
|
13
|
+
|
|
14
|
+
def query(self, messages: List[Dict[str, str]]) -> str:
|
|
15
|
+
completion = self._client.chat.completions.create(model=self._model, messages=messages)
|
|
16
|
+
return completion.choices[0].message.content.strip()
|
truthbench/models.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from typing import Optional, List, Dict
|
|
2
|
+
|
|
3
|
+
import pydantic
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Tracker(pydantic.BaseModel):
|
|
7
|
+
input_samples: int = 0
|
|
8
|
+
find_factual_data_error: int = 0
|
|
9
|
+
json_parse_ranking_error: int = 0
|
|
10
|
+
index_ranking_error: int = 0
|
|
11
|
+
ranking_factual_data_error: int = 0
|
|
12
|
+
output_samples: int = 0
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Sample(pydantic.BaseModel):
|
|
16
|
+
question: Optional[str] = None
|
|
17
|
+
ground_truth: Optional[str] = None
|
|
18
|
+
raw_factual_data: Optional[List[str]] = None
|
|
19
|
+
with_brackets: Optional[Dict[str, str]] = None
|
|
20
|
+
thinking: Optional[Dict[str, str]] = None
|
|
21
|
+
blacklisted: Optional[List[str]] = None
|
|
22
|
+
factual_data: Optional[List[str]] = None
|
|
23
|
+
ranked_factual_data: Optional[List[str]] = None
|
|
24
|
+
answers: Optional[Dict[str, str]] = None
|
|
25
|
+
|
|
26
|
+
def is_valid(self) -> bool:
|
|
27
|
+
return len(self.answers.keys()) > 1
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Item(pydantic.BaseModel):
|
|
31
|
+
id: int
|
|
32
|
+
question: str
|
|
33
|
+
ground_truth: str
|
|
34
|
+
answers: Dict[str, str]
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_sample(cls, id_: int, sample: Sample) -> 'Item':
|
|
38
|
+
return Item(id=id_, question=sample.question, ground_truth=sample.ground_truth, answers=sample.answers)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Dataset(pydantic.BaseModel):
|
|
42
|
+
questions: List[Item]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Report(pydantic.BaseModel):
|
|
46
|
+
report: Tracker
|
|
47
|
+
questions: List[Sample]
|
|
48
|
+
|
|
49
|
+
def to_dataset(self) -> Dataset:
|
|
50
|
+
items = [Item.from_sample(id_=i, sample=s) for i, s in enumerate(self.questions) if s.is_valid()]
|
|
51
|
+
return Dataset(questions=items)
|
truthbench/pipeline.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
from typing import List, Dict, Tuple, Any, Set
|
|
3
|
+
|
|
4
|
+
from tqdm import tqdm
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class StrictTracker(dict):
|
|
8
|
+
"""
|
|
9
|
+
A dictionary subclass that enforces allowed keys and initializes counters.
|
|
10
|
+
|
|
11
|
+
This tracker only allows keys declared in `allowed_keys`. Accessing or setting
|
|
12
|
+
a key not in `allowed_keys` raises a KeyError with a helpful message.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, allowed_keys: set[str]):
|
|
16
|
+
super().__init__()
|
|
17
|
+
self._allowed_keys = allowed_keys
|
|
18
|
+
self.update({k: 0 for k in self._allowed_keys})
|
|
19
|
+
|
|
20
|
+
def __getitem__(self, key):
|
|
21
|
+
if key not in self._allowed_keys:
|
|
22
|
+
raise KeyError(
|
|
23
|
+
f"Tracker counter '{key}' is being set but was not declared. "
|
|
24
|
+
f"Declare it at the Step constructor with: "
|
|
25
|
+
f" super().__init__(..., counters=frozenset({{{repr(key)}}}))"
|
|
26
|
+
)
|
|
27
|
+
return super().__getitem__(key)
|
|
28
|
+
|
|
29
|
+
def __setitem__(self, key, value):
|
|
30
|
+
if key not in self._allowed_keys:
|
|
31
|
+
raise KeyError(
|
|
32
|
+
f"Tracker counter '{key}' is being set but was not declared. "
|
|
33
|
+
f"Declare it at the Step constructor with: "
|
|
34
|
+
f" super().__init__(..., counters=frozenset({{{repr(key)}}}))"
|
|
35
|
+
)
|
|
36
|
+
return super().__setitem__(key, value)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class LLM(abc.ABC):
|
|
40
|
+
"""
|
|
41
|
+
Abstract base class for Language Models.
|
|
42
|
+
|
|
43
|
+
Subclasses must implement the `query` method to send messages to the LLM
|
|
44
|
+
and return the response as a string.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
@abc.abstractmethod
|
|
48
|
+
def query(self, messages: List[Dict[str, str]]) -> str:
|
|
49
|
+
"""
|
|
50
|
+
Query the language model with a list of messages and get the output string.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
messages (List[Dict[str, str]]): A list of message dicts with keys like 'role' and 'content'.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
str: The LLM's response as a string.
|
|
57
|
+
"""
|
|
58
|
+
...
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Step(abc.ABC):
|
|
62
|
+
"""
|
|
63
|
+
Abstract base class representing a single processing step in the pipeline.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
required_fields (Set[str]): Set of keys that must be present in each sample before running this step.
|
|
67
|
+
counters (Set[str]): Set of counter names that this step may increment in the tracker.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, required_fields: Set[str] = frozenset(), counters: Set[str] = frozenset()):
|
|
71
|
+
self.required_fields = required_fields
|
|
72
|
+
self.counters = counters
|
|
73
|
+
|
|
74
|
+
def validate(self, sample: Dict[str, Any]) -> None:
|
|
75
|
+
"""
|
|
76
|
+
Validate that the sample contains all required fields for this step.
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
ValueError: If any required field is missing in the sample.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
current_fields = set(sample.keys())
|
|
83
|
+
if not self.required_fields.issubset(current_fields):
|
|
84
|
+
missing = self.required_fields.difference(current_fields)
|
|
85
|
+
raise ValueError(
|
|
86
|
+
f"{type(self).__name__} requires {sorted(self.required_fields)}, but some are missing from the sample: "
|
|
87
|
+
f"{sorted(missing)}. Check pipeline dependencies before proceeding."
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
@abc.abstractmethod
|
|
91
|
+
def step(self, sample: Dict[str, Any], tracker: Dict[str, int]) -> None:
|
|
92
|
+
"""
|
|
93
|
+
Execute the step logic on the sample, possibly updating the tracker.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
sample (Dict[str, Any]): The data sample to process.
|
|
97
|
+
tracker (Dict[str, int]): A dictionary tracking counters/errors during processing.
|
|
98
|
+
"""
|
|
99
|
+
...
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Reader(abc.ABC):
|
|
103
|
+
"""
|
|
104
|
+
Abstract base class for data readers that provide samples to the pipeline.
|
|
105
|
+
|
|
106
|
+
Subclasses must implement the `samples` method that returns a list of validated samples.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
@abc.abstractmethod
|
|
110
|
+
def samples(self) -> List[Dict[str, Any]]:
|
|
111
|
+
"""
|
|
112
|
+
Load and return validated samples.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
List[Dict[str, Any]]: A list of dictionaries containing 'question' and 'ground_truth' keys.
|
|
116
|
+
|
|
117
|
+
Raises:
|
|
118
|
+
ValueError: If the source could not be read or has an invalid format.
|
|
119
|
+
"""
|
|
120
|
+
...
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class Pipeline:
|
|
124
|
+
"""
|
|
125
|
+
Orchestrates a sequence of Steps to process data samples.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
with_progress (bool): Whether to display a progress bar during execution (tqdm).
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
def __init__(self, with_progress: bool = True):
|
|
132
|
+
self._steps: List[Step] = []
|
|
133
|
+
self._with_progress = with_progress
|
|
134
|
+
|
|
135
|
+
def with_step(self, step: Step) -> 'Pipeline':
|
|
136
|
+
"""
|
|
137
|
+
Add a processing step to the pipeline.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
step (Step): A step instance to add.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
Pipeline: Self, to allow method chaining.
|
|
144
|
+
"""
|
|
145
|
+
self._steps.append(step)
|
|
146
|
+
return self
|
|
147
|
+
|
|
148
|
+
def run(self, reader: Reader) -> Tuple[List[Dict[str, Any]], Dict[str, int]]:
|
|
149
|
+
"""
|
|
150
|
+
Execute all steps in sequence on each sample provided by the reader.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
reader (Reader): Data reader yielding samples.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
Tuple[List[Dict[str, Any]], Dict[str, int]]:
|
|
157
|
+
- List of processed samples.
|
|
158
|
+
- Tracker dictionary with counters collected during processing.
|
|
159
|
+
"""
|
|
160
|
+
allowed_keys = {"input_samples"} | frozenset.union(*(step.counters for step in self._steps))
|
|
161
|
+
|
|
162
|
+
tracker = StrictTracker(allowed_keys)
|
|
163
|
+
|
|
164
|
+
samples = reader.samples()
|
|
165
|
+
|
|
166
|
+
collected = []
|
|
167
|
+
for sample in tqdm(samples, desc="Samples:", disable=not self._with_progress):
|
|
168
|
+
tracker["input_samples"] += 1
|
|
169
|
+
for step in self._steps:
|
|
170
|
+
step.validate(sample)
|
|
171
|
+
step.step(sample, tracker)
|
|
172
|
+
collected.append(sample)
|
|
173
|
+
|
|
174
|
+
return collected, tracker
|
|
File without changes
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import pathlib
|
|
3
|
+
from typing import List, Dict, Any
|
|
4
|
+
|
|
5
|
+
from truthbench.pipeline import Reader
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class JsonReader(Reader):
|
|
9
|
+
"""
|
|
10
|
+
A reader that loads question-answer samples from a JSON file.
|
|
11
|
+
|
|
12
|
+
The JSON file must contain a list of dictionaries. Each dictionary must include
|
|
13
|
+
the following keys:
|
|
14
|
+
- "question" (str)
|
|
15
|
+
- "ground_truth" (str)
|
|
16
|
+
|
|
17
|
+
Example input JSON:
|
|
18
|
+
[
|
|
19
|
+
{"question": "What is Python?", "ground_truth": "A programming language."},
|
|
20
|
+
{"question": "What is 2+2?", "ground_truth": "4"}
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
Parameters:
|
|
24
|
+
input_file (pathlib.Path): Path to the input JSON file.
|
|
25
|
+
|
|
26
|
+
Raises:
|
|
27
|
+
ValueError: If the JSON is invalid, not a list of objects, or missing required keys.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, input_file: pathlib.Path):
|
|
31
|
+
self._input_file = input_file
|
|
32
|
+
|
|
33
|
+
def samples(self) -> List[Dict[str, Any]]:
|
|
34
|
+
with open(self._input_file, "r") as f:
|
|
35
|
+
content = f.read()
|
|
36
|
+
|
|
37
|
+
gold_dataset = json.loads(content)
|
|
38
|
+
|
|
39
|
+
if not isinstance(gold_dataset, list):
|
|
40
|
+
raise ValueError("Expected top-level JSON array (list of samples)")
|
|
41
|
+
|
|
42
|
+
samples = []
|
|
43
|
+
for d in gold_dataset:
|
|
44
|
+
if not isinstance(d, dict):
|
|
45
|
+
raise ValueError(f"Samples must be JSON objects")
|
|
46
|
+
if "question" not in d or "ground_truth" not in d:
|
|
47
|
+
raise ValueError(
|
|
48
|
+
f"Missing required keys: 'question' and 'ground_truth'"
|
|
49
|
+
)
|
|
50
|
+
samples.append({"question": d["question"], "ground_truth": d["ground_truth"]})
|
|
51
|
+
|
|
52
|
+
return samples
|
|
File without changes
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Set, Dict, Any
|
|
3
|
+
|
|
4
|
+
from truthbench.pipeline import Step
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class BlacklistItemsFromQuestionStep(Step):
|
|
8
|
+
"""
|
|
9
|
+
Pipeline step that filters out factual items which contain words already present in the question.
|
|
10
|
+
|
|
11
|
+
For each item in `raw_factual_data`, this step checks whether any token in the item
|
|
12
|
+
(split by whitespace) overlaps with the question tokens. If so, the item is added to
|
|
13
|
+
the `blacklisted` list in the sample. Tokens are lowercased and stripped of punctuation.
|
|
14
|
+
|
|
15
|
+
Stop words can be provided to exclude common words from the question during matching.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
- stop_words (Set[str]): A set of words to ignore when matching against the question.
|
|
19
|
+
|
|
20
|
+
Expected Sample Fields:
|
|
21
|
+
- question (str): The question text.
|
|
22
|
+
- raw_factual_data (List[str]): List of candidate factual items.
|
|
23
|
+
|
|
24
|
+
Modifies:
|
|
25
|
+
- sample["blacklisted"] (List[str] | None): List of lowercased blacklisted items,
|
|
26
|
+
or None if the question or factual data is empty.
|
|
27
|
+
|
|
28
|
+
Counter:
|
|
29
|
+
- Increments no counters
|
|
30
|
+
|
|
31
|
+
Notes:
|
|
32
|
+
- This step does not increment or modify the tracker.
|
|
33
|
+
- Matching is case-insensitive and ignores punctuation.
|
|
34
|
+
- If either `question` or `raw_factual_data` is empty, `blacklisted` is set to None.
|
|
35
|
+
|
|
36
|
+
Example:
|
|
37
|
+
question: "What is climate change?"
|
|
38
|
+
raw_factual_data: ["Climate models", "Carbon emissions", "Solar activity"]
|
|
39
|
+
-> blacklisted: ["climate models", "carbon emissions"]
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, stop_words: Set[str]):
|
|
43
|
+
self._stop_words = stop_words
|
|
44
|
+
super().__init__(
|
|
45
|
+
required_fields=frozenset({"question", "raw_factual_data"})
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def step(self, sample: Dict[str, Any], tracker: Dict[str, int]) -> None:
|
|
49
|
+
if not sample["question"] or not sample["raw_factual_data"]:
|
|
50
|
+
sample["blacklisted"] = None
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
# Simple tokenization of the question
|
|
54
|
+
# (strip punctuation, lowercase, then split on whitespace)
|
|
55
|
+
question_words = set(re.findall(r"\w+", sample["question"].lower()))
|
|
56
|
+
question_words = question_words - self._stop_words
|
|
57
|
+
sample["blacklisted"] = [
|
|
58
|
+
term.lower() for term in sample["raw_factual_data"]
|
|
59
|
+
if any(word.lower() in question_words for word in term.split())
|
|
60
|
+
]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from typing import Dict, Any
|
|
2
|
+
|
|
3
|
+
from truthbench.pipeline import Step
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CounterStep(Step):
|
|
7
|
+
"""
|
|
8
|
+
Pipeline step that counts samples with the expected number of answers.
|
|
9
|
+
|
|
10
|
+
This step checks whether the `answers` field exists and contains exactly the expected number
|
|
11
|
+
of answer levels. If so, it increments the `output_samples` counter in the tracker.
|
|
12
|
+
|
|
13
|
+
Attributes:
|
|
14
|
+
- expected_levels (int): The required number of answer levels for a valid sample.
|
|
15
|
+
|
|
16
|
+
Expected Sample Fields:
|
|
17
|
+
- answers (List[Any]): A list of answers to be validated.
|
|
18
|
+
|
|
19
|
+
Modifies:
|
|
20
|
+
- It does not modify any field.
|
|
21
|
+
|
|
22
|
+
Counter:
|
|
23
|
+
- tracker["output_samples"] (int): Incremented by 1 if the sample passes the check.
|
|
24
|
+
|
|
25
|
+
Notes:
|
|
26
|
+
- This step does not modify the sample.
|
|
27
|
+
- The sample is only counted if `answers` is present and its length matches the expected level.
|
|
28
|
+
- Does nothing if `answers` is None or its length does not match `expected_levels`.
|
|
29
|
+
|
|
30
|
+
Example:
|
|
31
|
+
expected_levels: 3
|
|
32
|
+
sample["answers"]: ["yes", "no", "maybe"]
|
|
33
|
+
-> tracker["output_samples"] += 1
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, expected_levels: int):
|
|
37
|
+
self._expected_levels = expected_levels
|
|
38
|
+
super().__init__(
|
|
39
|
+
required_fields=frozenset({"answers"}),
|
|
40
|
+
counters=frozenset({"output_samples"})
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def step(self, sample: Dict[str, Any], tracker: Dict[str, int]) -> None:
|
|
44
|
+
if sample["answers"] and len(sample["answers"]) == self._expected_levels:
|
|
45
|
+
tracker["output_samples"] += 1
|
|
46
|
+
return
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
import re
|
|
3
|
+
from typing import Union, Iterator, List, Tuple, Dict, Any
|
|
4
|
+
|
|
5
|
+
from spacy import Language, Errors
|
|
6
|
+
from spacy.symbols import NOUN, PROPN, ADV, ADJ, amod, NUM
|
|
7
|
+
from spacy.tokens import Doc, Span
|
|
8
|
+
|
|
9
|
+
from truthbench.pipeline import Step
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FactualChunker(abc.ABC):
|
|
13
|
+
"""
|
|
14
|
+
Extract factual components of a sentence.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
@abc.abstractmethod
|
|
18
|
+
def tag(self, sentence: str) -> str:
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class NounAdverbFactualChunker(FactualChunker):
|
|
23
|
+
"""
|
|
24
|
+
Implements a rule-based factual chunker that identifies textual spans likely to carry factual content
|
|
25
|
+
within a sentence, focusing on noun phrases and adverbial modifiers.
|
|
26
|
+
|
|
27
|
+
This chunker uses syntactic dependency parsing to detect spans such as:
|
|
28
|
+
- Direct objects, attributes, and complements of the main verb.
|
|
29
|
+
- Adverbial modifiers and prepositional phrases expressing circumstantial details.
|
|
30
|
+
- Appositional phrases and descriptive noun modifiers.
|
|
31
|
+
- Numerical expressions excluding those in the subject.
|
|
32
|
+
|
|
33
|
+
To avoid altering the core meaning of sentences, it excludes spans that belong to grammatical subjects.
|
|
34
|
+
For noun phrase subjects containing embedded relative clauses, only the head noun is excluded,
|
|
35
|
+
allowing modifiers in relative clauses to be eligible.
|
|
36
|
+
|
|
37
|
+
Coordination is handled by propagating eligibility from a conjunct to its siblings if the head
|
|
38
|
+
satisfies the criteria. The chunker also suppresses nested or overlapping spans to produce a clean,
|
|
39
|
+
non-redundant set of factual candidates.
|
|
40
|
+
|
|
41
|
+
The output is a bracketed string marking identified factual spans, forming an intermediate
|
|
42
|
+
representation for downstream filtering, ranking, or perturbation.
|
|
43
|
+
|
|
44
|
+
Example:
|
|
45
|
+
Input: "The government announced the new policy in 2021 with confidence."
|
|
46
|
+
Output: "The government announced [the new policy] in [2021] with [confidence]."
|
|
47
|
+
|
|
48
|
+
Methods:
|
|
49
|
+
- tag(sentence: str) -> str:
|
|
50
|
+
Returns the input sentence with factual spans bracketed.
|
|
51
|
+
|
|
52
|
+
Notes:
|
|
53
|
+
- Requires a syntactic dependency parse (e.g., from spaCy).
|
|
54
|
+
- Focuses on spans relevant for factual content modification.
|
|
55
|
+
- Does not modify spans related to sentence subjects to prevent meaning distortion.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, nlp: Language):
|
|
59
|
+
self._nlp = nlp
|
|
60
|
+
|
|
61
|
+
def span_boxes(self, doclike: Union[Doc, Span]) -> Iterator[Span]:
|
|
62
|
+
"""
|
|
63
|
+
Detect base noun phrases in the object and adverbs from a dependency parse.
|
|
64
|
+
"""
|
|
65
|
+
labels = [
|
|
66
|
+
"oprd",
|
|
67
|
+
"dobj",
|
|
68
|
+
"advmod",
|
|
69
|
+
"amod",
|
|
70
|
+
"npadvmod",
|
|
71
|
+
"pcomp",
|
|
72
|
+
"pobj",
|
|
73
|
+
"dative",
|
|
74
|
+
"appos",
|
|
75
|
+
"attr",
|
|
76
|
+
"ROOT",
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
doc = doclike.doc # Ensure works on both Doc and Span.
|
|
80
|
+
|
|
81
|
+
if not doc.has_annotation("DEP"):
|
|
82
|
+
raise ValueError(Errors.E029)
|
|
83
|
+
|
|
84
|
+
np_deps = [doc.vocab.strings.add(label) for label in labels]
|
|
85
|
+
conj = doc.vocab.strings.add("conj")
|
|
86
|
+
prev_end = -1
|
|
87
|
+
|
|
88
|
+
# Collect subject heads within the doclike (Span or Doc)
|
|
89
|
+
subject_heads = [token for token in doclike if token.dep_ in {'nsubj', 'nsubjpass'}]
|
|
90
|
+
|
|
91
|
+
# Collect indices of all tokens in their subtrees
|
|
92
|
+
subject_indices = set()
|
|
93
|
+
for head in subject_heads:
|
|
94
|
+
# Add all tokens in the subject head's subtree
|
|
95
|
+
head_subtree = {t.i for t in head.subtree}
|
|
96
|
+
subject_indices.update(head_subtree)
|
|
97
|
+
|
|
98
|
+
# Subtract tokens in relative clauses (relcl) attached to the subject head
|
|
99
|
+
for child in head.children:
|
|
100
|
+
if child.dep_ == "relcl":
|
|
101
|
+
relcl_subtree = {t.i for t in child.subtree}
|
|
102
|
+
subject_indices.difference_update(relcl_subtree)
|
|
103
|
+
|
|
104
|
+
for i, word in enumerate(doclike):
|
|
105
|
+
if word.pos not in (NOUN, PROPN, ADV, ADJ, NUM):
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
# Skip if part of the subject
|
|
109
|
+
if word.i in subject_indices:
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
if word.pos == ADJ and word.dep == amod and word.head.pos in (NOUN, PROPN):
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
# Prevent nested chunks from being produced
|
|
116
|
+
if word.left_edge.i <= prev_end:
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
if word.dep in np_deps or (word.pos == NUM and word.dep_ in ("nummod", "appos", "attr")):
|
|
120
|
+
prev_end = word.i
|
|
121
|
+
yield doc[word.left_edge.i:word.i + 1]
|
|
122
|
+
elif word.dep == conj:
|
|
123
|
+
head = word.head
|
|
124
|
+
|
|
125
|
+
while head.dep == conj and head.head.i < head.i:
|
|
126
|
+
head = head.head
|
|
127
|
+
|
|
128
|
+
# If the head is an NP, and we're coordinated to it, we're an NP
|
|
129
|
+
if head.dep in np_deps:
|
|
130
|
+
prev_end = word.i
|
|
131
|
+
yield doc[word.left_edge.i:word.i + 1]
|
|
132
|
+
|
|
133
|
+
def overlaps(self, idx: List[Tuple[int, int]]) -> bool:
|
|
134
|
+
sorted_intervals = sorted(idx)
|
|
135
|
+
return any(
|
|
136
|
+
current_end > next_start
|
|
137
|
+
for (_, current_end), (next_start, _) in zip(sorted_intervals, sorted_intervals[1:])
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def tag(self, sentence: str) -> str:
|
|
141
|
+
doc = self._nlp(sentence)
|
|
142
|
+
|
|
143
|
+
idx = []
|
|
144
|
+
for box in self.span_boxes(doc):
|
|
145
|
+
idx.append((min(b.idx for b in box), max(b.idx + len(b) for b in box)))
|
|
146
|
+
|
|
147
|
+
idx.sort(reverse=True)
|
|
148
|
+
|
|
149
|
+
assert not self.overlaps(idx), \
|
|
150
|
+
f"Something went wrong... Overlapping indexes for `{sentence}`"
|
|
151
|
+
|
|
152
|
+
boxed_sentence = sentence
|
|
153
|
+
for start, end in idx:
|
|
154
|
+
boxed_sentence = boxed_sentence[:start] + "[" + boxed_sentence[start:end] + "]" + boxed_sentence[end:]
|
|
155
|
+
|
|
156
|
+
return boxed_sentence
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class FactualDataStep(Step):
|
|
160
|
+
"""
|
|
161
|
+
Step that identifies factual data spans within an answer text by leveraging a
|
|
162
|
+
provided FactualChunker implementation. It marks spans in the answer likely to
|
|
163
|
+
contain factual content, brackets them, and extracts these spans for downstream processing.
|
|
164
|
+
|
|
165
|
+
Attributes:
|
|
166
|
+
- chunker (FactualChunker): An instance responsible for tagging factual spans in sentences.
|
|
167
|
+
|
|
168
|
+
Expected Sample Fields:
|
|
169
|
+
- answers (Dict[str, str]): A dictionary containing answer texts keyed by identifiers
|
|
170
|
+
(e.g., "A0"). This step processes the text under the "A0" key.
|
|
171
|
+
|
|
172
|
+
Modifies:
|
|
173
|
+
- sample["with_brackets"] (Dict[str, str] or None): Adds a dictionary mapping answer keys
|
|
174
|
+
to bracketed strings marking factual spans. Set to None if input is missing or invalid.
|
|
175
|
+
- sample["raw_factual_data"] (List[str] or None): Extracted factual spans as a list of strings.
|
|
176
|
+
Set to None if no factual spans are found.
|
|
177
|
+
|
|
178
|
+
Counter:
|
|
179
|
+
- find_factual_data_error: Incremented when no factual spans are detected in the answer text.
|
|
180
|
+
|
|
181
|
+
Notes:
|
|
182
|
+
- Relies on the injected FactualChunker to perform the actual span identification and tagging.
|
|
183
|
+
- If "answers" is missing or does not contain the key "A0", no processing occurs and relevant
|
|
184
|
+
fields are set to None.
|
|
185
|
+
- Extracted spans are obtained by regex matching bracketed sections in the tagged text.
|
|
186
|
+
|
|
187
|
+
Example:
|
|
188
|
+
sample = {
|
|
189
|
+
"answers": {"A0": "The government announced the new policy in 2021 with confidence."}
|
|
190
|
+
}
|
|
191
|
+
tracker = {"find_factual_data_error": 0}
|
|
192
|
+
|
|
193
|
+
chunker = NounAdverbFactualChunker(nlp)
|
|
194
|
+
step = FactualDataStep(chunker)
|
|
195
|
+
step.step(sample, tracker)
|
|
196
|
+
|
|
197
|
+
# After processing:
|
|
198
|
+
# sample["with_brackets"]["A0"] will be:
|
|
199
|
+
# "The government announced [the new policy] in [2021] with [confidence]."
|
|
200
|
+
# sample["raw_factual_data"] will be:
|
|
201
|
+
# ["the new policy", "2021", "confidence"]
|
|
202
|
+
# tracker["find_factual_data_error"] remains 0 because factual spans were found.
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
def __init__(self, chunker: FactualChunker):
|
|
206
|
+
self._chunker = chunker
|
|
207
|
+
super().__init__(
|
|
208
|
+
required_fields=frozenset({"answers"}),
|
|
209
|
+
counters=frozenset({"find_factual_data_error"})
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def step(self, sample: Dict[str, Any], tracker: Dict[str, int]) -> None:
|
|
213
|
+
if not sample["answers"] or "A0" not in sample["answers"].keys():
|
|
214
|
+
sample["with_brackets"] = None
|
|
215
|
+
sample["raw_factual_data"] = None
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
sample["with_brackets"] = {}
|
|
219
|
+
response_text = self._chunker.tag(sample["answers"]["A0"])
|
|
220
|
+
sample["with_brackets"]["A0"] = response_text
|
|
221
|
+
|
|
222
|
+
matches = re.findall(r"\[(.*?)]", response_text)
|
|
223
|
+
if not matches:
|
|
224
|
+
tracker["find_factual_data_error"] += 1
|
|
225
|
+
sample["raw_factual_data"] = None
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
sample["raw_factual_data"] = matches
|