Files
Celes Renata a72f336ad1 feat: Intelligence Pipeline v3 — full implementation
Multi-stage evidence-grounded inference architecture replacing the
monolithic 9B model extraction pipeline. CPU-first specialist services
handle routine extraction while the 9B vLLM model is preserved for
semantic adjudication of ambiguous cases.

Key components:
- Capability-aware inference gateway (OpenAI-compatible + Ollama)
- Endpoint registry with DB migrations and REST API
- Sentence-aware document segmenter (property tests)
- Deterministic financial parsing with offset integrity
- Symbol resolution with ambiguity detection
- Specialist service (GLiNER2, dynamic batching, K8s deployment)
- Company-specific sentiment (FinBERT, calibration)
- Retrieval-based novelty and duplicate detection
- Confidence calibration pipeline
- Deterministic routing engine (property tests)
- 9B adjudication layer with VRAM gating
- Stock-specific impact model (features, labels, baseline, trained)
- Pipeline orchestrator (state machine, queues, leases, feature flags)
- Bounded parallelism (async workers, semaphore, load shedding)
- Observability (tracing, metrics, alerts)
- Compatibility adapter (v3→v2 golden mapping tests)
- Shadow/canary promotion framework
- Active learning and fine-tuning pipeline

Test results: 1,161 tests pass, ruff lint clean.
All 282 spec tasks completed.
2026-07-13 02:14:59 +00:00

168 lines
5.6 KiB
Python

"""FinBERT adapter for financial sentiment classification.
Provides a unified interface for FinBERT inference with:
- Production mode: loads ProsusAI/finbert and runs real inference
- Test mode: deterministic keyword-based mock probabilities
Model version is pinned and exposed for lineage tracking.
"""
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
# Pinned model configuration
FINBERT_MODEL_NAME = "ProsusAI/finbert"
FINBERT_MODEL_VERSION = "ProsusAI/finbert@v1.0"
# Keywords for deterministic test mode
_POSITIVE_KEYWORDS = frozenset({
"growth", "profit", "beat", "raised", "upgrade", "strong",
"surge", "gain", "bullish", "outperform", "exceeded", "record",
"positive", "optimistic", "rally", "upside",
})
_NEGATIVE_KEYWORDS = frozenset({
"loss", "decline", "miss", "cut", "downgrade", "weak",
"plunge", "drop", "bearish", "underperform", "fell", "crash",
"negative", "pessimistic", "risk", "downside", "slump",
})
class FinBERTAdapter:
"""Adapter for FinBERT financial sentiment classification.
Parameters
----------
test_mode
When True, uses deterministic keyword-based classification
instead of loading the actual FinBERT model. Useful for testing
without GPU/large model dependencies.
"""
def __init__(self, test_mode: bool = True) -> None:
self._test_mode = test_mode
self._model = None
self._tokenizer = None
self._model_name = FINBERT_MODEL_NAME
self._model_version = FINBERT_MODEL_VERSION
if not test_mode:
self._load_model()
@property
def model_version(self) -> str:
"""Return the pinned model version string."""
return self._model_version
@property
def model_name(self) -> str:
"""Return the model name."""
return self._model_name
def _load_model(self) -> None:
"""Load the FinBERT model and tokenizer for production inference."""
try:
from transformers import AutoModelForSequenceClassification, AutoTokenizer
logger.info("Loading FinBERT model: %s", self._model_name)
self._tokenizer = AutoTokenizer.from_pretrained(self._model_name)
self._model = AutoModelForSequenceClassification.from_pretrained(self._model_name)
self._model.eval()
logger.info("FinBERT model loaded successfully")
except ImportError:
raise RuntimeError(
"transformers and torch are required for production FinBERT inference. "
"Install with: pip install transformers torch"
)
except Exception as e:
raise RuntimeError(f"Failed to load FinBERT model: {e}") from e
def classify(self, texts: list[str]) -> list[tuple[float, float, float]]:
"""Classify texts and return probability distributions.
Parameters
----------
texts
List of text strings to classify.
Returns
-------
list[tuple[float, float, float]]
List of (positive_prob, negative_prob, neutral_prob) tuples.
Probabilities sum to 1.0 for each text.
"""
if not texts:
return []
if self._test_mode:
return self._classify_test_mode(texts)
return self._classify_production(texts)
def _classify_test_mode(self, texts: list[str]) -> list[tuple[float, float, float]]:
"""Deterministic keyword-based classification for testing.
Returns consistent probabilities based on keyword presence:
- Positive keywords dominant -> (0.75, 0.10, 0.15)
- Negative keywords dominant -> (0.10, 0.75, 0.15)
- Both present (mixed signals) -> (0.40, 0.40, 0.20)
- Neither present -> (0.15, 0.15, 0.70)
"""
results: list[tuple[float, float, float]] = []
for text in texts:
lower_text = text.lower()
words = set(lower_text.split())
has_positive = bool(words & _POSITIVE_KEYWORDS)
has_negative = bool(words & _NEGATIVE_KEYWORDS)
if has_positive and has_negative:
# Mixed signals
results.append((0.40, 0.40, 0.20))
elif has_positive:
results.append((0.75, 0.10, 0.15))
elif has_negative:
results.append((0.10, 0.75, 0.15))
else:
# Neutral — no sentiment keywords
results.append((0.15, 0.15, 0.70))
return results
def _classify_production(self, texts: list[str]) -> list[tuple[float, float, float]]:
"""Run FinBERT inference on texts using the loaded model."""
import torch
if self._model is None or self._tokenizer is None:
raise RuntimeError("Model not loaded. Initialize with test_mode=False.")
results: list[tuple[float, float, float]] = []
# Process in batches to manage memory
batch_size = 16
for i in range(0, len(texts), batch_size):
batch = texts[i : i + batch_size]
inputs = self._tokenizer(
batch,
padding=True,
truncation=True,
max_length=512,
return_tensors="pt",
)
with torch.no_grad():
outputs = self._model(**inputs)
# FinBERT output order: positive, negative, neutral
probs = torch.softmax(outputs.logits, dim=-1)
for prob in probs:
pos = float(prob[0])
neg = float(prob[1])
neu = float(prob[2])
results.append((pos, neg, neu))
return results