chore: merge executor worktree (worktree-agent-afe278fe2f809d30b)
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
---
|
||||
phase: 07-redo-and-optimize-llm-integration
|
||||
plan: "02"
|
||||
subsystem: backend/ai-providers
|
||||
tags:
|
||||
- ai
|
||||
- provider-refactor
|
||||
- singleton-client
|
||||
- json-mode
|
||||
- smart-truncation
|
||||
- registry-pattern
|
||||
- pydantic
|
||||
- wave-2
|
||||
dependency_graph:
|
||||
requires:
|
||||
- "07-01 (system_settings table, HKDF helpers, xfail stubs)"
|
||||
provides:
|
||||
- "ProviderConfig Pydantic model + PROVIDER_DEFAULTS + SUPPORTS_JSON_MODE"
|
||||
- "GenericOpenAIProvider covering all 8 OpenAI-compat vendors"
|
||||
- "OpenAIProvider singleton _client lifecycle (D-07)"
|
||||
- "Smart truncation _truncate() 60/40 (D-13)"
|
||||
- "Registry-based get_provider(config: ProviderConfig) — no if/elif chain"
|
||||
- "MAX_AI_CHARS removed from openai_provider.py and classifier.py"
|
||||
- "anthropic SDK floor bumped to >=0.95.0"
|
||||
- "6 Wave-2 xfail stubs promoted to passing tests"
|
||||
affects:
|
||||
- "07-03 (Anthropic output_config — depends on ProviderConfig and registry)"
|
||||
- "07-04 (Celery retry — depends on classifier.py clean pass-through)"
|
||||
- "07-05 (Admin AI panel — depends on ProviderConfig for form validation)"
|
||||
tech_stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "ProviderConfig(BaseModel) with extra=forbid; context_chars=0 sentinel for PROVIDER_DEFAULTS resolution"
|
||||
- "PROVIDER_DEFAULTS dict with 10 entries; SUPPORTS_JSON_MODE dict with gemini=False"
|
||||
- "GenericOpenAIProvider(OpenAIProvider) with conditional response_format kwarg (D-01/D-02)"
|
||||
- "Singleton self._client = AsyncOpenAI(...) in __init__ (D-07)"
|
||||
- "_truncate(): first 60% + last 40% of context_chars (D-13)"
|
||||
- "_REGISTRY dict in ai/__init__.py replaces if/elif chain (O(1) lookup)"
|
||||
- "D-02 invariant: parse_classification/parse_suggestions always imported from ai.utils"
|
||||
key_files:
|
||||
created:
|
||||
- backend/ai/provider_config.py
|
||||
- backend/ai/generic_openai_provider.py
|
||||
modified:
|
||||
- backend/ai/openai_provider.py
|
||||
- backend/ai/ollama_provider.py
|
||||
- backend/ai/lmstudio_provider.py
|
||||
- backend/ai/__init__.py
|
||||
- backend/services/classifier.py
|
||||
- backend/requirements.txt
|
||||
- backend/tests/test_ai_providers.py
|
||||
decisions:
|
||||
- "context_chars=0 as sentinel in ProviderConfig (not 8000) — enables factory to resolve PROVIDER_DEFAULTS via `config.context_chars or defaults['context_chars']`"
|
||||
- "GenericOpenAIProvider always calls parse_classification() as last-resort regardless of json_mode — D-02 invariant preserved even when json_object is requested"
|
||||
- "ai/__init__.py imports GenericOpenAIProvider at module load time; no lazy import needed since provider_config.py has no side effects"
|
||||
- "anthropic floor bumped to >=0.95.0 to unblock Plan 03 output_config usage (A5 from RESEARCH.md)"
|
||||
metrics:
|
||||
duration: "~35 minutes"
|
||||
completed: "2026-06-04"
|
||||
tasks_completed: 4
|
||||
tasks_total: 4
|
||||
files_created: 2
|
||||
files_modified: 7
|
||||
---
|
||||
|
||||
# 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)` with `extra="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_chars
|
||||
- `SUPPORTS_JSON_MODE: dict[str, bool]` with gemini=False (OpenAI compat endpoint does not support `json_object` string form) and True for all others
|
||||
|
||||
### Task 2: GenericOpenAIProvider + Singleton OpenAIProvider + Removals
|
||||
|
||||
**backend/ai/openai_provider.py** refactored:
|
||||
- `__init__` now accepts `context_chars: int` parameter
|
||||
- `self._client = AsyncOpenAI(api_key=self._api_key, base_url=self._base_url)` stored as singleton in `__init__` (D-07)
|
||||
- `def _client(self)` method deleted entirely
|
||||
- `MAX_AI_CHARS = 8_000` constant deleted
|
||||
- `def _truncate(self, text: str) -> str` added: returns text unchanged if len <= context_chars, otherwise `text[: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 use `self._client.chat.completions.create(...)` (no parentheses)
|
||||
|
||||
**backend/ai/generic_openai_provider.py** created:
|
||||
- `class GenericOpenAIProvider(OpenAIProvider)` with `supports_json_mode` instance attribute
|
||||
- `__init__` accepts `supports_json_mode: bool = True` kwarg, calls `super().__init__(...)`
|
||||
- `classify()` and `suggest_topics()` conditionally add `response_format={"type":"json_object"}` when `supports_json_mode is True`; omit it for Gemini preset (D-01/D-02)
|
||||
- Both methods import and call `parse_classification` / `parse_suggestions` from `ai.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 classes
|
||||
- `def get_provider(config: ProviderConfig) -> AIProvider` with 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`:
|
||||
|
||||
1. **test_get_provider_typed**: Registry lookup returns GenericOpenAIProvider for groq/gemini; ValueError for unknown; supports_json_mode correct; _context_chars from PROVIDER_DEFAULTS
|
||||
2. **test_client_singleton**: `AsyncOpenAI` class called exactly once per provider instance (mocked via patch)
|
||||
3. **test_generic_openai_json_mode**: `response_format` present in kwargs when supports_json_mode=True; absent when False
|
||||
4. **test_context_chars_truncation**: Provider with context_chars=100 truncates 500-char input with "[...truncated...]"
|
||||
5. **test_smart_truncation**: 1000-char limit on 10000-char input → starts with 600 'H's, ends with 400 'T's
|
||||
6. **test_gemini_fallback_to_parse_classification**: D-02 contract — `parse_classification` called with raw content AND `response_format` absent 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'` → 0
|
||||
- `grep -v '^#' backend/services/classifier.py | grep -c 'MAX_AI_CHARS'` → 0
|
||||
- `grep "from ai.utils import parse_classification" backend/ai/generic_openai_provider.py` → match found
|
||||
- `grep -c "def parse_classification" backend/ai/utils.py` → 1 (file untouched)
|
||||
- `backend/ai/__init__.py` contains `_REGISTRY` and `def get_provider(config: ProviderConfig)` and all 10 provider keys
|
||||
- `requirements.txt` contains `anthropic>=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 = 8000` but Task 3 test asserts `result._context_chars == PROVIDER_DEFAULTS["groq"]["context_chars"]` (128000) when `ProviderConfig(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's `config.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:
|
||||
|
||||
- [x] backend/ai/provider_config.py — FOUND (ProviderConfig + PROVIDER_DEFAULTS + SUPPORTS_JSON_MODE)
|
||||
- [x] backend/ai/generic_openai_provider.py — FOUND (GenericOpenAIProvider class present)
|
||||
- [x] backend/ai/openai_provider.py — FOUND (singleton _client, _truncate, no MAX_AI_CHARS)
|
||||
- [x] backend/ai/ollama_provider.py — FOUND (context_chars param present)
|
||||
- [x] backend/ai/lmstudio_provider.py — FOUND (context_chars param present)
|
||||
- [x] backend/ai/__init__.py — FOUND (_REGISTRY and get_provider(config: ProviderConfig))
|
||||
- [x] backend/services/classifier.py — FOUND (MAX_AI_CHARS removed)
|
||||
- [x] backend/requirements.txt — FOUND (anthropic>=0.95.0)
|
||||
- [x] backend/tests/test_ai_providers.py — FOUND (6 tests promoted, 1 xfail remaining)
|
||||
|
||||
Commits:
|
||||
- [x] beb5b5e — feat(07-02): ProviderConfig Pydantic model + PROVIDER_DEFAULTS + SUPPORTS_JSON_MODE
|
||||
- [x] 02bcbb9 — feat(07-02): singleton OpenAIProvider + GenericOpenAIProvider + MAX_AI_CHARS removal
|
||||
- [x] 13eef37 — feat(07-02): registry-based get_provider(config: ProviderConfig) — D-06
|
||||
- [x] 209b156 — test(07-02): promote 6 Wave-2 xfail tests to passing — D-01/D-02/D-07/D-12/D-13
|
||||
Reference in New Issue
Block a user