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.
This commit is contained in:
Celes Renata
2026-07-13 02:14:59 +00:00
parent 84634a365e
commit a72f336ad1
227 changed files with 50403 additions and 0 deletions
@@ -0,0 +1,26 @@
"""Deterministic financial parsing for the v3 intelligence pipeline.
This package provides regex-based detection of financial entities:
- Ticker symbols ($AAPL, AAPL)
- Currencies and money amounts ($123.45, €99, $94.9 billion)
- Percentages (4%, -2.5%)
- Basis points (25 basis points, 25bps)
- Ranges ($10-$12)
- EPS values ($1.52 per share)
- Revenue figures
- Dates and fiscal periods (Q1 2024, FY2025)
Each match returns exact character offsets, literal text, and a normalized numeric value.
"""
from services.intelligence_pipeline_v3.parsing.financial_parser import FinancialParser
from services.intelligence_pipeline_v3.parsing.models import CandidateType, ParsedCandidate, PeriodAnnotation
from services.intelligence_pipeline_v3.parsing.normalizer import normalize_value
__all__ = [
"CandidateType",
"FinancialParser",
"ParsedCandidate",
"PeriodAnnotation",
"normalize_value",
]
@@ -0,0 +1,426 @@
"""Deterministic financial parser using regex-based detection.
Detects tickers, currencies, money amounts, percentages, basis points,
ranges, EPS, revenue, dates, and fiscal periods from source text.
Each match returns exact character offsets into the source text.
"""
from __future__ import annotations
import re
from services.intelligence_pipeline_v3.parsing.models import (
CandidateType,
ParsedCandidate,
PeriodAnnotation,
)
from services.intelligence_pipeline_v3.parsing.normalizer import (
normalize_basis_points,
normalize_money,
normalize_percentage,
normalize_range,
)
# ---------------------------------------------------------------------------
# Regex patterns
# ---------------------------------------------------------------------------
# Ticker: $AAPL or standalone AAPL-like (1-5 uppercase letters)
_TICKER_DOLLAR_RE = re.compile(r"\$([A-Z]{1,5})\b")
_TICKER_BARE_RE = re.compile(r"\b([A-Z]{1,5})\b")
# Common English words that look like tickers but aren't
_TICKER_STOPWORDS = frozenset({
"A", "I", "AM", "AN", "AS", "AT", "BE", "BY", "DO", "GO", "HE", "IF",
"IN", "IS", "IT", "ME", "MY", "NO", "OF", "OK", "ON", "OR", "OUR", "SO",
"THE", "TO", "UP", "US", "WE", "CEO", "CFO", "COO", "CTO", "EPS", "ETF",
"GDP", "IPO", "LLC", "LTD", "NYSE", "SEC", "USA", "AND", "ARE", "BUT",
"CAN", "DID", "FOR", "GET", "GOT", "HAD", "HAS", "HER", "HIS", "HOW",
"ITS", "LET", "MAY", "NEW", "NOT", "NOW", "OLD", "OUR", "OWN", "PUT",
"RAN", "SAY", "SHE", "TOO", "TWO", "USE", "WAS", "WAY", "WHO", "WHY",
"WIN", "WON", "YET", "YOU", "ALL", "ANY", "BIG", "DAY", "END", "FEW",
"FAR", "HIT", "LOW", "MET", "NET", "OUT", "RUN", "SET", "TOP", "TRY",
"ALSO", "BACK", "BEEN", "BEST", "BOTH", "CAME", "COME", "DOWN", "EACH",
"FROM", "GAVE", "GOOD", "HAVE", "HERE", "HIGH", "INTO", "JUST", "KEEP",
"LAST", "LONG", "MADE", "MAKE", "MANY", "MORE", "MOST", "MUCH", "MUST",
"NEED", "NEXT", "ONLY", "OVER", "SAID", "SAME", "SOME", "SUCH", "TAKE",
"THAN", "THAT", "THEM", "THEN", "THEY", "THIS", "VERY", "WANT", "WELL",
"WENT", "WERE", "WHAT", "WHEN", "WILL", "WITH", "WORK", "YEAR", "YOUR",
"ITEM", "CASH", "FLOW", "FREE", "FULL", "HALF", "RISE", "ROSE", "FELL",
"BEAT", "MISS", "GREW", "GROW", "LOST", "LOSS", "GAIN", "HOLD", "SELL",
"CALL", "BUY", "FUND", "BOND", "RATE", "DEBT", "DEAL", "RISK",
"Q", "H", "FY", "YOY", "QOQ", "AI", "R", "D",
})
# Fiscal period: Q1 2024, Q4'24, FY2025, FY25, H1 2024
_FISCAL_PERIOD_RE = re.compile(
r"\b(Q[1-4]|H[12]|FY)\s*['\u2019]?\s*(\d{4}|\d{2})\b"
)
# Date patterns: January 15, 2024 / Jan 15, 2024 / 2024-01-15
_MONTH_NAMES = (
r"(?:January|February|March|April|May|June|July|August|September|October|November|December"
r"|Jan|Feb|Mar|Apr|Jun|Jul|Aug|Sep|Oct|Nov|Dec)"
)
_DATE_NAMED_RE = re.compile(
rf"\b({_MONTH_NAMES})\s+(\d{{1,2}})(?:,?\s+(\d{{4}}))?\b"
)
_DATE_ISO_RE = re.compile(r"\b(\d{4})-(\d{2})-(\d{2})\b")
# Basis points: "25 basis points", "50bps", "25 bps"
_BASIS_POINTS_RE = re.compile(
r"[+-]?\d[\d,]*\.?\d*\s*(?:basis\s+points?|bps)\b", re.IGNORECASE
)
# Percentage: 4%, -2.5%, +1.2 percent
_PERCENTAGE_RE = re.compile(
r"[+-]?\d[\d,]*\.?\d*\s*(?:%|percent(?:age)?(?:\s+points?)?\b)", re.IGNORECASE
)
# EPS: "$1.52 per share", "earnings per share of $1.52"
_EPS_PER_SHARE_RE = re.compile(
r"\$\s*\d[\d,]*\.?\d*\s+per\s+share\b", re.IGNORECASE
)
_EPS_PREFIX_RE = re.compile(
r"\b(?:EPS|earnings\s+per\s+share)\s+(?:of\s+)?\$\s*\d[\d,]*\.?\d*", re.IGNORECASE
)
# Revenue: "$94.9 billion in revenue", "revenue of $94.9 billion"
_REVENUE_AMOUNT_RE = re.compile(
r"\$\s*\d[\d,]*\.?\d*\s*(?:trillion|billion|million|bn|mn)\s+(?:in\s+)?revenue\b",
re.IGNORECASE,
)
_REVENUE_PREFIX_RE = re.compile(
r"\brevenue\s+(?:of|was|reached|hit|grew\s+to|increased\s+to|totaled)\s+\$\s*\d[\d,]*\.?\d*\s*(?:trillion|billion|million|bn|mn)?",
re.IGNORECASE,
)
# Range: "$10-$12", "$10 to $12", "$1.50-$2.00"
_RANGE_RE = re.compile(
r"\$\s*\d[\d,]*\.?\d*\s*(?:-|to||—)\s*\$?\s*\d[\d,]*\.?\d*",
re.IGNORECASE,
)
# Money with multiplier: "$94.9 billion", "$1.2 million", "€5 billion"
_MONEY_MULT_RE = re.compile(
r"[€£¥$]\s*\d[\d,]*\.?\d*\s*(?:trillion|billion|million|thousand|bn|mn|tn|[kmbt])\b",
re.IGNORECASE,
)
# Simple currency: $123.45, €99, £1,234.56
# Note: requires digits after decimal point to avoid matching trailing periods
_CURRENCY_RE = re.compile(
r"[€£¥$]\s*\d[\d,]*(?:\.\d+)?"
)
# Currency codes: USD, EUR, GBP, JPY
_CURRENCY_SYMBOLS = {"$": "USD", "": "EUR", "£": "GBP", "¥": "JPY"}
def _detect_currency_unit(text: str) -> str:
"""Detect currency unit from symbol in text."""
for symbol, code in _CURRENCY_SYMBOLS.items():
if symbol in text:
return code
return "USD"
class FinancialParser:
"""Deterministic regex-based financial entity parser.
Detects financial entities in text and returns ParsedCandidate instances
with exact character offsets, literal text, and normalized values.
"""
def parse(self, text: str) -> list[ParsedCandidate]:
"""Parse text for financial entities.
Returns a list of ParsedCandidate sorted by start_char offset.
Overlapping matches are resolved by priority (more specific wins).
"""
if not text or not text.strip():
return []
candidates: list[ParsedCandidate] = []
# Order matters: more specific patterns first to claim offsets
candidates.extend(self._parse_fiscal_periods(text))
candidates.extend(self._parse_dates(text))
candidates.extend(self._parse_basis_points(text))
candidates.extend(self._parse_eps(text))
candidates.extend(self._parse_revenue(text))
candidates.extend(self._parse_ranges(text))
candidates.extend(self._parse_percentages(text))
candidates.extend(self._parse_money_with_multiplier(text))
candidates.extend(self._parse_tickers(text))
candidates.extend(self._parse_currency(text))
# Resolve overlaps: keep higher-priority (earlier in list) matches
candidates = self._resolve_overlaps(candidates)
# Sort by position
candidates.sort(key=lambda c: (c.start_char, -c.end_char))
return candidates
def _resolve_overlaps(self, candidates: list[ParsedCandidate]) -> list[ParsedCandidate]:
"""Remove overlapping candidates, keeping earlier ones (higher priority)."""
if not candidates:
return []
# Sort by start position for greedy non-overlap resolution
sorted_candidates = sorted(candidates, key=lambda c: (c.start_char, -c.end_char))
result: list[ParsedCandidate] = []
claimed: list[tuple[int, int]] = []
for cand in sorted_candidates:
overlaps = False
for start, end in claimed:
# Check if this candidate overlaps with any claimed range
if cand.start_char < end and cand.end_char > start:
overlaps = True
break
if not overlaps:
result.append(cand)
claimed.append((cand.start_char, cand.end_char))
return result
def _parse_tickers(self, text: str) -> list[ParsedCandidate]:
"""Parse ticker symbols: $AAPL style."""
results: list[ParsedCandidate] = []
# Dollar-prefixed tickers: $AAPL
for match in _TICKER_DOLLAR_RE.finditer(text):
ticker = match.group(1)
if ticker not in _TICKER_STOPWORDS:
results.append(ParsedCandidate(
candidate_type=CandidateType.TICKER,
literal_value=match.group(0),
normalized_value=None,
unit=None,
start_char=match.start(),
end_char=match.end(),
))
return results
def _parse_fiscal_periods(self, text: str) -> list[ParsedCandidate]:
"""Parse fiscal periods: Q1 2024, FY2025, H1 2024."""
results: list[ParsedCandidate] = []
for match in _FISCAL_PERIOD_RE.finditer(text):
period_prefix = match.group(1)
year_str = match.group(2)
year = int(year_str)
if len(year_str) == 2:
year = 2000 + year if year < 80 else 1900 + year
if period_prefix.startswith("Q"):
period_type = "quarter"
period_value = period_prefix
elif period_prefix.startswith("H"):
period_type = "half"
period_value = period_prefix
else: # FY
period_type = "fiscal_year"
period_value = "FY"
results.append(ParsedCandidate(
candidate_type=CandidateType.FISCAL_PERIOD,
literal_value=match.group(0),
normalized_value=None,
unit=None,
start_char=match.start(),
end_char=match.end(),
period=PeriodAnnotation(
period_type=period_type,
period_value=period_value,
year=year,
),
))
return results
def _parse_dates(self, text: str) -> list[ParsedCandidate]:
"""Parse dates: January 15, 2024 / 2024-01-15."""
results: list[ParsedCandidate] = []
for match in _DATE_NAMED_RE.finditer(text):
results.append(ParsedCandidate(
candidate_type=CandidateType.DATE,
literal_value=match.group(0),
normalized_value=None,
unit=None,
start_char=match.start(),
end_char=match.end(),
))
for match in _DATE_ISO_RE.finditer(text):
results.append(ParsedCandidate(
candidate_type=CandidateType.DATE,
literal_value=match.group(0),
normalized_value=None,
unit=None,
start_char=match.start(),
end_char=match.end(),
))
return results
def _parse_basis_points(self, text: str) -> list[ParsedCandidate]:
"""Parse basis points: 25 basis points, 50bps."""
results: list[ParsedCandidate] = []
for match in _BASIS_POINTS_RE.finditer(text):
literal = match.group(0)
normalized = normalize_basis_points(literal)
results.append(ParsedCandidate(
candidate_type=CandidateType.BASIS_POINTS,
literal_value=literal,
normalized_value=normalized,
unit="bps",
start_char=match.start(),
end_char=match.end(),
))
return results
def _parse_percentages(self, text: str) -> list[ParsedCandidate]:
"""Parse percentages: 4%, -2.5%, +1.2 percent."""
results: list[ParsedCandidate] = []
for match in _PERCENTAGE_RE.finditer(text):
literal = match.group(0)
normalized = normalize_percentage(literal)
results.append(ParsedCandidate(
candidate_type=CandidateType.PERCENTAGE,
literal_value=literal,
normalized_value=normalized,
unit="%",
start_char=match.start(),
end_char=match.end(),
))
return results
def _parse_eps(self, text: str) -> list[ParsedCandidate]:
"""Parse EPS values: $1.52 per share, EPS of $1.52."""
results: list[ParsedCandidate] = []
for match in _EPS_PER_SHARE_RE.finditer(text):
literal = match.group(0)
normalized = normalize_money(literal)
results.append(ParsedCandidate(
candidate_type=CandidateType.EPS,
literal_value=literal,
normalized_value=normalized,
unit="USD",
start_char=match.start(),
end_char=match.end(),
))
for match in _EPS_PREFIX_RE.finditer(text):
literal = match.group(0)
normalized = normalize_money(literal)
results.append(ParsedCandidate(
candidate_type=CandidateType.EPS,
literal_value=literal,
normalized_value=normalized,
unit="USD",
start_char=match.start(),
end_char=match.end(),
))
return results
def _parse_revenue(self, text: str) -> list[ParsedCandidate]:
"""Parse revenue figures: $94.9 billion in revenue, revenue of $50 billion."""
results: list[ParsedCandidate] = []
for match in _REVENUE_AMOUNT_RE.finditer(text):
literal = match.group(0)
normalized = normalize_money(literal)
results.append(ParsedCandidate(
candidate_type=CandidateType.REVENUE,
literal_value=literal,
normalized_value=normalized,
unit="USD",
start_char=match.start(),
end_char=match.end(),
))
for match in _REVENUE_PREFIX_RE.finditer(text):
literal = match.group(0)
normalized = normalize_money(literal)
results.append(ParsedCandidate(
candidate_type=CandidateType.REVENUE,
literal_value=literal,
normalized_value=normalized,
unit="USD",
start_char=match.start(),
end_char=match.end(),
))
return results
def _parse_ranges(self, text: str) -> list[ParsedCandidate]:
"""Parse ranges: $10-$12, $1.50 to $2.00."""
results: list[ParsedCandidate] = []
for match in _RANGE_RE.finditer(text):
literal = match.group(0)
low, high = normalize_range(literal)
# Store midpoint as normalized value
normalized = None
if low is not None and high is not None:
normalized = (low + high) / 2.0
unit = _detect_currency_unit(literal)
results.append(ParsedCandidate(
candidate_type=CandidateType.RANGE,
literal_value=literal,
normalized_value=normalized,
unit=unit,
start_char=match.start(),
end_char=match.end(),
))
return results
def _parse_money_with_multiplier(self, text: str) -> list[ParsedCandidate]:
"""Parse money with multiplier: $94.9 billion, €5 million."""
results: list[ParsedCandidate] = []
for match in _MONEY_MULT_RE.finditer(text):
literal = match.group(0)
normalized = normalize_money(literal)
unit = _detect_currency_unit(literal)
results.append(ParsedCandidate(
candidate_type=CandidateType.MONEY,
literal_value=literal,
normalized_value=normalized,
unit=unit,
start_char=match.start(),
end_char=match.end(),
))
return results
def _parse_currency(self, text: str) -> list[ParsedCandidate]:
"""Parse simple currency: $123.45, €99, £1,234.56."""
results: list[ParsedCandidate] = []
for match in _CURRENCY_RE.finditer(text):
literal = match.group(0)
normalized = normalize_money(literal)
unit = _detect_currency_unit(literal)
results.append(ParsedCandidate(
candidate_type=CandidateType.CURRENCY,
literal_value=literal,
normalized_value=normalized,
unit=unit,
start_char=match.start(),
end_char=match.end(),
))
return results
@@ -0,0 +1,47 @@
"""Pydantic models for parsed financial candidates."""
from __future__ import annotations
from enum import Enum
from pydantic import BaseModel, Field
class CandidateType(str, Enum):
"""Types of financial entities detected by the deterministic parser."""
TICKER = "ticker"
CURRENCY = "currency"
MONEY = "money"
PERCENTAGE = "percentage"
BASIS_POINTS = "basis_points"
RANGE = "range"
EPS = "eps"
REVENUE = "revenue"
DATE = "date"
FISCAL_PERIOD = "fiscal_period"
class PeriodAnnotation(BaseModel):
"""Optional period context for a parsed candidate (e.g., Q1, FY2025)."""
period_type: str = Field(description="Type: quarter, year, fiscal_year, half")
period_value: str = Field(description="Normalized period: Q1, Q2, H1, FY")
year: int | None = Field(default=None, description="Calendar or fiscal year")
class ParsedCandidate(BaseModel):
"""A single parsed financial entity with source offset and normalization.
Stores both the literal text as it appeared in the source document and the
normalized numeric value (if applicable). Exact character offsets allow
downstream evidence linking back to the source.
"""
candidate_type: CandidateType = Field(description="Classification of the parsed entity")
literal_value: str = Field(min_length=1, description="Exact text as it appears in source")
normalized_value: float | None = Field(default=None, description="Normalized numeric value")
unit: str | None = Field(default=None, description="Unit: USD, EUR, %, bps, etc.")
start_char: int = Field(ge=0, description="Start character offset in source text")
end_char: int = Field(gt=0, description="End character offset in source text (exclusive)")
period: PeriodAnnotation | None = Field(default=None, description="Optional fiscal/calendar period")
@@ -0,0 +1,147 @@
"""Normalization rules for financial text values.
Converts literal financial text into normalized numeric values:
- "$94.9 billion" → 94_900_000_000.0
- "25 basis points" → 0.25 (percentage points)
- "$1.52 per share" → 1.52
- "4%" → 4.0
- "$123.45" → 123.45
- "€99" → 99.0
"""
from __future__ import annotations
import re
# Multiplier suffixes for money normalization
_MULTIPLIERS: dict[str, float] = {
"trillion": 1_000_000_000_000.0,
"billion": 1_000_000_000.0,
"million": 1_000_000.0,
"thousand": 1_000.0,
"k": 1_000.0,
"m": 1_000_000.0,
"b": 1_000_000_000.0,
"t": 1_000_000_000_000.0,
"bn": 1_000_000_000.0,
"mn": 1_000_000.0,
"tn": 1_000_000_000_000.0,
}
_NUMBER_RE = re.compile(r"[+-]?\d[\d,]*\.?\d*")
def _extract_number(text: str) -> float | None:
"""Extract the first numeric value from text, stripping commas."""
match = _NUMBER_RE.search(text)
if not match:
return None
num_str = match.group().replace(",", "")
try:
return float(num_str)
except ValueError:
return None
def _find_multiplier(text: str) -> float:
"""Find a magnitude multiplier in text (billion, million, etc.)."""
lower = text.lower()
for suffix, mult in _MULTIPLIERS.items():
# Match word boundaries for short suffixes to avoid false positives
if len(suffix) <= 2:
if re.search(rf"\b{suffix}\b", lower):
return mult
else:
if suffix in lower:
return mult
return 1.0
def normalize_money(text: str) -> float | None:
"""Normalize a money expression to its numeric value.
Examples:
"$94.9 billion" → 94_900_000_000.0
"$1.52 per share" → 1.52
"€123.45" → 123.45
"$1,234" → 1234.0
"""
number = _extract_number(text)
if number is None:
return None
# Check for "per share" — don't apply multiplier
lower = text.lower()
if "per share" in lower:
return number
multiplier = _find_multiplier(text)
return number * multiplier
def normalize_percentage(text: str) -> float | None:
"""Normalize a percentage to its numeric value.
Examples:
"4%" → 4.0
"-2.5%" → -2.5
"+1.2 percent" → 1.2
"""
return _extract_number(text)
def normalize_basis_points(text: str) -> float | None:
"""Normalize basis points to percentage points.
Examples:
"25 basis points" → 0.25
"50bps" → 0.50
"100 bps" → 1.0
"""
number = _extract_number(text)
if number is None:
return None
return number / 100.0
def normalize_range(text: str) -> tuple[float | None, float | None]:
"""Normalize a range expression to (low, high).
Examples:
"$10-$12" → (10.0, 12.0)
"$1.50 to $2.00" → (1.50, 2.00)
"""
numbers = _NUMBER_RE.findall(text)
if len(numbers) < 2:
return (None, None)
try:
low = float(numbers[0].replace(",", ""))
high = float(numbers[1].replace(",", ""))
return (low, high)
except ValueError:
return (None, None)
def normalize_value(candidate_type: str, text: str) -> float | None:
"""Normalize a candidate value based on its type.
Returns the normalized numeric value, or None if not applicable.
For ranges, returns the midpoint.
"""
if candidate_type == "money" or candidate_type == "currency":
return normalize_money(text)
elif candidate_type == "percentage":
return normalize_percentage(text)
elif candidate_type == "basis_points":
return normalize_basis_points(text)
elif candidate_type == "eps":
return normalize_money(text)
elif candidate_type == "revenue":
return normalize_money(text)
elif candidate_type == "range":
low, high = normalize_range(text)
if low is not None and high is not None:
return (low + high) / 2.0
return low
else:
return None