VTdevelops/bond-text-extraction
0
1from __future__ import annotations2 3import json4from typing import Sequence5 6from openai import OpenAI7from openai.types.responses import Response8 9from .models import BondRecord10 11BOND_FIELD_DESCRIPTIONS: dict[str, str] = {12 "status": "Current status of the instrument (e.g. Active, Closed, In Preparation).",13 "effective_from": "Date from which the issuance or terms become effective.",14 "isin": "International Securities Identification Number for the bond.",15 "valor": "Valor or Swiss security identifier, if disclosed.",16 "denomination": "Minimum denomination or face value per security.",17 "issue_size": "Total issue size or aggregate nominal amount.",18 "legal_venue": "Legal venue or governing law associated with the issuance.",19 "security_ranking": "Seniority or ranking of the security (e.g. Senior Unsecured).",20 "issue_price_rate": "Issue price expressed as a percentage of par or nominal value.",21 "jurisdiction_country": "Jurisdiction or country relevant for issuance or taxation.",22 "security_category": "Security category or classification used in the document.",23 "collateralization": "Description of whether and how the security is collateralised.",24 "callable_putable": "Details on call or put features, including schedules.",25 "prolongable": "Information on whether the bond can be prolonged or extended.",26 "amortizable": "Information on whether principal amortises over time.",27 "increasable": "Whether the issuer may increase the issuance amount after launch.",28 "issue_date": "Issue date stated in the term sheet.",29 "payment_date": "Initial payment or settlement date.",30 "redemption_date": "Redemption or maturity date.",31 "coupon_frequency": "Coupon payment frequency (e.g. Semi-annual).",32 "annual_rate": "Annual coupon or yield rate as described.",33 "income_begin": "Income accrual start date.",34 "first_payment_date": "Date of first coupon or interest payment.",35 "day_count_method": "Day count convention applied (e.g. 30/360, ACT/ACT).",36 "earning_specification": "Narrative describing how earnings are calculated/disbursed.",37 "interest_rate": "Interest rate description, including structure or qualifiers used in the document.",38 "rating": "Credit rating or outlook for the bond or issuer.",39}40 41 42_RESPONSE_FORMAT = {43 "type": "json_schema",44 "name": "bond_information_schema",45 "strict": True,46 "schema": {47 "type": "object",48 "properties": {49 "bonds": {50 "type": "array",51 "items": {52 "type": "object",53 "properties": {54 field: {55 "type": "string",56 "description": description,57 }58 for field, description in BOND_FIELD_DESCRIPTIONS.items()59 },60 "required": list(BOND_FIELD_DESCRIPTIONS.keys()),61 "additionalProperties": False,62 },63 }64 },65 "required": ["bonds"],66 "additionalProperties": False,67 },68}69 70_SYSTEM_PROMPT = (71 "You extract comprehensive bond information from financial documents. "72 "Capture the wording exactly as written when possible, without inventing data. "73 "If a field is not present, return an empty string for that entry."74)75 76 77class BondInfoExtractor:78 """Wrapper around the OpenAI client to retrieve bond information."""79 80 def __init__(self, model: str = "gpt-4.1-mini", client: OpenAI | None = None) -> None:81 self.model = model82 self.client = client or OpenAI()83 84 def extract_bonds(85 self,86 documents: Sequence[str],87 extra_instructions: str | None = None,88 ) -> list[BondRecord]:89 """Run the LLM over *documents* and parse the resulting bond records."""90 91 if not documents:92 return []93 94 response = self._run_request(documents, extra_instructions)95 raw_payload = self._extract_json_payload(response)96 bonds = raw_payload.get("bonds", [])97 98 records: list[BondRecord] = []99 for item in bonds:100 payload: dict[str, str] = {}101 for field_info in BondRecord.__dataclass_fields__.values():102 raw_value = item.get(field_info.name, "")103 payload[field_info.name] = str(raw_value).strip() if raw_value is not None else ""104 if not any(payload.values()):105 continue106 records.append(BondRecord(**payload))107 108 return records109 110 def _run_request(111 self,112 documents: Sequence[str],113 extra_instructions: str | None,114 ) -> Response:115 user_sections = []116 for index, doc in enumerate(documents, start=1):117 user_sections.append(f"Document {index}:\n{doc.strip()}")118 119 user_prompt = "\n\n".join(user_sections)120 if extra_instructions:121 user_prompt += f"\n\nAdditional guidance: {extra_instructions.strip()}"122 123 return self.client.responses.create(124 model=self.model,125 input=[126 {"role": "system", "content": [127 {"type": "input_text", "text": _SYSTEM_PROMPT},128 ]},129 {"role": "user", "content": [130 {"type": "input_text", "text": user_prompt},131 ]},132 ],133 text={"format": _RESPONSE_FORMAT},134 )135 136 @staticmethod137 def _extract_json_payload(response: Response) -> dict:138 for output in response.output or []:139 for content in output.content or []:140 if content.type == "output_text":141 return json.loads(content.text)142 if content.type == "json_schema":143 return json.loads(content.json_schema.value)144 raise ValueError("Unexpected response structure from OpenAI Agents API")145 