"""Parse forward-looking guidance from MD&A and earnings call transcripts. Uses Claude Haiku to extract structured low/high ranges per metric for the next reporting period. Returns columns aligned with metrics_db._GUIDANCE_COLS. """ from __future__ import annotations import json import re import sys from typing import Optional _EMPTY = { "guidance_period": None, "guidance_revenue_low": None, "guidance_revenue_high": None, "guidance_eps_low": None, "guidance_eps_high": None, "guidance_gross_margin_low": None, "guidance_gross_margin_high": None, "guidance_operating_margin_low": None, "guidance_operating_margin_high": None, } MODEL = "claude-haiku-4-5-20251001" MAX_CHARS = 8000 _GUIDANCE_HINT = re.compile( r"(outlook|guidance|expect|anticipate|forecast|project|fiscal\s+(?:first|second|third|fourth|q[1-4]))", re.IGNORECASE, ) def _extract_relevant(text: str, max_chars: int) -> str: """Pull paragraphs that look like guidance to keep prompt small.""" if not text: return "" paras = re.split(r"\n{2,}|(?<=\.)\s{2,}", text) keep = [p for p in paras if _GUIDANCE_HINT.search(p)] joined = "\n\n".join(keep) if keep else text return joined[:max_chars] def _extract_json(raw: str) -> str: m = re.search(r"```(?:json)?\s*(\{.*\})\s*```", raw, re.DOTALL) if m: return m.group(1) s, e = raw.find("{"), raw.rfind("}") return raw[s:e + 1] if (s != -1 and e != -1 and e > s) else raw _PROMPT = """You extract forward-looking financial guidance from SEC filings and earnings calls. Current reporting period just disclosed: {current_period} Input text (MD&A excerpt + transcript excerpt): --- {text} --- Extract guidance issued by management for the NEXT reporting period (or full year if only annual is given). Return STRICT JSON with this exact schema: {{ "next_period": "Q12026" | "FY2026" | null, "revenue": {{"low": , "high": }} | null, "eps": {{"low": , "high": }} | null, "gross_margin": {{"low": , "high": }} | null, "operating_margin": {{"low": , "high": }} | null }} Rules: - USD values in dollars (e.g. $94.5B → 94500000000). - Margins as decimals (e.g. "75% gross margin" → 0.75). NEVER as percentages. - If management gives a point estimate, set low == high. - If a metric is not mentioned, use null. - If no forward guidance exists at all, return all-null fields. - Output JSON only, no commentary.""" def parse_guidance( mda_text: str, transcript_text: Optional[str], current_period: str, ) -> dict: """Return a dict with keys matching metrics_db._GUIDANCE_COLS. All-None on parse failure or absent guidance. Never raises. """ combined = "\n\n=== TRANSCRIPT ===\n\n".join( t for t in (_extract_relevant(mda_text or "", MAX_CHARS // 2), _extract_relevant(transcript_text or "", MAX_CHARS // 2)) if t ) if not combined.strip(): return dict(_EMPTY) try: from langchain_anthropic import ChatAnthropic from langchain_core.messages import HumanMessage llm = ChatAnthropic(model=MODEL, temperature=0) resp = llm.invoke([HumanMessage( content=_PROMPT.format(current_period=current_period or "unknown", text=combined) )]) raw = resp.content if isinstance(resp.content, str) else str(resp.content) data = json.loads(_extract_json(raw)) except Exception as exc: print(f"[guidance_parser] WARNING: parse failed: {exc}", file=sys.stderr) return dict(_EMPTY) def _pair(obj, key): v = obj.get(key) if isinstance(obj, dict) else None if not isinstance(v, dict): return None, None lo, hi = v.get("low"), v.get("high") try: lo = float(lo) if lo is not None else None hi = float(hi) if hi is not None else None except (TypeError, ValueError): return None, None return lo, hi rev_lo, rev_hi = _pair(data, "revenue") eps_lo, eps_hi = _pair(data, "eps") gm_lo, gm_hi = _pair(data, "gross_margin") om_lo, om_hi = _pair(data, "operating_margin") return { "guidance_period": data.get("next_period") if isinstance(data, dict) else None, "guidance_revenue_low": rev_lo, "guidance_revenue_high": rev_hi, "guidance_eps_low": eps_lo, "guidance_eps_high": eps_hi, "guidance_gross_margin_low": gm_lo, "guidance_gross_margin_high": gm_hi, "guidance_operating_margin_low": om_lo, "guidance_operating_margin_high": om_hi, }