from __future__ import annotations from openai import AsyncOpenAI from ai.base import AIProvider, ClassificationResult from ai.utils import parse_classification, parse_suggestions class OpenAIProvider(AIProvider): def __init__( self, api_key: str, model: str = "gpt-4o", base_url: str | None = None, context_chars: int = 8000, ): self._api_key = api_key or "not-needed" self._model = model self._base_url = base_url self._context_chars = context_chars # Singleton: created once in __init__, reused for all calls on this instance. # Do NOT recreate per API call — AsyncOpenAI wraps an httpx.AsyncClient # that maintains a connection pool; recreating per call destroys pool reuse # and forces a new TLS handshake per request (D-07 / RESEARCH.md). self._client = AsyncOpenAI(api_key=self._api_key, base_url=self._base_url) def _truncate(self, text: str) -> str: """D-13 smart truncation: first 60% + last 40% of context window. Captures both document introduction and conclusion, which carry the most topic signal for long documents. """ if len(text) <= self._context_chars: return text head_len = int(self._context_chars * 0.6) tail_len = self._context_chars - head_len return text[:head_len] + "\n[...truncated...]\n" + text[-tail_len:] async def classify( self, document_text: str, existing_topics: list[str], system_prompt: str, ) -> ClassificationResult: topics_str = ", ".join(existing_topics) if existing_topics else "(none yet)" user_msg = ( f"Existing topics: [{topics_str}]\n\n" f"Document text:\n{self._truncate(document_text)}" ) response = await self._client.chat.completions.create( model=self._model, max_tokens=1024, messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_msg}, ], ) raw = response.choices[0].message.content or "" return parse_classification(raw) async def suggest_topics( self, document_text: str, system_prompt: str, ) -> list[str]: user_msg = ( "Suggest 3-5 topic names for this document. " "Return ONLY valid JSON: {\"suggested_topics\": [\"topic1\", \"topic2\"]}\n\n" f"Document text:\n{self._truncate(document_text)}" ) response = await self._client.chat.completions.create( model=self._model, max_tokens=256, messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_msg}, ], ) raw = response.choices[0].message.content or "" return parse_suggestions(raw) async def health_check(self) -> bool: try: await self._client.chat.completions.create( model=self._model, max_tokens=5, messages=[{"role": "user", "content": "ping"}], ) return True except Exception: return False