Three root causes fixed: 1. docker-compose.yml — celery-worker and celery-beat missing PYTHONPATH=/app ForkPoolWorker processes inherit sys.path with '' (CWD), but fork can change CWD so lazy imports like `from db.session import ...` raised ModuleNotFoundError. Adding PYTHONPATH=/app ensures /app is always explicit in the path. 2. provider_config.py — lmstudio SUPPORTS_JSON_MODE set to False LM Studio only accepts response_format type 'json_schema' or 'text', not 'json_object'. GenericOpenAIProvider now omits response_format for lmstudio and relies on parse_classification() fallback (same path as Gemini). 3. generic_openai_provider.py — max_tokens raised 1024→4096 (classify) / 256→1024 (suggest) Qwen3 thinking models (like qwen/qwen3.5-9b) consume reasoning tokens from the same budget. With max_tokens=1024 the model exhausts the budget in the thinking trace, leaving content=''. 4096 gives room for both thinking and the JSON output. Also updates lmstudio PROVIDER_DEFAULTS model to qwen/qwen3.5-9b (confirmed loaded in LM Studio) and context_chars to 32_000 (Qwen3 context window). Tested: classify(invoice_text, ['Finance','Legal','HR']) → topics=['Finance'] ✓ Backend tests: 376 passed ✓ Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
104 lines
4.0 KiB
Python
104 lines
4.0 KiB
Python
"""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=4096,
|
|
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=1024,
|
|
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
|