radfact-lite 0.1.0__tar.gz
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.
- radfact_lite-0.1.0/LICENSE +21 -0
- radfact_lite-0.1.0/PKG-INFO +51 -0
- radfact_lite-0.1.0/README.md +36 -0
- radfact_lite-0.1.0/pyproject.toml +27 -0
- radfact_lite-0.1.0/radfact_lite.egg-info/PKG-INFO +51 -0
- radfact_lite-0.1.0/radfact_lite.egg-info/SOURCES.txt +29 -0
- radfact_lite-0.1.0/radfact_lite.egg-info/dependency_links.txt +1 -0
- radfact_lite-0.1.0/radfact_lite.egg-info/requires.txt +4 -0
- radfact_lite-0.1.0/radfact_lite.egg-info/top_level.txt +1 -0
- radfact_lite-0.1.0/setup.cfg +4 -0
- radfact_lite-0.1.0/src/__init__.py +48 -0
- radfact_lite-0.1.0/src/clients.py +77 -0
- radfact_lite-0.1.0/src/entailment.py +78 -0
- radfact_lite-0.1.0/src/metric.py +40 -0
- radfact_lite-0.1.0/src/negative_filter.py +39 -0
- radfact_lite-0.1.0/src/phrase_parser.py +47 -0
- radfact_lite-0.1.0/src/pipeline.py +74 -0
- radfact_lite-0.1.0/src/prompts/__init__.py +25 -0
- radfact_lite-0.1.0/src/prompts/entailment/toothfairy/few_shot_examples.json +17 -0
- radfact_lite-0.1.0/src/prompts/entailment/toothfairy/system_message_ev_singlephrase.txt +6 -0
- radfact_lite-0.1.0/src/prompts/negative_filtering/toothfairy/few_shot_examples.json +10 -0
- radfact_lite-0.1.0/src/prompts/negative_filtering/toothfairy/system_message.txt +3 -0
- radfact_lite-0.1.0/src/prompts/report_to_phrases/toothfairy/few_shot_examples.json +13 -0
- radfact_lite-0.1.0/src/prompts/report_to_phrases/toothfairy/system_message.txt +10 -0
- radfact_lite-0.1.0/src/rf_types.py +54 -0
- radfact_lite-0.1.0/tests/test_clients.py +62 -0
- radfact_lite-0.1.0/tests/test_entailment.py +56 -0
- radfact_lite-0.1.0/tests/test_metric.py +25 -0
- radfact_lite-0.1.0/tests/test_negative_filter.py +36 -0
- radfact_lite-0.1.0/tests/test_phrase_parser.py +41 -0
- radfact_lite-0.1.0/tests/test_pipeline_flow.py +44 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Luca Lumetti
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: radfact-lite
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight text-only RadFact metric with OpenAI and Ollama backends
|
|
5
|
+
Author-email: Luca Lumetti <lumetti.luca@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/LucaLumetti/radfact_lite
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: openai>=1.0.0
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest; extra == "dev"
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# radfact-lite
|
|
17
|
+
|
|
18
|
+
Lightweight, text-only [RadFact](https://github.com/microsoft/RadFact) with OpenAI and Ollama backends.
|
|
19
|
+
|
|
20
|
+
Parses reports into phrases, optionally filters negative findings, then scores bidirectional LLM entailment as logical precision / recall / F1.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install radfact-lite
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from radfact_lite import ModelConfig, PipelineModels, RadFactLitePipeline
|
|
32
|
+
|
|
33
|
+
cfg = ModelConfig(model="gpt-4o-mini", provider="openai") # reads OPENAI_API_KEY
|
|
34
|
+
models = PipelineModels(parse_model=cfg, entailment_model=cfg, filtering_model=cfg)
|
|
35
|
+
pipeline = RadFactLitePipeline(models)
|
|
36
|
+
|
|
37
|
+
aggregate, per_sample = pipeline.compute_radfact(
|
|
38
|
+
candidates_by_id={"id1": "No pleural effusion. Mild bibasilar atelectasis."},
|
|
39
|
+
references_by_id={"id1": "Mild bibasilar atelectasis. No pneumothorax."},
|
|
40
|
+
filter_negatives=True,
|
|
41
|
+
)
|
|
42
|
+
print(aggregate.logical_f1)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
For Ollama: `ModelConfig(model="llama3.1", provider="ollama")` (default base URL `http://localhost:11434/v1`).
|
|
46
|
+
|
|
47
|
+
Pass pre-parsed phrase lists instead of narrative text with `is_narrative_text=False`.
|
|
48
|
+
|
|
49
|
+
## New modalities
|
|
50
|
+
|
|
51
|
+
Add prompt files under `src/prompts/{report_to_phrases,negative_filtering,entailment}/<modality>/` and a value to `ReportType` in `src/rf_types.py`.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# radfact-lite
|
|
2
|
+
|
|
3
|
+
Lightweight, text-only [RadFact](https://github.com/microsoft/RadFact) with OpenAI and Ollama backends.
|
|
4
|
+
|
|
5
|
+
Parses reports into phrases, optionally filters negative findings, then scores bidirectional LLM entailment as logical precision / recall / F1.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install radfact-lite
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from radfact_lite import ModelConfig, PipelineModels, RadFactLitePipeline
|
|
17
|
+
|
|
18
|
+
cfg = ModelConfig(model="gpt-4o-mini", provider="openai") # reads OPENAI_API_KEY
|
|
19
|
+
models = PipelineModels(parse_model=cfg, entailment_model=cfg, filtering_model=cfg)
|
|
20
|
+
pipeline = RadFactLitePipeline(models)
|
|
21
|
+
|
|
22
|
+
aggregate, per_sample = pipeline.compute_radfact(
|
|
23
|
+
candidates_by_id={"id1": "No pleural effusion. Mild bibasilar atelectasis."},
|
|
24
|
+
references_by_id={"id1": "Mild bibasilar atelectasis. No pneumothorax."},
|
|
25
|
+
filter_negatives=True,
|
|
26
|
+
)
|
|
27
|
+
print(aggregate.logical_f1)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
For Ollama: `ModelConfig(model="llama3.1", provider="ollama")` (default base URL `http://localhost:11434/v1`).
|
|
31
|
+
|
|
32
|
+
Pass pre-parsed phrase lists instead of narrative text with `is_narrative_text=False`.
|
|
33
|
+
|
|
34
|
+
## New modalities
|
|
35
|
+
|
|
36
|
+
Add prompt files under `src/prompts/{report_to_phrases,negative_filtering,entailment}/<modality>/` and a value to `ReportType` in `src/rf_types.py`.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "radfact-lite"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Lightweight text-only RadFact metric with OpenAI and Ollama backends"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "Luca Lumetti", email = "lumetti.luca@gmail.com" }]
|
|
13
|
+
requires-python = ">=3.10"
|
|
14
|
+
dependencies = ["openai>=1.0.0"]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Repository = "https://github.com/LucaLumetti/radfact_lite"
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
dev = ["pytest"]
|
|
21
|
+
|
|
22
|
+
[tool.setuptools]
|
|
23
|
+
package-dir = {"radfact_lite" = "src"}
|
|
24
|
+
packages = ["radfact_lite", "radfact_lite.prompts"]
|
|
25
|
+
|
|
26
|
+
[tool.setuptools.package-data]
|
|
27
|
+
"radfact_lite.prompts" = ["**/*.txt", "**/*.json"]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: radfact-lite
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight text-only RadFact metric with OpenAI and Ollama backends
|
|
5
|
+
Author-email: Luca Lumetti <lumetti.luca@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/LucaLumetti/radfact_lite
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: openai>=1.0.0
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest; extra == "dev"
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# radfact-lite
|
|
17
|
+
|
|
18
|
+
Lightweight, text-only [RadFact](https://github.com/microsoft/RadFact) with OpenAI and Ollama backends.
|
|
19
|
+
|
|
20
|
+
Parses reports into phrases, optionally filters negative findings, then scores bidirectional LLM entailment as logical precision / recall / F1.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install radfact-lite
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from radfact_lite import ModelConfig, PipelineModels, RadFactLitePipeline
|
|
32
|
+
|
|
33
|
+
cfg = ModelConfig(model="gpt-4o-mini", provider="openai") # reads OPENAI_API_KEY
|
|
34
|
+
models = PipelineModels(parse_model=cfg, entailment_model=cfg, filtering_model=cfg)
|
|
35
|
+
pipeline = RadFactLitePipeline(models)
|
|
36
|
+
|
|
37
|
+
aggregate, per_sample = pipeline.compute_radfact(
|
|
38
|
+
candidates_by_id={"id1": "No pleural effusion. Mild bibasilar atelectasis."},
|
|
39
|
+
references_by_id={"id1": "Mild bibasilar atelectasis. No pneumothorax."},
|
|
40
|
+
filter_negatives=True,
|
|
41
|
+
)
|
|
42
|
+
print(aggregate.logical_f1)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
For Ollama: `ModelConfig(model="llama3.1", provider="ollama")` (default base URL `http://localhost:11434/v1`).
|
|
46
|
+
|
|
47
|
+
Pass pre-parsed phrase lists instead of narrative text with `is_narrative_text=False`.
|
|
48
|
+
|
|
49
|
+
## New modalities
|
|
50
|
+
|
|
51
|
+
Add prompt files under `src/prompts/{report_to_phrases,negative_filtering,entailment}/<modality>/` and a value to `ReportType` in `src/rf_types.py`.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
radfact_lite.egg-info/PKG-INFO
|
|
5
|
+
radfact_lite.egg-info/SOURCES.txt
|
|
6
|
+
radfact_lite.egg-info/dependency_links.txt
|
|
7
|
+
radfact_lite.egg-info/requires.txt
|
|
8
|
+
radfact_lite.egg-info/top_level.txt
|
|
9
|
+
src/__init__.py
|
|
10
|
+
src/clients.py
|
|
11
|
+
src/entailment.py
|
|
12
|
+
src/metric.py
|
|
13
|
+
src/negative_filter.py
|
|
14
|
+
src/phrase_parser.py
|
|
15
|
+
src/pipeline.py
|
|
16
|
+
src/rf_types.py
|
|
17
|
+
src/prompts/__init__.py
|
|
18
|
+
src/prompts/entailment/toothfairy/few_shot_examples.json
|
|
19
|
+
src/prompts/entailment/toothfairy/system_message_ev_singlephrase.txt
|
|
20
|
+
src/prompts/negative_filtering/toothfairy/few_shot_examples.json
|
|
21
|
+
src/prompts/negative_filtering/toothfairy/system_message.txt
|
|
22
|
+
src/prompts/report_to_phrases/toothfairy/few_shot_examples.json
|
|
23
|
+
src/prompts/report_to_phrases/toothfairy/system_message.txt
|
|
24
|
+
tests/test_clients.py
|
|
25
|
+
tests/test_entailment.py
|
|
26
|
+
tests/test_metric.py
|
|
27
|
+
tests/test_negative_filter.py
|
|
28
|
+
tests/test_phrase_parser.py
|
|
29
|
+
tests/test_pipeline_flow.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
radfact_lite
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .clients import JSONClient, Message, OllamaJSONClient, OpenAIJSONClient, build_json_client
|
|
4
|
+
from .negative_filter import filter_positive_findings
|
|
5
|
+
from .pipeline import RadFactLitePipeline
|
|
6
|
+
from .rf_types import (
|
|
7
|
+
EntailmentDecision,
|
|
8
|
+
ModelConfig,
|
|
9
|
+
PipelineModels,
|
|
10
|
+
RadFactAggregateResult,
|
|
11
|
+
RadFactSampleResult,
|
|
12
|
+
ReportType,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def compute_radfact_text(
|
|
17
|
+
candidates_by_id: dict[str, list[str] | str],
|
|
18
|
+
references_by_id: dict[str, list[str] | str],
|
|
19
|
+
models: PipelineModels,
|
|
20
|
+
report_type: ReportType = ReportType.TOOTHFAIRY,
|
|
21
|
+
is_narrative_text: bool = True,
|
|
22
|
+
filter_negatives: bool = False,
|
|
23
|
+
) -> dict[str, float | int]:
|
|
24
|
+
pipeline = RadFactLitePipeline(models=models, report_type=report_type)
|
|
25
|
+
return pipeline.compute_radfact_dict(
|
|
26
|
+
candidates_by_id=candidates_by_id,
|
|
27
|
+
references_by_id=references_by_id,
|
|
28
|
+
is_narrative_text=is_narrative_text,
|
|
29
|
+
filter_negatives=filter_negatives,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"EntailmentDecision",
|
|
35
|
+
"JSONClient",
|
|
36
|
+
"Message",
|
|
37
|
+
"ModelConfig",
|
|
38
|
+
"OllamaJSONClient",
|
|
39
|
+
"OpenAIJSONClient",
|
|
40
|
+
"PipelineModels",
|
|
41
|
+
"RadFactAggregateResult",
|
|
42
|
+
"RadFactLitePipeline",
|
|
43
|
+
"RadFactSampleResult",
|
|
44
|
+
"ReportType",
|
|
45
|
+
"build_json_client",
|
|
46
|
+
"compute_radfact_text",
|
|
47
|
+
"filter_positive_findings",
|
|
48
|
+
]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Protocol
|
|
7
|
+
|
|
8
|
+
from openai import OpenAI
|
|
9
|
+
|
|
10
|
+
from .rf_types import ModelConfig
|
|
11
|
+
|
|
12
|
+
OLLAMA_DEFAULT_BASE_URL = "http://localhost:11434/v1"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Message:
|
|
17
|
+
role: str
|
|
18
|
+
content: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class JSONClient(Protocol):
|
|
22
|
+
def complete_json(self, messages: list[Message], response_format: dict[str, Any]) -> dict[str, Any]:
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _ChatJSONClient:
|
|
27
|
+
def __init__(self, model: str, **client_kwargs: Any) -> None:
|
|
28
|
+
self.model = model
|
|
29
|
+
self.client = OpenAI(**client_kwargs)
|
|
30
|
+
|
|
31
|
+
def complete_json(self, messages: list[Message], response_format: dict[str, Any]) -> dict[str, Any]:
|
|
32
|
+
response = self.client.chat.completions.create(
|
|
33
|
+
model=self.model,
|
|
34
|
+
messages=[{"role": m.role, "content": m.content} for m in messages],
|
|
35
|
+
response_format=response_format,
|
|
36
|
+
temperature=0,
|
|
37
|
+
)
|
|
38
|
+
content = response.choices[0].message.content
|
|
39
|
+
if content is None:
|
|
40
|
+
raise ValueError("Empty response content from model")
|
|
41
|
+
try:
|
|
42
|
+
return json.loads(content)
|
|
43
|
+
except json.JSONDecodeError as exc:
|
|
44
|
+
raise ValueError(f"Invalid JSON response from model: {content}") from exc
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class OpenAIJSONClient(_ChatJSONClient):
|
|
48
|
+
def __init__(self, config: ModelConfig) -> None:
|
|
49
|
+
api_key = os.environ.get(config.api_key_env_var)
|
|
50
|
+
if not api_key:
|
|
51
|
+
raise ValueError(f"Missing API key in env var '{config.api_key_env_var}'")
|
|
52
|
+
super().__init__(
|
|
53
|
+
config.model,
|
|
54
|
+
api_key=api_key,
|
|
55
|
+
base_url=config.base_url,
|
|
56
|
+
timeout=config.timeout,
|
|
57
|
+
max_retries=config.max_retries,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class OllamaJSONClient(_ChatJSONClient):
|
|
62
|
+
def __init__(self, config: ModelConfig) -> None:
|
|
63
|
+
super().__init__(
|
|
64
|
+
config.model,
|
|
65
|
+
api_key="ollama",
|
|
66
|
+
base_url=config.base_url or OLLAMA_DEFAULT_BASE_URL,
|
|
67
|
+
timeout=config.timeout,
|
|
68
|
+
max_retries=config.max_retries,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def build_json_client(config: ModelConfig) -> JSONClient:
|
|
73
|
+
if config.provider == "openai":
|
|
74
|
+
return OpenAIJSONClient(config)
|
|
75
|
+
if config.provider == "ollama":
|
|
76
|
+
return OllamaJSONClient(config)
|
|
77
|
+
raise ValueError(f"Unsupported provider '{config.provider}'")
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from .clients import JSONClient, Message
|
|
7
|
+
from .prompts import load_few_shot_json, load_system_prompt
|
|
8
|
+
from .rf_types import EntailmentDecision, ReportType
|
|
9
|
+
|
|
10
|
+
_ENTAILMENT_SCHEMA = {
|
|
11
|
+
"type": "json_schema",
|
|
12
|
+
"json_schema": {
|
|
13
|
+
"name": "evidenced_phrase",
|
|
14
|
+
"strict": True,
|
|
15
|
+
"schema": {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"properties": {
|
|
18
|
+
"phrase": {"type": "string"},
|
|
19
|
+
"evidence": {"type": "array", "items": {"type": "string"}},
|
|
20
|
+
"status": {"type": "string", "enum": ["entailment", "not_entailment"]},
|
|
21
|
+
},
|
|
22
|
+
"required": ["phrase", "evidence", "status"],
|
|
23
|
+
"additionalProperties": False,
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def normalize_text(text: str) -> str:
|
|
30
|
+
text = text.lower().strip()
|
|
31
|
+
text = re.sub(r"\s+", " ", text)
|
|
32
|
+
text = re.sub(r"[\-–—]", "", text)
|
|
33
|
+
text = re.sub(r"\s*(?<=[\.\:\!\?])", "", text)
|
|
34
|
+
text = re.sub(r"[\.\:\!\?]$", "", text)
|
|
35
|
+
return text
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def assess_entailment(
|
|
39
|
+
client: JSONClient,
|
|
40
|
+
reference_phrases: list[str],
|
|
41
|
+
hypothesis_phrase: str,
|
|
42
|
+
report_type: ReportType,
|
|
43
|
+
) -> EntailmentDecision:
|
|
44
|
+
system = load_system_prompt("entailment", report_type, "system_message_ev_singlephrase.txt")
|
|
45
|
+
few_shots = load_few_shot_json("entailment", report_type)
|
|
46
|
+
messages = [Message(role="system", content=system)]
|
|
47
|
+
|
|
48
|
+
for shot in few_shots:
|
|
49
|
+
for pair in _expand_single_phrase_examples(shot):
|
|
50
|
+
messages.append(Message(role="user", content=json.dumps(pair["input"], ensure_ascii=False)))
|
|
51
|
+
messages.append(Message(role="assistant", content=json.dumps(pair["output"], ensure_ascii=False)))
|
|
52
|
+
|
|
53
|
+
query = {"reference": reference_phrases, "hypothesis": hypothesis_phrase}
|
|
54
|
+
messages.append(Message(role="user", content=json.dumps(query, ensure_ascii=False)))
|
|
55
|
+
out = client.complete_json(messages=messages, response_format=_ENTAILMENT_SCHEMA)
|
|
56
|
+
phrase = out["phrase"]
|
|
57
|
+
if normalize_text(phrase) != normalize_text(hypothesis_phrase):
|
|
58
|
+
phrase = hypothesis_phrase
|
|
59
|
+
return EntailmentDecision(phrase=phrase, status=out["status"], evidence=out["evidence"])
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _expand_single_phrase_examples(example: dict) -> list[dict]:
|
|
63
|
+
result = []
|
|
64
|
+
phrases_a = example["input"]["phrases_A"]
|
|
65
|
+
phrases_b = example["input"]["phrases_B"]
|
|
66
|
+
for evidenced in example["output"]["phrases_A_evidenced"]:
|
|
67
|
+
result.append({"input": {"reference": phrases_b, "hypothesis": evidenced["phrase"]}, "output": _binary_output(evidenced)})
|
|
68
|
+
for evidenced in example["output"]["phrases_B_evidenced"]:
|
|
69
|
+
result.append({"input": {"reference": phrases_a, "hypothesis": evidenced["phrase"]}, "output": _binary_output(evidenced)})
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _binary_output(evidenced_phrase: dict) -> dict:
|
|
74
|
+
return {
|
|
75
|
+
"phrase": evidenced_phrase["phrase"],
|
|
76
|
+
"status": "entailment" if evidenced_phrase["status"] == "entailment" else "not_entailment",
|
|
77
|
+
"evidence": evidenced_phrase.get("evidence", []),
|
|
78
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .rf_types import RadFactAggregateResult, RadFactSampleResult
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def divide_or_zero(numerator: int, denominator: int) -> float:
|
|
7
|
+
return 0.0 if denominator == 0 else numerator / denominator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def f1(precision: float, recall: float) -> float:
|
|
11
|
+
return 0.0 if (precision + recall) == 0 else 2 * (precision * recall) / (precision + recall)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def sample_result(
|
|
15
|
+
sample_id: str,
|
|
16
|
+
entailed_candidate_count: int,
|
|
17
|
+
entailed_reference_count: int,
|
|
18
|
+
candidate_count: int,
|
|
19
|
+
reference_count: int,
|
|
20
|
+
) -> RadFactSampleResult:
|
|
21
|
+
precision = divide_or_zero(entailed_candidate_count, candidate_count)
|
|
22
|
+
recall = divide_or_zero(entailed_reference_count, reference_count)
|
|
23
|
+
return RadFactSampleResult(
|
|
24
|
+
sample_id=sample_id,
|
|
25
|
+
logical_precision=precision,
|
|
26
|
+
logical_recall=recall,
|
|
27
|
+
logical_f1=f1(precision, recall),
|
|
28
|
+
entailed_candidate_count=entailed_candidate_count,
|
|
29
|
+
entailed_reference_count=entailed_reference_count,
|
|
30
|
+
candidate_count=candidate_count,
|
|
31
|
+
reference_count=reference_count,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def aggregate_results(results: list[RadFactSampleResult], num_llm_failures: int) -> RadFactAggregateResult:
|
|
36
|
+
if not results:
|
|
37
|
+
return RadFactAggregateResult(0.0, 0.0, 0.0, 0, num_llm_failures)
|
|
38
|
+
mean_precision = sum(x.logical_precision for x in results) / len(results)
|
|
39
|
+
mean_recall = sum(x.logical_recall for x in results) / len(results)
|
|
40
|
+
return RadFactAggregateResult(mean_precision, mean_recall, f1(mean_precision, mean_recall), len(results), num_llm_failures)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from .clients import JSONClient, Message
|
|
6
|
+
from .prompts import load_few_shot_json, load_system_prompt
|
|
7
|
+
from .rf_types import ReportType
|
|
8
|
+
|
|
9
|
+
_FILTER_SCHEMA = {
|
|
10
|
+
"type": "json_schema",
|
|
11
|
+
"json_schema": {
|
|
12
|
+
"name": "positive_phrases",
|
|
13
|
+
"strict": True,
|
|
14
|
+
"schema": {
|
|
15
|
+
"type": "object",
|
|
16
|
+
"properties": {"phrases": {"type": "array", "items": {"type": "string"}}},
|
|
17
|
+
"required": ["phrases"],
|
|
18
|
+
"additionalProperties": False,
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def filter_positive_findings(
|
|
25
|
+
client: JSONClient,
|
|
26
|
+
phrases: list[str],
|
|
27
|
+
report_type: ReportType = ReportType.TOOTHFAIRY,
|
|
28
|
+
) -> list[str]:
|
|
29
|
+
system = load_system_prompt("negative_filtering", report_type, "system_message.txt")
|
|
30
|
+
few_shots = load_few_shot_json("negative_filtering", report_type)
|
|
31
|
+
messages = [Message(role="system", content=system)]
|
|
32
|
+
for shot in few_shots:
|
|
33
|
+
messages.append(Message(role="user", content=json.dumps(shot["input"], ensure_ascii=False)))
|
|
34
|
+
messages.append(Message(role="assistant", content=json.dumps(shot["output"], ensure_ascii=False)))
|
|
35
|
+
messages.append(Message(role="user", content=json.dumps(phrases, ensure_ascii=False)))
|
|
36
|
+
parsed = client.complete_json(messages=messages, response_format=_FILTER_SCHEMA)
|
|
37
|
+
|
|
38
|
+
original_set = set(phrases)
|
|
39
|
+
return [p for p in parsed["phrases"] if p in original_set]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from .clients import JSONClient, Message
|
|
6
|
+
from .prompts import load_few_shot_json, load_system_prompt
|
|
7
|
+
from .rf_types import ReportType
|
|
8
|
+
|
|
9
|
+
_PARSE_SCHEMA = {
|
|
10
|
+
"type": "json_schema",
|
|
11
|
+
"json_schema": {
|
|
12
|
+
"name": "parsed_report",
|
|
13
|
+
"strict": True,
|
|
14
|
+
"schema": {
|
|
15
|
+
"type": "object",
|
|
16
|
+
"properties": {
|
|
17
|
+
"sentence_list": {
|
|
18
|
+
"type": "array",
|
|
19
|
+
"items": {
|
|
20
|
+
"type": "object",
|
|
21
|
+
"properties": {
|
|
22
|
+
"orig": {"type": "string"},
|
|
23
|
+
"new": {"type": "array", "items": {"type": "string"}},
|
|
24
|
+
},
|
|
25
|
+
"required": ["orig", "new"],
|
|
26
|
+
"additionalProperties": False,
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"required": ["sentence_list"],
|
|
31
|
+
"additionalProperties": False,
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_report_to_phrases(client: JSONClient, report_text: str, report_type: ReportType) -> list[str]:
|
|
38
|
+
system = load_system_prompt("report_to_phrases", report_type, "system_message.txt")
|
|
39
|
+
few_shots = load_few_shot_json("report_to_phrases", report_type)
|
|
40
|
+
messages = [Message(role="system", content=system)]
|
|
41
|
+
for shot in few_shots:
|
|
42
|
+
messages.append(Message(role="user", content=shot["findings_text"]))
|
|
43
|
+
messages.append(Message(role="assistant", content=json.dumps(shot["parsed_report"], ensure_ascii=False)))
|
|
44
|
+
messages.append(Message(role="user", content=report_text))
|
|
45
|
+
parsed = client.complete_json(messages=messages, response_format=_PARSE_SCHEMA)
|
|
46
|
+
|
|
47
|
+
return [clean for sentence in parsed["sentence_list"] for phrase in sentence["new"] if (clean := phrase.strip())]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict
|
|
4
|
+
|
|
5
|
+
from .clients import build_json_client
|
|
6
|
+
from .entailment import assess_entailment
|
|
7
|
+
from .metric import aggregate_results, sample_result
|
|
8
|
+
from .negative_filter import filter_positive_findings
|
|
9
|
+
from .phrase_parser import parse_report_to_phrases
|
|
10
|
+
from .rf_types import PipelineModels, RadFactAggregateResult, RadFactSampleResult, ReportType
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RadFactLitePipeline:
|
|
14
|
+
def __init__(self, models: PipelineModels, report_type: ReportType = ReportType.TOOTHFAIRY) -> None:
|
|
15
|
+
self.report_type = report_type
|
|
16
|
+
self._parse_client = build_json_client(models.parse_model)
|
|
17
|
+
self._entailment_client = build_json_client(models.entailment_model)
|
|
18
|
+
self._filtering_client = build_json_client(models.filtering_model) if models.filtering_model else None
|
|
19
|
+
|
|
20
|
+
def parse_reports(self, reports_by_id: dict[str, str]) -> dict[str, list[str]]:
|
|
21
|
+
return {k: parse_report_to_phrases(self._parse_client, v, self.report_type) for k, v in reports_by_id.items()}
|
|
22
|
+
|
|
23
|
+
def filter_negatives(self, phrases_by_id: dict[str, list[str]]) -> dict[str, list[str]]:
|
|
24
|
+
if self._filtering_client is None:
|
|
25
|
+
raise ValueError("No filtering model configured")
|
|
26
|
+
return {k: filter_positive_findings(self._filtering_client, v, self.report_type) for k, v in phrases_by_id.items()}
|
|
27
|
+
|
|
28
|
+
def compute_radfact(
|
|
29
|
+
self,
|
|
30
|
+
candidates_by_id: dict[str, list[str] | str],
|
|
31
|
+
references_by_id: dict[str, list[str] | str],
|
|
32
|
+
is_narrative_text: bool = True,
|
|
33
|
+
filter_negatives: bool = False,
|
|
34
|
+
) -> tuple[RadFactAggregateResult, list[RadFactSampleResult]]:
|
|
35
|
+
common = sorted(set(candidates_by_id.keys()).intersection(references_by_id.keys()))
|
|
36
|
+
if is_narrative_text:
|
|
37
|
+
parsed_candidates = self.parse_reports({k: str(candidates_by_id[k]) for k in common})
|
|
38
|
+
parsed_references = self.parse_reports({k: str(references_by_id[k]) for k in common})
|
|
39
|
+
else:
|
|
40
|
+
parsed_candidates = {k: list(candidates_by_id[k]) for k in common} # type: ignore[arg-type]
|
|
41
|
+
parsed_references = {k: list(references_by_id[k]) for k in common} # type: ignore[arg-type]
|
|
42
|
+
|
|
43
|
+
if filter_negatives:
|
|
44
|
+
parsed_candidates = self.filter_negatives(parsed_candidates)
|
|
45
|
+
parsed_references = self.filter_negatives(parsed_references)
|
|
46
|
+
|
|
47
|
+
results: list[RadFactSampleResult] = []
|
|
48
|
+
llm_failures = 0
|
|
49
|
+
for sample_id in common:
|
|
50
|
+
candidate_phrases = parsed_candidates[sample_id]
|
|
51
|
+
reference_phrases = parsed_references[sample_id]
|
|
52
|
+
entailed_candidate = 0
|
|
53
|
+
entailed_reference = 0
|
|
54
|
+
|
|
55
|
+
for phrase in candidate_phrases:
|
|
56
|
+
try:
|
|
57
|
+
if assess_entailment(self._entailment_client, reference_phrases, phrase, self.report_type).status == "entailment":
|
|
58
|
+
entailed_candidate += 1
|
|
59
|
+
except Exception:
|
|
60
|
+
llm_failures += 1
|
|
61
|
+
for phrase in reference_phrases:
|
|
62
|
+
try:
|
|
63
|
+
if assess_entailment(self._entailment_client, candidate_phrases, phrase, self.report_type).status == "entailment":
|
|
64
|
+
entailed_reference += 1
|
|
65
|
+
except Exception:
|
|
66
|
+
llm_failures += 1
|
|
67
|
+
|
|
68
|
+
results.append(sample_result(sample_id, entailed_candidate, entailed_reference, len(candidate_phrases), len(reference_phrases)))
|
|
69
|
+
|
|
70
|
+
return aggregate_results(results, llm_failures), results
|
|
71
|
+
|
|
72
|
+
def compute_radfact_dict(self, *args: object, **kwargs: object) -> dict[str, float | int]:
|
|
73
|
+
aggregate, _ = self.compute_radfact(*args, **kwargs) # type: ignore[misc]
|
|
74
|
+
return asdict(aggregate)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ..rf_types import ReportType
|
|
8
|
+
|
|
9
|
+
_ROOT = Path(__file__).resolve().parent
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _modality_dir(task: str, report_type: ReportType) -> Path:
|
|
13
|
+
path = _ROOT / task / report_type.value
|
|
14
|
+
if not path.is_dir():
|
|
15
|
+
available = sorted(p.name for p in (_ROOT / task).iterdir() if p.is_dir())
|
|
16
|
+
raise ValueError(f"No prompts for task='{task}', modality='{report_type.value}'. Available: {available}")
|
|
17
|
+
return path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_system_prompt(task: str, report_type: ReportType, filename: str) -> str:
|
|
21
|
+
return (_modality_dir(task, report_type) / filename).read_text(encoding="utf-8")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_few_shot_json(task: str, report_type: ReportType) -> list[dict[str, Any]]:
|
|
25
|
+
return json.loads((_modality_dir(task, report_type) / "few_shot_examples.json").read_text(encoding="utf-8"))
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"input": {
|
|
4
|
+
"phrases_A": ["Complete opacification of the right maxillary sinus is consistent with inflammatory material as a sequela of sinusitis."],
|
|
5
|
+
"phrases_B": ["The right maxillary sinus is completely airless on imaging.", "The right max. sinus is consistent with inflammatory changes from sinusitis."]
|
|
6
|
+
},
|
|
7
|
+
"output": {
|
|
8
|
+
"phrases_A_evidenced": [
|
|
9
|
+
{"phrase": "Complete opacification of the right maxillary sinus is consistent with inflammatory material as a sequela of sinusitis.", "status": "entailment", "evidence": ["The right maxillary sinus is completely airless on imaging.", "The right max. sinus is consistent with inflammatory changes from sinusitis."]}
|
|
10
|
+
],
|
|
11
|
+
"phrases_B_evidenced": [
|
|
12
|
+
{"phrase": "The right maxillary sinus is completely airless on imaging.", "status": "entailment", "evidence": ["Complete opacification of the right maxillary sinus is consistent with inflammatory material as a sequela of sinusitis."]},
|
|
13
|
+
{"phrase": "The right max. sinus is consistent with inflammatory changes from sinusitis.", "status": "entailment", "evidence": ["Complete opacification of the right maxillary sinus is consistent with inflammatory material as a sequela of sinusitis."]}
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"input": ["No pneumothorax.", "Mild right pleural effusion.", "Heart size is normal."],
|
|
4
|
+
"output": {"phrases": ["Mild right pleural effusion."]}
|
|
5
|
+
},
|
|
6
|
+
{
|
|
7
|
+
"input": ["All teeth are present.", "Implant is correctly osseointegrated.", "Sinus are present in the scan.", "Prosthetic crown on tooth 31."],
|
|
8
|
+
"output": {"phrases": ["Prosthetic crown on tooth 31."]}
|
|
9
|
+
}
|
|
10
|
+
]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"example_id": "few_shot_1",
|
|
4
|
+
"findings_text": "Absence from the dental arch of teeth 4.7 and 4.8. Presence of an endosseous implant in position 4.6, apparently correctly osseointegrated. Prosthetic crown on the endosseous implant in position 4.6 and on teeth 3.4, 3.5, and 3.6.",
|
|
5
|
+
"parsed_report": {
|
|
6
|
+
"id": "few_shot_1",
|
|
7
|
+
"sentence_list": [
|
|
8
|
+
{"orig": "Absence from the dental arch of teeth 4.7 and 4.8.", "new": ["Absence of tooth 4.7.", "Absence of tooth 4.8."]},
|
|
9
|
+
{"orig": "Presence of an endosseous implant in position 4.6, apparently correctly osseointegrated.", "new": ["Presence of an endosseous implant in position 4.6.", "The endosseous implant in position 4.6 is apparently correctly osseointegrated."]}
|
|
10
|
+
]
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
You are an AI radiology assistant. You are helping process CBCT reports focussed on the maxillofacial region.
|
|
2
|
+
Please extract phrases from the report which refer to findings or anatomies visible on maxillofacial CBCT, including absence findings.
|
|
3
|
+
|
|
4
|
+
Rules:
|
|
5
|
+
- Split multi-finding sentences into separate phrases.
|
|
6
|
+
- Exclude recommendations and non-visual speculation.
|
|
7
|
+
- Exclude scan quality comments.
|
|
8
|
+
- Keep change statements.
|
|
9
|
+
|
|
10
|
+
The objective is to extract phrases that can be directly verified on the CBCT.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ReportType(str, Enum):
|
|
9
|
+
TOOTHFAIRY = "toothfairy"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class ModelConfig:
|
|
14
|
+
model: str
|
|
15
|
+
provider: Literal["openai", "ollama"] = "openai"
|
|
16
|
+
api_key_env_var: str = "OPENAI_API_KEY"
|
|
17
|
+
base_url: str | None = None
|
|
18
|
+
timeout: float = 60.0
|
|
19
|
+
max_retries: int = 3
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class PipelineModels:
|
|
24
|
+
parse_model: ModelConfig
|
|
25
|
+
entailment_model: ModelConfig
|
|
26
|
+
filtering_model: ModelConfig | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class EntailmentDecision:
|
|
31
|
+
phrase: str
|
|
32
|
+
status: str
|
|
33
|
+
evidence: list[str]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class RadFactSampleResult:
|
|
38
|
+
sample_id: str
|
|
39
|
+
logical_precision: float
|
|
40
|
+
logical_recall: float
|
|
41
|
+
logical_f1: float
|
|
42
|
+
entailed_candidate_count: int
|
|
43
|
+
entailed_reference_count: int
|
|
44
|
+
candidate_count: int
|
|
45
|
+
reference_count: int
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class RadFactAggregateResult:
|
|
50
|
+
logical_precision: float
|
|
51
|
+
logical_recall: float
|
|
52
|
+
logical_f1: float
|
|
53
|
+
num_samples: int
|
|
54
|
+
num_llm_failures: int
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from radfact_lite.clients import Message, OllamaJSONClient, OpenAIJSONClient, build_json_client
|
|
4
|
+
from radfact_lite.rf_types import ModelConfig
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class _FakeMessage:
|
|
8
|
+
def __init__(self, content): # type: ignore[no-untyped-def]
|
|
9
|
+
self.content = content
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _FakeChoice:
|
|
13
|
+
def __init__(self, content): # type: ignore[no-untyped-def]
|
|
14
|
+
self.message = _FakeMessage(content)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _FakeResponse:
|
|
18
|
+
def __init__(self, content): # type: ignore[no-untyped-def]
|
|
19
|
+
self.choices = [_FakeChoice(content)]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _fake_openai(content): # type: ignore[no-untyped-def]
|
|
23
|
+
class FakeOpenAI:
|
|
24
|
+
def __init__(self, **kwargs): # type: ignore[no-untyped-def]
|
|
25
|
+
self.chat = type("Chat", (), {})()
|
|
26
|
+
self.chat.completions = type("Completions", (), {})()
|
|
27
|
+
self.chat.completions.create = lambda **create_kwargs: _FakeResponse(content)
|
|
28
|
+
|
|
29
|
+
return FakeOpenAI
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_factory_returns_openai_client(monkeypatch): # type: ignore[no-untyped-def]
|
|
33
|
+
monkeypatch.setattr("radfact_lite.clients.OpenAI", _fake_openai("{}"))
|
|
34
|
+
monkeypatch.setenv("OPENAI_API_KEY", "key")
|
|
35
|
+
client = build_json_client(ModelConfig(model="gpt-4o-mini", provider="openai"))
|
|
36
|
+
assert isinstance(client, OpenAIJSONClient)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_factory_returns_ollama_client(monkeypatch): # type: ignore[no-untyped-def]
|
|
40
|
+
monkeypatch.setattr("radfact_lite.clients.OpenAI", _fake_openai("{}"))
|
|
41
|
+
client = build_json_client(ModelConfig(model="llama3.1", provider="ollama"))
|
|
42
|
+
assert isinstance(client, OllamaJSONClient)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_openai_client_requires_api_key(monkeypatch): # type: ignore[no-untyped-def]
|
|
46
|
+
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
|
47
|
+
with pytest.raises(ValueError, match="Missing API key"):
|
|
48
|
+
OpenAIJSONClient(ModelConfig(model="gpt-4o-mini"))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_ollama_client_parses_valid_json(monkeypatch): # type: ignore[no-untyped-def]
|
|
52
|
+
monkeypatch.setattr("radfact_lite.clients.OpenAI", _fake_openai('{"ok": true}'))
|
|
53
|
+
client = OllamaJSONClient(ModelConfig(model="llama3.1", provider="ollama"))
|
|
54
|
+
parsed = client.complete_json([Message(role="user", content="hello")], {"type": "json_object"})
|
|
55
|
+
assert parsed == {"ok": True}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_ollama_client_raises_on_invalid_json(monkeypatch): # type: ignore[no-untyped-def]
|
|
59
|
+
monkeypatch.setattr("radfact_lite.clients.OpenAI", _fake_openai("not-json"))
|
|
60
|
+
client = OllamaJSONClient(ModelConfig(model="llama3.1", provider="ollama"))
|
|
61
|
+
with pytest.raises(ValueError, match="Invalid JSON response"):
|
|
62
|
+
client.complete_json([Message(role="user", content="hello")], {"type": "json_object"})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from radfact_lite.entailment import _binary_output, _expand_single_phrase_examples, assess_entailment
|
|
4
|
+
from radfact_lite.rf_types import ReportType
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CapturingClient:
|
|
8
|
+
def __init__(self, response): # type: ignore[no-untyped-def]
|
|
9
|
+
self.response = response
|
|
10
|
+
self.messages = []
|
|
11
|
+
|
|
12
|
+
def complete_json(self, messages: list, response_format: dict) -> dict: # type: ignore[no-untyped-def]
|
|
13
|
+
del response_format
|
|
14
|
+
self.messages = messages
|
|
15
|
+
return self.response
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_binary_output_maps_non_entailment_states() -> None:
|
|
19
|
+
neutral = _binary_output({"phrase": "a", "status": "neutral", "evidence": []})
|
|
20
|
+
contradiction = _binary_output({"phrase": "a", "status": "contradiction", "evidence": ["x"]})
|
|
21
|
+
assert neutral["status"] == "not_entailment"
|
|
22
|
+
assert contradiction["status"] == "not_entailment"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_expand_single_phrase_examples_bidirectional_count() -> None:
|
|
26
|
+
example = {
|
|
27
|
+
"input": {"phrases_A": ["a1", "a2"], "phrases_B": ["b1"]},
|
|
28
|
+
"output": {
|
|
29
|
+
"phrases_A_evidenced": [
|
|
30
|
+
{"phrase": "a1", "status": "entailment", "evidence": ["b1"]},
|
|
31
|
+
{"phrase": "a2", "status": "neutral", "evidence": []},
|
|
32
|
+
],
|
|
33
|
+
"phrases_B_evidenced": [{"phrase": "b1", "status": "entailment", "evidence": ["a1"]}],
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
expanded = _expand_single_phrase_examples(example)
|
|
37
|
+
assert len(expanded) == 3
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_assess_entailment_rewrites_phrase_back_to_hypothesis(monkeypatch): # type: ignore[no-untyped-def]
|
|
41
|
+
monkeypatch.setattr("radfact_lite.entailment.load_system_prompt", lambda *args, **kwargs: "sys")
|
|
42
|
+
monkeypatch.setattr("radfact_lite.entailment.load_few_shot_json", lambda *args, **kwargs: [])
|
|
43
|
+
|
|
44
|
+
client = CapturingClient(response={"phrase": "rewritten", "status": "entailment", "evidence": ["ref"]})
|
|
45
|
+
decision = assess_entailment(client, ["ref"], "original hypothesis", ReportType.TOOTHFAIRY) # type: ignore[arg-type]
|
|
46
|
+
assert decision.phrase == "original hypothesis"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_assess_entailment_sends_expected_query(monkeypatch): # type: ignore[no-untyped-def]
|
|
50
|
+
monkeypatch.setattr("radfact_lite.entailment.load_system_prompt", lambda *args, **kwargs: "sys")
|
|
51
|
+
monkeypatch.setattr("radfact_lite.entailment.load_few_shot_json", lambda *args, **kwargs: [])
|
|
52
|
+
|
|
53
|
+
client = CapturingClient(response={"phrase": "h", "status": "entailment", "evidence": ["r"]})
|
|
54
|
+
assess_entailment(client, ["r1", "r2"], "h", ReportType.TOOTHFAIRY) # type: ignore[arg-type]
|
|
55
|
+
payload = json.loads(client.messages[-1].content)
|
|
56
|
+
assert payload == {"reference": ["r1", "r2"], "hypothesis": "h"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from radfact_lite.metric import aggregate_results, f1, sample_result
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_sample_result_and_f1() -> None:
|
|
5
|
+
result = sample_result(
|
|
6
|
+
sample_id="1",
|
|
7
|
+
entailed_candidate_count=3,
|
|
8
|
+
entailed_reference_count=2,
|
|
9
|
+
candidate_count=4,
|
|
10
|
+
reference_count=4,
|
|
11
|
+
)
|
|
12
|
+
assert result.logical_precision == 0.75
|
|
13
|
+
assert result.logical_recall == 0.5
|
|
14
|
+
assert result.logical_f1 == f1(0.75, 0.5)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_aggregate_results() -> None:
|
|
18
|
+
r1 = sample_result("a", 2, 1, 2, 2)
|
|
19
|
+
r2 = sample_result("b", 1, 2, 2, 2)
|
|
20
|
+
aggregate = aggregate_results([r1, r2], num_llm_failures=3)
|
|
21
|
+
assert aggregate.logical_precision == 0.75
|
|
22
|
+
assert aggregate.logical_recall == 0.75
|
|
23
|
+
assert aggregate.logical_f1 == 0.75
|
|
24
|
+
assert aggregate.num_samples == 2
|
|
25
|
+
assert aggregate.num_llm_failures == 3
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from radfact_lite.negative_filter import filter_positive_findings
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class StubClient:
|
|
7
|
+
def __init__(self, output_phrases): # type: ignore[no-untyped-def]
|
|
8
|
+
self.output_phrases = output_phrases
|
|
9
|
+
|
|
10
|
+
def complete_json(self, messages: list, response_format: dict) -> dict: # type: ignore[no-untyped-def]
|
|
11
|
+
del messages, response_format
|
|
12
|
+
return {"phrases": self.output_phrases}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_filter_positive_findings_drops_hallucinated_output(monkeypatch): # type: ignore[no-untyped-def]
|
|
16
|
+
monkeypatch.setattr("radfact_lite.negative_filter.load_system_prompt", lambda *args, **kwargs: "sys")
|
|
17
|
+
monkeypatch.setattr("radfact_lite.negative_filter.load_few_shot_json", lambda *args, **kwargs: [])
|
|
18
|
+
|
|
19
|
+
client = StubClient(["Mild right pleural effusion.", "Hallucinated phrase"])
|
|
20
|
+
filtered = filter_positive_findings(client, ["No pneumothorax.", "Mild right pleural effusion."]) # type: ignore[arg-type]
|
|
21
|
+
assert filtered == ["Mild right pleural effusion."]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_filter_positive_findings_keeps_subset_order(monkeypatch): # type: ignore[no-untyped-def]
|
|
25
|
+
monkeypatch.setattr("radfact_lite.negative_filter.load_system_prompt", lambda *args, **kwargs: "sys")
|
|
26
|
+
monkeypatch.setattr("radfact_lite.negative_filter.load_few_shot_json", lambda *args, **kwargs: [])
|
|
27
|
+
|
|
28
|
+
client = StubClient(["b", "a"])
|
|
29
|
+
filtered = filter_positive_findings(client, ["a", "b", "c"]) # type: ignore[arg-type]
|
|
30
|
+
assert filtered == ["b", "a"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_filter_positive_findings_surfaces_missing_modality_error(monkeypatch): # type: ignore[no-untyped-def]
|
|
34
|
+
monkeypatch.setattr("radfact_lite.negative_filter.load_system_prompt", lambda *args, **kwargs: (_ for _ in ()).throw(ValueError("missing prompts")))
|
|
35
|
+
with pytest.raises(ValueError, match="missing prompts"):
|
|
36
|
+
filter_positive_findings(StubClient([]), ["a"]) # type: ignore[arg-type]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from radfact_lite.phrase_parser import parse_report_to_phrases
|
|
2
|
+
from radfact_lite.rf_types import ReportType
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class StubClient:
|
|
6
|
+
def complete_json(self, messages: list, response_format: dict) -> dict: # type: ignore[no-untyped-def]
|
|
7
|
+
del messages, response_format
|
|
8
|
+
return {
|
|
9
|
+
"sentence_list": [
|
|
10
|
+
{"orig": "a", "new": [" first ", ""]},
|
|
11
|
+
{"orig": "b", "new": [" ", "second"]},
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_parse_report_to_phrases_removes_empty_and_preserves_order(monkeypatch): # type: ignore[no-untyped-def]
|
|
17
|
+
monkeypatch.setattr("radfact_lite.phrase_parser.load_system_prompt", lambda *args, **kwargs: "sys")
|
|
18
|
+
monkeypatch.setattr("radfact_lite.phrase_parser.load_few_shot_json", lambda *args, **kwargs: [])
|
|
19
|
+
|
|
20
|
+
result = parse_report_to_phrases(StubClient(), "report text", ReportType.TOOTHFAIRY) # type: ignore[arg-type]
|
|
21
|
+
assert result == ["first", "second"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_parse_report_to_phrases_uses_report_to_phrases_task(monkeypatch): # type: ignore[no-untyped-def]
|
|
25
|
+
captured = {}
|
|
26
|
+
|
|
27
|
+
def fake_load_system(task, report_type, filename): # type: ignore[no-untyped-def]
|
|
28
|
+
captured["task"] = task
|
|
29
|
+
captured["modality"] = report_type.value
|
|
30
|
+
captured["filename"] = filename
|
|
31
|
+
return "sys"
|
|
32
|
+
|
|
33
|
+
monkeypatch.setattr("radfact_lite.phrase_parser.load_system_prompt", fake_load_system)
|
|
34
|
+
monkeypatch.setattr("radfact_lite.phrase_parser.load_few_shot_json", lambda *args, **kwargs: [])
|
|
35
|
+
|
|
36
|
+
parse_report_to_phrases(StubClient(), "report text", ReportType.TOOTHFAIRY) # type: ignore[arg-type]
|
|
37
|
+
assert captured == {
|
|
38
|
+
"task": "report_to_phrases",
|
|
39
|
+
"modality": "toothfairy",
|
|
40
|
+
"filename": "system_message.txt",
|
|
41
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from unittest.mock import patch
|
|
2
|
+
|
|
3
|
+
from radfact_lite.pipeline import RadFactLitePipeline
|
|
4
|
+
from radfact_lite.rf_types import EntailmentDecision, ModelConfig, PipelineModels, ReportType
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class FakeClient:
|
|
8
|
+
def complete_json(self, messages: list, response_format: dict) -> dict: # type: ignore[no-untyped-def]
|
|
9
|
+
del messages, response_format
|
|
10
|
+
return {}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _models() -> PipelineModels:
|
|
14
|
+
cfg = ModelConfig(model="dummy")
|
|
15
|
+
return PipelineModels(parse_model=cfg, entailment_model=cfg, filtering_model=cfg)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_pipeline_narrative_with_filtering_end_to_end_mocked() -> None:
|
|
19
|
+
with patch("radfact_lite.pipeline.build_json_client", return_value=FakeClient()):
|
|
20
|
+
pipeline = RadFactLitePipeline(_models(), report_type=ReportType.TOOTHFAIRY)
|
|
21
|
+
|
|
22
|
+
with patch("radfact_lite.pipeline.parse_report_to_phrases", side_effect=[['a', 'b'], ['a', 'c']]):
|
|
23
|
+
with patch("radfact_lite.pipeline.filter_positive_findings", side_effect=[['a', 'b'], ['a', 'c']]):
|
|
24
|
+
|
|
25
|
+
def fake_assess(client, reference_phrases, hypothesis_phrase, report_type): # type: ignore[no-untyped-def]
|
|
26
|
+
del client, report_type
|
|
27
|
+
return EntailmentDecision(
|
|
28
|
+
phrase=hypothesis_phrase,
|
|
29
|
+
status="entailment" if hypothesis_phrase in reference_phrases else "not_entailment",
|
|
30
|
+
evidence=[hypothesis_phrase] if hypothesis_phrase in reference_phrases else [],
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
with patch("radfact_lite.pipeline.assess_entailment", side_effect=fake_assess):
|
|
34
|
+
aggregate, per_sample = pipeline.compute_radfact(
|
|
35
|
+
candidates_by_id={"s1": "candidate"},
|
|
36
|
+
references_by_id={"s1": "reference"},
|
|
37
|
+
is_narrative_text=True,
|
|
38
|
+
filter_negatives=True,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
assert aggregate.logical_precision == 0.5
|
|
42
|
+
assert aggregate.logical_recall == 0.5
|
|
43
|
+
assert aggregate.logical_f1 == 0.5
|
|
44
|
+
assert len(per_sample) == 1
|