- ProviderConfig + PROVIDER_DEFAULTS + SUPPORTS_JSON_MODE in provider_config.py - GenericOpenAIProvider with JSON-mode conditional + D-02 fallback - OpenAIProvider singleton _client + smart truncation (D-07/D-13) - Registry-based get_provider(config: ProviderConfig) — no if/elif chain - MAX_AI_CHARS removed from openai_provider.py + classifier.py - anthropic floor bumped to >=0.95.0 for Plan 03 output_config - 6 Wave-2 xfail tests promoted; suite: 363 passed, 15 xfailed, 1 pre-existing failure
11 KiB
phase, plan, subsystem, tags, dependency_graph, tech_stack, key_files, decisions, metrics
| phase | plan | subsystem | tags | dependency_graph | tech_stack | key_files | decisions | metrics | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 07-redo-and-optimize-llm-integration | 02 | backend/ai-providers |
|
|
|
|
|
|
Phase 7 Plan 02: Provider Config, GenericOpenAIProvider, and Registry Factory Summary
ProviderConfig Pydantic model with 10-provider PROVIDER_DEFAULTS + SUPPORTS_JSON_MODE; GenericOpenAIProvider subclassing OpenAIProvider with JSON-mode conditional on supports_json_mode; singleton _client lifecycle; smart truncation; registry-based get_provider(); MAX_AI_CHARS removed from two files; 6 Wave-2 xfail tests promoted.
Tasks Completed
| Task | Description | Commit | Files |
|---|---|---|---|
| 1 | ProviderConfig + PROVIDER_DEFAULTS + SUPPORTS_JSON_MODE | beb5b5e |
backend/ai/provider_config.py |
| 2 | GenericOpenAIProvider + singleton OpenAIProvider + MAX_AI_CHARS removal + ollama/lmstudio context_chars | 02bcbb9 |
openai_provider.py, generic_openai_provider.py, ollama_provider.py, lmstudio_provider.py, classifier.py, requirements.txt |
| 3 | Registry-based get_provider(config: ProviderConfig) | 13eef37 |
ai/__init__.py, provider_config.py |
| 4 | Promote 6 Wave-2 xfail stubs to passing | 209b156 |
tests/test_ai_providers.py |
What Was Built
Task 1: ProviderConfig + PROVIDER_DEFAULTS + SUPPORTS_JSON_MODE
Created backend/ai/provider_config.py as a pure data module (no provider class imports):
ProviderConfig(BaseModel)withextra="forbid": provider_id (str, required), api_key (str, default ""), base_url (Optional[str], default None), model (str, default ""), context_chars (int, default 0 — sentinel meaning "use PROVIDER_DEFAULTS")PROVIDER_DEFAULTS: dict[str, dict]with 10 entries from RESEARCH.md Pattern 2: openai, anthropic, gemini, groq, xai, deepseek, openrouter, mistral, ollama, lmstudio — each with base_url, model, context_charsSUPPORTS_JSON_MODE: dict[str, bool]with gemini=False (OpenAI compat endpoint does not supportjson_objectstring form) and True for all others
Task 2: GenericOpenAIProvider + Singleton OpenAIProvider + Removals
backend/ai/openai_provider.py refactored:
__init__now acceptscontext_chars: intparameterself._client = AsyncOpenAI(api_key=self._api_key, base_url=self._base_url)stored as singleton in__init__(D-07)def _client(self)method deleted entirelyMAX_AI_CHARS = 8_000constant deleteddef _truncate(self, text: str) -> stradded: returns text unchanged if len <= context_chars, otherwisetext[:head] + "\n[...truncated...]\n" + text[-tail:]where head = int(context_chars*0.6), tail = context_chars - head (D-13)classify(),suggest_topics(),health_check()updated to useself._client.chat.completions.create(...)(no parentheses)
backend/ai/generic_openai_provider.py created:
class GenericOpenAIProvider(OpenAIProvider)withsupports_json_modeinstance attribute__init__acceptssupports_json_mode: bool = Truekwarg, callssuper().__init__(...)classify()andsuggest_topics()conditionally addresponse_format={"type":"json_object"}whensupports_json_mode is True; omit it for Gemini preset (D-01/D-02)- Both methods import and call
parse_classification/parse_suggestionsfromai.utils— D-02 contract enforced via import line health_check()inherited from OpenAIProvider
backend/ai/ollama_provider.py and lmstudio_provider.py: Added context_chars: int = 8000 parameter, passed through to super().__init__().
backend/services/classifier.py: Removed MAX_AI_CHARS = 8_000 constant and replaced text[:MAX_AI_CHARS] slices with text (truncation now inside provider via _truncate()).
backend/requirements.txt: anthropic>=0.26 bumped to anthropic>=0.95.0 (D-03 output_config support gate for Plan 03).
Task 3: Registry-Based Factory
backend/ai/__init__.py rewritten:
_REGISTRY: dict[str, type[AIProvider]]maps 10 provider_ids to classesdef get_provider(config: ProviderConfig) -> AIProviderwith typed signature (no raw dict)- Resolves effective values from PROVIDER_DEFAULTS when config fields are empty/zero
- AnthropicProvider instantiated without base_url (current ctor contract; Plan 03 widens)
- GenericOpenAIProvider gets
supports_json_mode=SUPPORTS_JSON_MODE[config.provider_id] - Raises
ValueError(f"Unknown AI provider: {config.provider_id!r}")for unknown ids
Task 4: Six Wave-2 Tests Promoted
All 6 tests now pass without @pytest.mark.xfail:
- test_get_provider_typed: Registry lookup returns GenericOpenAIProvider for groq/gemini; ValueError for unknown; supports_json_mode correct; _context_chars from PROVIDER_DEFAULTS
- test_client_singleton:
AsyncOpenAIclass called exactly once per provider instance (mocked via patch) - test_generic_openai_json_mode:
response_formatpresent in kwargs when supports_json_mode=True; absent when False - test_context_chars_truncation: Provider with context_chars=100 truncates 500-char input with "[...truncated...]"
- test_smart_truncation: 1000-char limit on 10000-char input → starts with 600 'H's, ends with 400 'T's
- test_gemini_fallback_to_parse_classification: D-02 contract —
parse_classificationcalled with raw content ANDresponse_formatabsent from API call kwargs
test_anthropic_structured_output remains xfail (Plan 07-03).
Verification Results
grep -v '^#' backend/ai/openai_provider.py | grep -c 'MAX_AI_CHARS'→ 0grep -v '^#' backend/services/classifier.py | grep -c 'MAX_AI_CHARS'→ 0grep "from ai.utils import parse_classification" backend/ai/generic_openai_provider.py→ match foundgrep -c "def parse_classification" backend/ai/utils.py→ 1 (file untouched)backend/ai/__init__.pycontains_REGISTRYanddef get_provider(config: ProviderConfig)and all 10 provider keysrequirements.txtcontainsanthropic>=0.95.0- Full test suite: 1 failed (pre-existing test_extract_docx ModuleNotFoundError), 363 passed, 15 xfailed, 5 skipped — no new failures; xfailed count down by 6
Deviations from Plan
Auto-fixed Issues
1. [Rule 1 - Bug] ProviderConfig.context_chars default changed from 8000 to 0
- Found during: Task 3 (test_get_provider_typed assertion failure)
- Issue: Task 1 spec said
context_chars: int = 8000but Task 3 test assertsresult._context_chars == PROVIDER_DEFAULTS["groq"]["context_chars"](128000) whenProviderConfig(provider_id="groq")is created without specifying context_chars. With default=8000,8000 or 128000 = 8000(truthy short-circuit) — test fails. - Fix: Changed
context_chars: int = 0(sentinel meaning "unset — use PROVIDER_DEFAULTS in factory"). The factory'sconfig.context_chars or defaults["context_chars"]then correctly resolves:0 or 128000 = 128000. - Files modified: backend/ai/provider_config.py
- Commit:
13eef37
Known Stubs
None — all plan goals implemented; no placeholders.
Threat Flags
No new threat surface introduced. Changes are purely internal provider class refactoring and factory logic — no new API endpoints, no new DB access patterns, no new network paths. T-07-04 (empty api_key) mitigated: factory normalizes api_key or "not-needed" before passing to AsyncOpenAI constructor.
Self-Check: PASSED
Files created/modified:
- backend/ai/provider_config.py — FOUND (ProviderConfig + PROVIDER_DEFAULTS + SUPPORTS_JSON_MODE)
- backend/ai/generic_openai_provider.py — FOUND (GenericOpenAIProvider class present)
- backend/ai/openai_provider.py — FOUND (singleton _client, _truncate, no MAX_AI_CHARS)
- backend/ai/ollama_provider.py — FOUND (context_chars param present)
- backend/ai/lmstudio_provider.py — FOUND (context_chars param present)
- backend/ai/__init__.py — FOUND (_REGISTRY and get_provider(config: ProviderConfig))
- backend/services/classifier.py — FOUND (MAX_AI_CHARS removed)
- backend/requirements.txt — FOUND (anthropic>=0.95.0)
- backend/tests/test_ai_providers.py — FOUND (6 tests promoted, 1 xfail remaining)
Commits:
beb5b5e— feat(07-02): ProviderConfig Pydantic model + PROVIDER_DEFAULTS + SUPPORTS_JSON_MODE02bcbb9— feat(07-02): singleton OpenAIProvider + GenericOpenAIProvider + MAX_AI_CHARS removal13eef37— feat(07-02): registry-based get_provider(config: ProviderConfig) — D-06209b156— test(07-02): promote 6 Wave-2 xfail tests to passing — D-01/D-02/D-07/D-12/D-13