diff --git a/backend/ai/generic_openai_provider.py b/backend/ai/generic_openai_provider.py new file mode 100644 index 0000000..be51272 --- /dev/null +++ b/backend/ai/generic_openai_provider.py @@ -0,0 +1,103 @@ +"""GenericOpenAIProvider — unified OpenAI-compatible provider for all 8 compat vendors. + +Covers: Groq, xAI/Grok, DeepSeek, OpenRouter, Gemini-compat, Mistral-compat, + Ollama, LMStudio (D-16/D-17/D-18). + +Key design decisions: + - Subclasses OpenAIProvider to inherit singleton _client and _truncate (D-07/D-13). + - Conditionally passes response_format={"type":"json_object"} based on + supports_json_mode flag (D-01) — Gemini preset sets this to False (D-02). + - Always parses the raw response with parse_classification / parse_suggestions + imported from ai.utils (D-02 last-resort fallback contract — NEVER redefine + locally; CLAUDE.md shared module map rule). +""" +from __future__ import annotations + +from ai.openai_provider import OpenAIProvider +from ai.utils import parse_classification, parse_suggestions # D-02 contract + + +class GenericOpenAIProvider(OpenAIProvider): + """OpenAI-compatible provider that enforces JSON mode on every call. + + Named presets (Groq, xAI, DeepSeek, OpenRouter, Gemini-compat, Mistral-compat, + Ollama, LMStudio) are factory shortcuts in ai/__init__.py that pass the known + base_url default for each vendor. + + supports_json_mode=False routes the provider through the parse_classification() + fallback path without sending response_format — used for Gemini (D-02). + """ + + supports_json_mode: bool = True # class-level default; overridden per instance + + def __init__( + self, + api_key: str, + model: str, + base_url: str | None, + context_chars: int = 8000, + supports_json_mode: bool = True, + ): + super().__init__( + api_key=api_key, + model=model, + base_url=base_url, + context_chars=context_chars, + ) + # Instance-level flag (may differ from class-level default) + self.supports_json_mode = supports_json_mode + + async def classify( + self, + document_text: str, + existing_topics: list[str], + system_prompt: str, + ): + 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)}" + ) + create_kwargs = dict( + model=self._model, + max_tokens=1024, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_msg}, + ], + ) + if self.supports_json_mode: + # D-01: enforce structured JSON output on all supporting providers + create_kwargs["response_format"] = {"type": "json_object"} + # else: Gemini preset — omit response_format, fall back to parse_classification() + response = await self._client.chat.completions.create(**create_kwargs) + raw = response.choices[0].message.content or "" + # D-02: parse_classification is the last-resort fallback — always called + 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)}" + ) + create_kwargs = dict( + model=self._model, + max_tokens=256, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_msg}, + ], + ) + if self.supports_json_mode: + create_kwargs["response_format"] = {"type": "json_object"} + response = await self._client.chat.completions.create(**create_kwargs) + raw = response.choices[0].message.content or "" + # D-02: parse_suggestions is the last-resort fallback — always called + return parse_suggestions(raw) + + # health_check is inherited from OpenAIProvider — no override needed diff --git a/backend/ai/lmstudio_provider.py b/backend/ai/lmstudio_provider.py index 36cf759..d2343e2 100644 --- a/backend/ai/lmstudio_provider.py +++ b/backend/ai/lmstudio_provider.py @@ -2,9 +2,15 @@ from ai.openai_provider import OpenAIProvider class LMStudioProvider(OpenAIProvider): - def __init__(self, base_url: str = "http://host.docker.internal:1234", model: str = "gemma-4-e4b-it"): + def __init__( + self, + base_url: str = "http://host.docker.internal:1234", + model: str = "gemma-4-e4b-it", + context_chars: int = 8000, + ): super().__init__( api_key="lm-studio", model=model, base_url=base_url.rstrip("/") + "/v1", + context_chars=context_chars, ) diff --git a/backend/ai/ollama_provider.py b/backend/ai/ollama_provider.py index 619673c..ed72bfe 100644 --- a/backend/ai/ollama_provider.py +++ b/backend/ai/ollama_provider.py @@ -2,9 +2,15 @@ from ai.openai_provider import OpenAIProvider class OllamaProvider(OpenAIProvider): - def __init__(self, base_url: str = "http://host.docker.internal:11434", model: str = "llama3.2"): + def __init__( + self, + base_url: str = "http://host.docker.internal:11434", + model: str = "llama3.2", + context_chars: int = 8000, + ): super().__init__( api_key="ollama", model=model, base_url=base_url.rstrip("/") + "/v1", + context_chars=context_chars, ) diff --git a/backend/ai/openai_provider.py b/backend/ai/openai_provider.py index e12af79..adcdb5d 100644 --- a/backend/ai/openai_provider.py +++ b/backend/ai/openai_provider.py @@ -1,18 +1,39 @@ +from __future__ import annotations + from openai import AsyncOpenAI from ai.base import AIProvider, ClassificationResult from ai.utils import parse_classification, parse_suggestions -MAX_AI_CHARS = 8_000 - class OpenAIProvider(AIProvider): - def __init__(self, api_key: str, model: str = "gpt-4o", base_url=None): # type: ignore[type-arg] - self._api_key = api_key + 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 _client(self) -> AsyncOpenAI: - return AsyncOpenAI(api_key=self._api_key or "placeholder", 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, @@ -23,9 +44,9 @@ class OpenAIProvider(AIProvider): topics_str = ", ".join(existing_topics) if existing_topics else "(none yet)" user_msg = ( f"Existing topics: [{topics_str}]\n\n" - f"Document text:\n{document_text[:MAX_AI_CHARS]}" + f"Document text:\n{self._truncate(document_text)}" ) - response = await self._client().chat.completions.create( + response = await self._client.chat.completions.create( model=self._model, max_tokens=1024, messages=[ @@ -44,9 +65,9 @@ class OpenAIProvider(AIProvider): user_msg = ( "Suggest 3-5 topic names for this document. " "Return ONLY valid JSON: {\"suggested_topics\": [\"topic1\", \"topic2\"]}\n\n" - f"Document text:\n{document_text[:MAX_AI_CHARS]}" + f"Document text:\n{self._truncate(document_text)}" ) - response = await self._client().chat.completions.create( + response = await self._client.chat.completions.create( model=self._model, max_tokens=256, messages=[ @@ -59,7 +80,7 @@ class OpenAIProvider(AIProvider): async def health_check(self) -> bool: try: - await self._client().chat.completions.create( + await self._client.chat.completions.create( model=self._model, max_tokens=5, messages=[{"role": "user", "content": "ping"}], @@ -67,5 +88,3 @@ class OpenAIProvider(AIProvider): return True except Exception: return False - - diff --git a/backend/requirements.txt b/backend/requirements.txt index 269dd5c..c3a04c1 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,7 +3,7 @@ uvicorn[standard]>=0.29 python-multipart>=0.0.27 pydantic-settings>=2.2 pydantic[email]>=2.0 -anthropic>=0.26 +anthropic>=0.95.0 openai>=1.30 PyMuPDF>=1.26.7 python-docx>=1.1 diff --git a/backend/services/classifier.py b/backend/services/classifier.py index a74e4ff..f9cd2ba 100644 --- a/backend/services/classifier.py +++ b/backend/services/classifier.py @@ -25,8 +25,6 @@ from db.models import Document from services import storage from ai import get_provider -MAX_AI_CHARS = 8_000 - _DEFAULT_SYSTEM_PROMPT = """You are a document classification assistant. When given a document's text content and a list of existing topics, you must: 1. Assign the document to one or more relevant topics from the list. 2. If no existing topics fit well, suggest new topic names. @@ -82,7 +80,7 @@ async def classify_document( topic_names = [t["name"] for t in all_topics] text = meta.get("extracted_text", "") - result = await provider.classify(text[:MAX_AI_CHARS], topic_names, system_prompt) + result = await provider.classify(text, topic_names, system_prompt) # Collect all topic names to persist (assigned + suggested) all_new_names = set(result.suggested_new_topics) | set(result.topics) @@ -124,4 +122,4 @@ async def suggest_topics_for_document( } provider = get_provider(_settings) text = meta.get("extracted_text", "") - return await provider.suggest_topics(text[:MAX_AI_CHARS], system_prompt) + return await provider.suggest_topics(text, system_prompt)