feat(07-02): singleton OpenAIProvider + GenericOpenAIProvider + MAX_AI_CHARS removal

- openai_provider.py: singleton self._client=AsyncOpenAI(...) in __init__ (D-07),
  _truncate() 60/40 smart truncation (D-13), removed MAX_AI_CHARS constant,
  changed __init__ signature to include context_chars, removed _client() method
- generic_openai_provider.py: new class, subclasses OpenAIProvider, conditional
  response_format={"type":"json_object"} on supports_json_mode flag (D-01/D-02),
  imports parse_classification + parse_suggestions from ai.utils (D-02 contract)
- ollama_provider.py: added context_chars kwarg with default 8000, passes through
- lmstudio_provider.py: added context_chars kwarg with default 8000, passes through
- classifier.py: removed MAX_AI_CHARS constant and text[:MAX_AI_CHARS] slices;
  truncation now handled inside each provider via _truncate()
- requirements.txt: bumped anthropic floor to >=0.95.0 (D-03 output_config support)
This commit is contained in:
curo1305
2026-06-04 18:58:19 +02:00
parent beb5b5e49d
commit 02bcbb9143
6 changed files with 152 additions and 20 deletions
+103
View File
@@ -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
+7 -1
View File
@@ -2,9 +2,15 @@ from ai.openai_provider import OpenAIProvider
class LMStudioProvider(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__( super().__init__(
api_key="lm-studio", api_key="lm-studio",
model=model, model=model,
base_url=base_url.rstrip("/") + "/v1", base_url=base_url.rstrip("/") + "/v1",
context_chars=context_chars,
) )
+7 -1
View File
@@ -2,9 +2,15 @@ from ai.openai_provider import OpenAIProvider
class OllamaProvider(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__( super().__init__(
api_key="ollama", api_key="ollama",
model=model, model=model,
base_url=base_url.rstrip("/") + "/v1", base_url=base_url.rstrip("/") + "/v1",
context_chars=context_chars,
) )
+32 -13
View File
@@ -1,18 +1,39 @@
from __future__ import annotations
from openai import AsyncOpenAI from openai import AsyncOpenAI
from ai.base import AIProvider, ClassificationResult from ai.base import AIProvider, ClassificationResult
from ai.utils import parse_classification, parse_suggestions from ai.utils import parse_classification, parse_suggestions
MAX_AI_CHARS = 8_000
class OpenAIProvider(AIProvider): class OpenAIProvider(AIProvider):
def __init__(self, api_key: str, model: str = "gpt-4o", base_url=None): # type: ignore[type-arg] def __init__(
self._api_key = api_key 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._model = model
self._base_url = base_url 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: def _truncate(self, text: str) -> str:
return AsyncOpenAI(api_key=self._api_key or "placeholder", base_url=self._base_url) """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( async def classify(
self, self,
@@ -23,9 +44,9 @@ class OpenAIProvider(AIProvider):
topics_str = ", ".join(existing_topics) if existing_topics else "(none yet)" topics_str = ", ".join(existing_topics) if existing_topics else "(none yet)"
user_msg = ( user_msg = (
f"Existing topics: [{topics_str}]\n\n" 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, model=self._model,
max_tokens=1024, max_tokens=1024,
messages=[ messages=[
@@ -44,9 +65,9 @@ class OpenAIProvider(AIProvider):
user_msg = ( user_msg = (
"Suggest 3-5 topic names for this document. " "Suggest 3-5 topic names for this document. "
"Return ONLY valid JSON: {\"suggested_topics\": [\"topic1\", \"topic2\"]}\n\n" "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, model=self._model,
max_tokens=256, max_tokens=256,
messages=[ messages=[
@@ -59,7 +80,7 @@ class OpenAIProvider(AIProvider):
async def health_check(self) -> bool: async def health_check(self) -> bool:
try: try:
await self._client().chat.completions.create( await self._client.chat.completions.create(
model=self._model, model=self._model,
max_tokens=5, max_tokens=5,
messages=[{"role": "user", "content": "ping"}], messages=[{"role": "user", "content": "ping"}],
@@ -67,5 +88,3 @@ class OpenAIProvider(AIProvider):
return True return True
except Exception: except Exception:
return False return False
+1 -1
View File
@@ -3,7 +3,7 @@ uvicorn[standard]>=0.29
python-multipart>=0.0.27 python-multipart>=0.0.27
pydantic-settings>=2.2 pydantic-settings>=2.2
pydantic[email]>=2.0 pydantic[email]>=2.0
anthropic>=0.26 anthropic>=0.95.0
openai>=1.30 openai>=1.30
PyMuPDF>=1.26.7 PyMuPDF>=1.26.7
python-docx>=1.1 python-docx>=1.1
+2 -4
View File
@@ -25,8 +25,6 @@ from db.models import Document
from services import storage from services import storage
from ai import get_provider 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: _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. 1. Assign the document to one or more relevant topics from the list.
2. If no existing topics fit well, suggest new topic names. 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] topic_names = [t["name"] for t in all_topics]
text = meta.get("extracted_text", "") 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) # Collect all topic names to persist (assigned + suggested)
all_new_names = set(result.suggested_new_topics) | set(result.topics) 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) provider = get_provider(_settings)
text = meta.get("extracted_text", "") text = meta.get("extracted_text", "")
return await provider.suggest_topics(text[:MAX_AI_CHARS], system_prompt) return await provider.suggest_topics(text, system_prompt)