From 694efd86e34ed60d8f77f701c7006f63e521715d Mon Sep 17 00:00:00 2001 From: Daniel Birhanu Date: Wed, 2 Sep 2026 18:22:29 +0300 Subject: [PATCH 1/5] create coreference resolver config --- config.py | 5 +++ core/coreference.py | 102 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 core/coreference.py diff --git a/config.py b/config.py index ce1f9e7..a085901 100644 --- a/config.py +++ b/config.py @@ -44,6 +44,11 @@ class Settings(BaseSettings): parser_batch_sentences: int = 4 parser_batch_max_chars: int = 2000 + # Optional document-level coreference preprocessing + coreference_enabled: bool = False + coreference_model: str = "biu-nlp/f-coref" + coreference_min_confidence: float = 0.65 + # Reasoning chaining_timeout: int = 30 # seconds before proof search is killed chaining_max_steps: int = 100 diff --git a/core/coreference.py b/core/coreference.py new file mode 100644 index 0000000..e545484 --- /dev/null +++ b/core/coreference.py @@ -0,0 +1,102 @@ +"""Optional document-level coreference resolution.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import Any + + +logger = logging.getLogger(__name__) + + +@dataclass +class ResolvedDocument: + """Original and parser-facing text plus optional resolution diagnostics.""" + + original: str + resolved: str + mentions: list[dict[str, Any]] = field(default_factory=list) + + +class CoreferenceResolver: + """Resolve text with fastcoref when enabled, otherwise leave it unchanged.""" + + def __init__( + self, + enabled: bool = False, + model_name: str = "biu-nlp/f-coref", + min_confidence: float = 0.65, + ) -> None: + self.enabled = enabled + self.model_name = model_name + self.min_confidence = min(1.0, max(0.0, min_confidence)) + self._model: Any | None = None + self._load_failed = False + + def resolve(self, text: str) -> ResolvedDocument: + """Return resolved text, failing open to the original input.""" + original = text or "" + if not self.enabled or not original.strip(): + return ResolvedDocument(original=original, resolved=original) + + try: + return self._resolve_with_fastcoref(original) + except Exception as exc: # Optional dependency/model must not break ingest. + if not self._load_failed: + logger.warning("Coreference resolver unavailable; using original text: %s", exc) + self._load_failed = True + return ResolvedDocument(original=original, resolved=original) + + def _resolve_with_fastcoref(self, text: str) -> ResolvedDocument: + if self._model is None: + from fastcoref import FCoref + + self._model = FCoref(model_name_or_path=self.model_name) + + prediction = self._model.predict(texts=[text])[0] + clusters = prediction.get_clusters(as_strings=False) + replacements: list[tuple[int, int, str]] = [] + mentions: list[dict[str, Any]] = [] + + for cluster_id, cluster in enumerate(clusters or []): + if len(cluster) < 2: + continue + antecedent_start, antecedent_end = self._span(cluster[0]) + antecedent = text[antecedent_start:antecedent_end] + if not antecedent.strip(): + continue + for span in cluster[1:]: + start, end = self._span(span) + mention = text[start:end] + if not mention.strip() or mention.casefold() == antecedent.casefold(): + continue + replacements.append((start, end, antecedent)) + mentions.append( + { + "cluster_id": cluster_id, + "mention": mention, + "antecedent": antecedent, + "start": start, + "end": end, + "confidence": 1.0, + } + ) + + resolved = self._replace_spans(text, replacements) + return ResolvedDocument(original=text, resolved=resolved, mentions=mentions) + + @staticmethod + def _span(span: Any) -> tuple[int, int]: + if not isinstance(span, (list, tuple)) or len(span) != 2: + raise ValueError(f"Invalid coreference span: {span!r}") + start, end = int(span[0]), int(span[1]) + if start < 0 or end <= start: + raise ValueError(f"Invalid coreference span: {span!r}") + return start, end + + @staticmethod + def _replace_spans(text: str, replacements: list[tuple[int, int, str]]) -> str: + for start, end, replacement in sorted(replacements, reverse=True): + text = text[:start] + replacement + text[end:] + return text From fd7929033e6b224353bdff7b8fcb8f1215e8c42c Mon Sep 17 00:00:00 2001 From: Daniel Birhanu Date: Wed, 2 Sep 2026 18:46:30 +0300 Subject: [PATCH 2/5] integrate coreference resolution before document chunking --- core/service.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/core/service.py b/core/service.py index 343b79e..2ec301a 100644 --- a/core/service.py +++ b/core/service.py @@ -6,6 +6,7 @@ from config import get_settings from core.chunker import Chunker +from core.coreference import CoreferenceResolver from core.parser import SemanticParser from core.reasoner import Reasoner from core.answer_generator import AnswerGenerator @@ -29,6 +30,11 @@ class PLNRAGService: def __init__(self, parser: SemanticParser): cfg = get_settings() self._parser = parser + self._coreference = CoreferenceResolver( + enabled=cfg.coreference_enabled, + model_name=cfg.coreference_model, + min_confidence=cfg.coreference_min_confidence, + ) create_chunker = getattr(parser, "create_chunker", None) self._chunker = create_chunker() if callable(create_chunker) else Chunker() self._reasoner = Reasoner() @@ -57,12 +63,14 @@ async def ingest_batch(self, texts: List[str]) -> List[IngestItemResult]: def _ingest_single(self, text: str) -> IngestItemResult: try: + resolved = self._coreference.resolve(text) + parser_text = resolved.resolved all_atoms: List[str] = [] rejected: List[dict] = [] supports_batch_parse = ( self._parser.__class__.parse_batch is not SemanticParser.parse_batch ) - chunk_units = self._chunker.chunk(text) + chunk_units = self._chunker.chunk(parser_text) chunk_count = len(chunk_units) batch_count = 0 batch_sizes: List[int] = [] @@ -71,7 +79,7 @@ def _ingest_single(self, text: str) -> IngestItemResult: if supports_batch_parse: batches = self._chunker.batch_chunks( - text, + parser_text, max_sentences=get_settings().parser_batch_sentences, max_chars=get_settings().parser_batch_max_chars, ) From d831e26afb3ea4e0ca4d4e84c69565f2ca8be406 Mon Sep 17 00:00:00 2001 From: Daniel Birhanu Date: Thu, 3 Sep 2026 12:31:19 +0300 Subject: [PATCH 3/5] add coreference benchmark comparison --- README.md | 19 +++++++++++++++++++ benchmark_parsers.py | 14 ++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/README.md b/README.md index 0f3d12b..40d3e88 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,25 @@ QUERY_FALLBACK_ENABLED=true When disabled, the service runs only the original generated query. When enabled, it may try later fallback candidates produced by the parser. +### Coreference preprocessing + +Coreference resolution is disabled by default. To enable it, install the +optional `fastcoref` dependency/model and set: + +```bash +COREFERENCE_ENABLED=true +COREFERENCE_MODEL=biu-nlp/f-coref +COREFERENCE_MIN_CONFIDENCE=0.65 +``` + +The resolver runs before document chunking and leaves text unchanged if the +optional model is unavailable. Compare benchmark runs with: + +```bash +python3 benchmark_parsers.py --suite-file data/benchmarks/stress25_v1.json --parsers canonical_pln --no-coreference +python3 benchmark_parsers.py --suite-file data/benchmarks/stress25_v1.json --parsers canonical_pln --coreference +``` + ## ConceptNet Background Knowledge ConceptNet can be loaded as readonly background knowledge and indexed into the diff --git a/benchmark_parsers.py b/benchmark_parsers.py index 60c71b2..2332a99 100644 --- a/benchmark_parsers.py +++ b/benchmark_parsers.py @@ -638,6 +638,16 @@ async def main() -> int: help="Print per-case progress to stderr", ) cli.add_argument("--quick", action="store_true", help="Run a reduced representative case set") + coref_group = cli.add_mutually_exclusive_group() + coref_group.add_argument( + "--coreference", dest="coreference", action="store_true", + help="Enable optional coreference preprocessing", + ) + coref_group.add_argument( + "--no-coreference", dest="coreference", action="store_false", + help="Disable coreference preprocessing", + ) + cli.set_defaults(coreference=None) cli.add_argument( "--output-dir", default="data/benchmarks", @@ -657,6 +667,9 @@ async def main() -> int: ) args = cli.parse_args() + if args.coreference is not None: + os.environ["COREFERENCE_ENABLED"] = str(args.coreference).lower() + if args.suite_file: suite_path = Path(args.suite_file) suite_metadata, cases = _select_cases_from_file(suite_path, args.quick) @@ -681,6 +694,7 @@ async def main() -> int: payload: dict[str, object] = { "run_id": run_id, "conceptnet_enabled": False, + "coreference_enabled": bool(get_settings().coreference_enabled), "mode": args.mode, "suite": suite_label, "suite_metadata": suite_metadata, From 6116ec610a8434e9b87568afb7f5ca92c4b59c8e Mon Sep 17 00:00:00 2001 From: Daniel Birhanu Date: Fri, 4 Sep 2026 11:20:22 +0300 Subject: [PATCH 4/5] fix dependency issue --- core/coreference.py | 4 ++++ requirements.txt | 1 + 2 files changed, 5 insertions(+) diff --git a/core/coreference.py b/core/coreference.py index e545484..1cb2fc2 100644 --- a/core/coreference.py +++ b/core/coreference.py @@ -51,6 +51,10 @@ def resolve(self, text: str) -> ResolvedDocument: def _resolve_with_fastcoref(self, text: str) -> ResolvedDocument: if self._model is None: from fastcoref import FCoref + from fastcoref.coref_models.modeling_fcoref import FCorefModel + + # Patch for compatibility with newer versions of transformers + FCorefModel.all_tied_weights_keys = {} self._model = FCoref(model_name_or_path=self.model_name) diff --git a/requirements.txt b/requirements.txt index 632c2d0..6cf4813 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,7 @@ dspy==3.1.3 litellm==1.95.0 openai>=1.0.0 spacy>=3.7.0 +fastcoref>=2.1.6 faiss-cpu>=1.8.0 numpy>=1.26.0 networkx>=3.0.0 From 66552c08384b5b5aeedc61c22c29d8489f738014 Mon Sep 17 00:00:00 2001 From: Daniel Birhanu Date: Fri, 4 Sep 2026 16:19:40 +0300 Subject: [PATCH 5/5] restrict coreference replacements --- core/coreference.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/core/coreference.py b/core/coreference.py index 1cb2fc2..5a79aab 100644 --- a/core/coreference.py +++ b/core/coreference.py @@ -63,6 +63,14 @@ def _resolve_with_fastcoref(self, text: str) -> ResolvedDocument: replacements: list[tuple[int, int, str]] = [] mentions: list[dict[str, Any]] = [] + pronouns = { + "he", "him", "his", + "she", "her", "hers", + "it", "its", + "they", "them", "their", "theirs" + } + possessives = {"his", "her", "hers", "its", "their", "theirs"} + for cluster_id, cluster in enumerate(clusters or []): if len(cluster) < 2: continue @@ -73,14 +81,24 @@ def _resolve_with_fastcoref(self, text: str) -> ResolvedDocument: for span in cluster[1:]: start, end = self._span(span) mention = text[start:end] - if not mention.strip() or mention.casefold() == antecedent.casefold(): + mention_lower = mention.casefold() + + # Restrict to exact pronoun matches to avoid breaking rigid grammar rules + if not mention.strip() or mention_lower == antecedent.casefold() or mention_lower not in pronouns: continue - replacements.append((start, end, antecedent)) + + # Handle possessive pronoun replacement (e.g., 'his' -> 'John\'s') + replacement = antecedent + if mention_lower in possessives: + if not replacement.endswith("'s") and not replacement.endswith("'"): + replacement += "'" if replacement.endswith("s") else "'s" + + replacements.append((start, end, replacement)) mentions.append( { "cluster_id": cluster_id, "mention": mention, - "antecedent": antecedent, + "antecedent": replacement, "start": start, "end": end, "confidence": 1.0,