From ed0d198afe3f9a3dfb0c9df306dbf92994a6272e Mon Sep 17 00:00:00 2001 From: Ed_ Date: Sun, 21 Jun 2026 15:35:41 -0400 Subject: [PATCH] feat(video_analysis): ocr_frames.py with TDD (4 tests, winsdk + tesseract backends) --- .../metadata.json | 198 ++++++ .../spec.md | 633 ++++++++++++++++++ .../state.toml | 129 ++++ scripts/video_analysis/ocr_frames.py | 110 +++ tests/test_video_analysis_ocr_frames.py | 42 ++ 5 files changed, 1112 insertions(+) create mode 100644 conductor/tracks/any_type_componentization_20260621/metadata.json create mode 100644 conductor/tracks/any_type_componentization_20260621/spec.md create mode 100644 conductor/tracks/any_type_componentization_20260621/state.toml create mode 100644 scripts/video_analysis/ocr_frames.py create mode 100644 tests/test_video_analysis_ocr_frames.py diff --git a/conductor/tracks/any_type_componentization_20260621/metadata.json b/conductor/tracks/any_type_componentization_20260621/metadata.json new file mode 100644 index 00000000..0cdb0541 --- /dev/null +++ b/conductor/tracks/any_type_componentization_20260621/metadata.json @@ -0,0 +1,198 @@ +{ + "track_id": "any_type_componentization_20260621", + "name": "Any-Type Componentization (Promote dict[str, Any] to dataclass(frozen=True))", + "initialized": "2026-06-21", + "owner": "tier2-tech-lead", + "priority": "medium", + "status": "active", + "type": "refactor + ai-readability + type-safety", + "scope": { + "new_files": [ + "src/mcp_tool_specs.py", + "src/openai_schemas.py", + "src/provider_state.py", + "scripts/audit_dataclass_coverage.py", + "scripts/audit_dataclass_coverage.baseline.json", + "tests/test_audit_dataclass_coverage.py", + "tests/test_mcp_tool_specs.py", + "tests/test_openai_schemas.py", + "tests/test_provider_state.py", + "docs/type_registry/src_mcp_tool_specs.md", + "docs/type_registry/src_openai_schemas.md", + "docs/type_registry/src_provider_state.md", + "docs/reports/TRACK_COMPLETION_any_type_componentization_20260621.md" + ], + "modified_files": [ + "src/type_aliases.py", + "src/mcp_client.py", + "src/openai_compatible.py", + "src/ai_client.py", + "src/log_registry.py", + "src/session_logger.py", + "src/log_pruner.py", + "src/gui_2.py", + "src/api_hooks.py", + "src/api_hook_client.py", + "conductor/code_styleguides/type_aliases.md", + "docs/type_registry/src_ai_client.md", + "docs/type_registry/src_openai_compatible.md", + "docs/type_registry/src_mcp_client.md", + "docs/type_registry/src_api_hooks.md", + "docs/type_registry/src_log_registry.md" + ], + "deleted_files": [] + }, + "blocked_by": [ + "data_structure_strengthening_20260606" + ], + "blocks": [ + "any_type_componentization_phase2_2026MMDD", + "openai_tools_dataclass_bridge_2026MMDD" + ], + "estimated_phases": 7, + "spec": "spec.md", + "plan": "plan.md (to be authored by writing-plans skill after spec approval)", + "priority_order": "A (5 fat-struct conversions + audit gate) > B (JsonValue + styleguide §12) > C (registry updates) > D (cross-phase coupling follow-up)", + "input_artifact": { + "report": "docs/reports/ANY_TYPE_AUDIT_20260621.md", + "date": "2026-06-21", + "findings_total": 300, + "candidates_identified": 5, + "candidates_sites": 89 + }, + "reference_pattern": { + "file": "src/vendor_capabilities.py", + "lines": "64-76", + "template": "@dataclass(frozen=True) + module-level _REGISTRY dict + factory function" + }, + "candidates": { + "p1_mcp_tool_specs": { + "file": "src/mcp_client.py", + "current": "MCP_TOOL_SPECS: list[dict[str, Any]] (45 tools)", + "target_module": "src/mcp_tool_specs.py (new)", + "sites": 8, + "value": "HIGH" + }, + "p1_openai_schemas": { + "file": "src/openai_compatible.py", + "current": "NormalizedResponse + OpenAICompatibleRequest with list[dict[str, Any]] fields", + "target_module": "src/openai_schemas.py (new)", + "sites": 17, + "value": "HIGH" + }, + "p2_provider_state": { + "file": "src/ai_client.py", + "current": "7× __history + 7× __history_lock module globals", + "target_module": "src/provider_state.py (new)", + "sites": 41, + "value": "HIGH" + }, + "p2_log_registry_session": { + "file": "src/log_registry.py", + "current": "self.data: dict[str, dict[str, Any]]", + "target_module": "src/log_registry.py (inline)", + "sites": 7, + "value": "MEDIUM" + }, + "p3_api_hooks_websocket": { + "file": "src/api_hooks.py", + "current": "def broadcast(channel, payload: dict[str, Any]) + _serialize_for_api", + "target_module": "src/api_hooks.py (inline)", + "sites": 16, + "value": "LOW" + } + }, + "audit_ci_gate": { + "script": "scripts/audit_dataclass_coverage.py", + "modes": { + "default": "informational (exit 0)", + "--json": "machine-readable report", + "--strict": "CI gate (exit 1 if current > baseline)", + "--baseline": "path to baseline file (default: scripts/audit_dataclass_coverage.baseline.json)" + }, + "baseline_after_track": "211 (300 Any sites - 89 promoted = 211 remaining)" + }, + "phases": { + "phase_0": { + "name": "Shared scaffolding", + "scope": "JsonValue TypeAlias + dataclass-coverage audit + styleguide §12", + "estimated_commits": 3, + "files": ["src/type_aliases.py", "scripts/audit_dataclass_coverage.py", "conductor/code_styleguides/type_aliases.md"] + }, + "phase_1": { + "name": "mcp_tool_specs (P1)", + "scope": "src/mcp_tool_specs.py new; src/mcp_client.py refactor 8 sites", + "estimated_commits": 10, + "files": ["src/mcp_tool_specs.py", "src/mcp_client.py", "src/ai_client.py"] + }, + "phase_2": { + "name": "openai_schemas (P1)", + "scope": "src/openai_schemas.py new; 17 sites in src/openai_compatible.py + src/ai_client.py", + "estimated_commits": 10, + "files": ["src/openai_schemas.py", "src/openai_compatible.py", "src/ai_client.py"] + }, + "phase_3": { + "name": "provider_state (P2)", + "scope": "src/provider_state.py new; 41 sites in src/ai_client.py", + "estimated_commits": 15, + "files": ["src/provider_state.py", "src/ai_client.py"] + }, + "phase_4": { + "name": "log_registry Session (P2)", + "scope": "7 sites in src/log_registry.py + 3 consumer files", + "estimated_commits": 5, + "files": ["src/log_registry.py", "src/session_logger.py", "src/log_pruner.py", "src/gui_2.py"] + }, + "phase_5": { + "name": "api_hooks WebSocketMessage (P3)", + "scope": "16 sites in src/api_hooks.py", + "estimated_commits": 5, + "files": ["src/api_hooks.py"] + }, + "phase_6": { + "name": "Verify + archive", + "scope": "Full audit + 11-tier regression + docs + archive move", + "estimated_commits": 2, + "files": ["docs/reports/TRACK_COMPLETION_*", "conductor/tracks.md"] + } + }, + "total_estimated_commits": 50, + "ai_performance_analysis": { + "win": "Closed-shape types vs open dicts. The AI now sees `.tool_calls[0].function.name` (field access; type-checked) instead of `tool_calls[0]['function']['name']` (3 nested dict-key lookups; untyped). Static analysis can verify field existence.", + "cost": "Migration overhead (~50 commits). New dataclass vocabulary for the AI to learn (similar to the 10 TypeAliases from data_structure_strengthening). Cross-phase coupling deferred (Phase 2's tools field stays as list[dict[str, Any]] for now).", + "caveat": "Frozen dataclasses are slightly slower to construct than dict literals (~microseconds). For hot paths (per-provider history append), this is negligible. The JSON wire format (`JsonValue`) is type-level only; runtime serialization is unchanged.", + "honest_assessment": "Net win. The 5 candidates are the highest-value fat-struct sites identified by the audit. Promoting them to frozen dataclasses + registries adds type safety, IDE autocomplete, and dispatch verification. The remaining 211 Any sites are intentional flexibility (Patterns 3/4/5) and stay as Any." + }, + "architectural_invariant": "Frozen dataclasses are the canonical pattern for closed-shape data in this codebase. TypeAlias remains the canonical pattern for open-shape data. The decision tree lives in conductor/code_styleguides/type_aliases.md §12 (added in Phase 0).", + "threading_constraint": "Phase 3 (provider_state) consolidates 7 locks into a single _PROVIDER_HISTORIES dict. Each ProviderHistory instance owns its own lock (via default_factory=threading.Lock). The lock semantics are unchanged from the current per-provider locks.", + "verification_criteria": [ + "src/mcp_tool_specs.py exists with ToolParameter + ToolSpec + registry", + "src/openai_schemas.py exists with ToolCall + ChatMessage + UsageStats", + "src/provider_state.py exists with ProviderHistory + _PROVIDER_HISTORIES dict", + "src/log_registry.py has Session + SessionMetadata dataclasses", + "src/api_hooks.py has WebSocketMessage + JsonValue TypeAlias usage", + "src/type_aliases.py extended with JsonPrimitive + JsonValue", + "scripts/audit_dataclass_coverage.py exists with --strict mode", + "scripts/audit_dataclass_coverage.baseline.json committed", + "conductor/code_styleguides/type_aliases.md has §12 When to Promote section", + "6 new test files exist with 48+ tests (Phase 0 audit: 6, Phase 1: 8, Phase 2: 10, Phase 3: 10, Phase 4: 8, Phase 5: 6)", + "All existing tests pass (no regressions in 11-tier batched run)", + "audit_weak_types.py --strict exits 0", + "audit_dataclass_coverage.py --strict exits 0", + "generate_type_registry.py --check exits 0 (5 new .md files appear)", + "docs/reports/TRACK_COMPLETION_any_type_componentization_20260621.md written", + "Track archived; conductor/tracks.md updated" + ], + "sequencing_note": "Per user direction 2026-06-21: this track is NOT blocked by code_path_audit_20260607. The two tracks are orthogonal (semantic clarity vs runtime cost). Both can run in parallel.", + "links": { + "input_report": "docs/reports/ANY_TYPE_AUDIT_20260621.md", + "parent_track": "conductor/tracks/data_structure_strengthening_20260606/", + "reference_pattern": "src/vendor_capabilities.py", + "audit_template": "scripts/audit_weak_types.py", + "type_alias_module": "src/type_aliases.py", + "code_styleguide": "conductor/code_styleguides/type_aliases.md", + "error_handling_styleguide": "conductor/code_styleguides/error_handling.md", + "testing_guide": "docs/guide_testing.md", + "parallel_track": "conductor/tracks/code_path_audit_20260607/" + } +} \ No newline at end of file diff --git a/conductor/tracks/any_type_componentization_20260621/spec.md b/conductor/tracks/any_type_componentization_20260621/spec.md new file mode 100644 index 00000000..562aa91a --- /dev/null +++ b/conductor/tracks/any_type_componentization_20260621/spec.md @@ -0,0 +1,633 @@ +# Track: Any-Type Componentization (Promote `dict[str, Any]` to `dataclass(frozen=True)`) + +**Status:** Active (spec approved 2026-06-21) +**Initialized:** 2026-06-21 +**Owner:** Tier 2 Tech Lead +**Priority:** Medium (developer + AI-readability; not a regression blocker) + +--- + +## 1. Overview + +The `data_structure_strengthening_20260606` track established the `TypeAlias` convention: 10 aliases + 1 `NamedTuple` in `src/type_aliases.py`, replacing 416 of 528 weak-type sites (79% reduction) across 6 high-traffic files. The aliases are **renames** — they point to the same underlying `dict[str, Any]` / `list[dict[str, Any]]` shapes. The alias names document intent; they do not add type safety. + +A follow-on audit (`docs/reports/ANY_TYPE_AUDIT_20260621.md`, committed 2026-06-21) identified **5 fat-struct candidates** that warrant promotion to `dataclass(frozen=True)` definitions, following the `src/vendor_capabilities.py` pattern (`frozen=True` dataclass + module-level registry + factory function). This track is the implementation of the audit's recommendations. + +**The 5 candidates (89 of the 300 `Any` usages, ~30%):** + +| Rank | Target | Sites | Value | +|---|---|---:|---| +| P1 | `src/mcp_client.py: MCP_TOOL_SPECS` (45 tools) | 8 | HIGH — 180 implicit fields become explicit | +| P1 | `src/openai_compatible.py: NormalizedResponse + OpenAICompatibleRequest` | 17 | HIGH — well-documented OpenAI schema | +| P2 | `src/ai_client.py: 7× ProviderHistory + 7 locks` | 41 | HIGH — 14 module globals → 1 dict | +| P2 | `src/log_registry.py: Session metadata` | 7 | MEDIUM — 2 levels of structural anonymity | +| P3 | `src/api_hooks.py: WebSocketMessage + JsonValue` | 16 | LOW — generic serialization | + +**The audit's 5-pattern taxonomy (`ANY_TYPE_AUDIT_20260621.md` §2.2):** only Pattern 1 (JSON-shaped payloads) and Pattern 2 (per-provider message lists) are componentization candidates. Patterns 3 (SDK holders), 4 (`__getattr__`), 5 (generic serialization) stay as `Any` — see §10. + +**Scope is deliberately bounded.** The track promotes the 5 fat-struct candidates to `dataclass(frozen=True)`. It does NOT migrate all 300 `Any` usages; it does NOT convert `TypeAlias` definitions to `TypedDict`; it does NOT introduce Pydantic. The audit's recommended boundary is honored. + +**Sequencing (revised 2026-06-21 per user direction).** The audit's §5.2 originally proposed gating this track behind `code_path_audit_20260607`. **This gate is removed.** The two tracks are orthogonal: +- `code_path_audit` measures RUNTIME cost per call (CPU/memory) +- `any_type_componentization` measures SEMANTIC clarity (AI-readability) + +Neither depends on the other. The code_path_audit's report can retroactively flag which any-type candidates it found in hot paths as a side benefit. Both tracks can run in parallel. + +## 2. Goals (Priority Order) + +| Priority | Goal | Rationale | +|---|---|---| +| **A (primary)** | Convert the 5 fat-struct candidates (89 sites) to `dataclass(frozen=True)` definitions following `src/vendor_capabilities.py` template | The audit identified these as the high-value subset; aliases alone don't add type safety | +| **A (primary)** | New `scripts/audit_dataclass_coverage.py` with `--strict` mode | The CI gate that prevents regression of dataclass promotion work | +| **B (architectural)** | New `JsonValue` recursive `TypeAlias` (in `src/type_aliases.py`) for the JSON wire format | Phase 5 (api_hooks) needs it; reusable for future JSON-boundary tracks | +| **B (architectural)** | New styleguide §12 "When to Promote `TypeAlias` to `dataclass`" section | Captures the rule that future contributors can apply without re-deriving | +| **C (documentation)** | Update `docs/type_registry/` registry entries for the 3 new modules + modified files | The type-registry generator picks them up automatically; `--check` mode validates | +| **D (forward-looking)** | Note the cross-phase coupling opportunity (Phase 2's `OpenAICompatibleRequest.tools` could consume Phase 1's `ToolSpec`) as a follow-up track — NOT in this track | Cross-phase coupling is a future concern; this track ships each phase independently | + +### 2.1 Non-Goals (this track) + +- **NOT** converting all 300 `Any` usages. Only the 5 fat-struct candidates. +- **NOT** converting SDK client holders (Pattern 3). They stay as `Any` — heterogeneous SDK types. +- **NOT** changing the `__getattr__` dynamic-dispatch pattern (Pattern 4). It stays as `Any` — intentional. +- **NOT** typing the generic serialization functions (Pattern 5). They stay as `Any` — input-driven. +- **NOT** converting `dict[str, Any]` to `TypedDict` (per `data_structure_strengthening_20260606` §10, deferred to a separate decision). +- **NOT** introducing Pydantic (would be a much larger architectural decision). +- **NOT** changing function signatures at the runtime level (dataclasses are serialization-compatible via `from_dict()`/`to_dict()` helpers). +- **NOT** waiting for `code_path_audit_20260607` (per the §1 sequencing revision). + +## 3. Architecture + +### 3.1 The Reference Pattern: `src/vendor_capabilities.py` + +`src/vendor_capabilities.py` is the **canonical "module-level abstraction layer"** (76 lines): + +```python +@dataclass(frozen=True) +class VendorCapabilities: + vendor: str + model: str + vision: bool = False + tool_calling: bool = True + caching: bool = False + # ... 22 named fields total + +_REGISTRY: dict[tuple[str, str], VendorCapabilities] = {} + +def register(cap: VendorCapabilities) -> None: ... +def get_capabilities(vendor: str, model: str) -> VendorCapabilities: ... +``` + +**Properties that make this pattern successful:** + +| Property | Why it matters | +|---|---| +| `frozen=True` | Immutable; thread-safe; no accidental mutation | +| Named fields | Every field is addressable by name (no `dict['vision']` lookups) | +| Module-level registry | O(1) lookup; no instantiation overhead | +| Wildcard `*` model | Fallback for unregistered models | +| Flat (no nesting) | Single cache-line access for most queries | +| Registration pattern | Extensible without modifying existing code | + +All 5 fat-struct candidates follow this template. + +### 3.2 The Conversion API: `from_dict` / `to_dict` + +For each new dataclass, the convention is: + +```python +@classmethod +def from_dict(cls, data: Metadata) -> Result[Self, ErrorInfo]: + """Parse a dict into the dataclass. Returns Result for graceful failure.""" + +def to_dict(self) -> Metadata: + """Serialize the dataclass back to a dict (for logging, JSON wire).""" +``` + +The `Result[Self, ErrorInfo]` return type follows the data-oriented convention from `data_oriented_error_handling_20260606` (see `conductor/code_styleguides/error_handling.md`). Conversion failures (missing required field, type mismatch, malformed JSON) return `ErrorInfo` instead of raising. + +### 3.3 The `JsonValue` Recursive Type + +Phase 5 (`api_hooks.py`) needs a type for arbitrary JSON-shaped data. Python 3.12+ has `type` statement; earlier versions need a `TypeAlias`: + +```python +# src/type_aliases.py (extension) +JsonPrimitive: TypeAlias = str | int | float | bool | None +JsonValue: TypeAlias = JsonPrimitive | list["JsonValue"] | dict[str, "JsonValue"] +``` + +This makes `_serialize_for_api(obj: Any) -> JsonValue` and `broadcast(message: WebSocketMessage)` (with `payload: JsonValue`) explicit. + +### 3.4 Module Layout + +``` +src/ + type_aliases.py # MODIFIED: add JsonPrimitive + JsonValue TypeAliases + vendor_capabilities.py # UNCHANGED: the reference pattern (no edits) + mcp_tool_specs.py # NEW: ToolParameter + ToolSpec dataclasses + registry + openai_schemas.py # NEW: ToolCall + ToolCallFunction + ChatMessage + UsageStats + provider_state.py # NEW: ProviderHistory dataclass + _PROVIDER_HISTORIES dict + mcp_client.py # MODIFIED: MCP_TOOL_SPECS -> list[ToolSpec]; update dispatch + openai_compatible.py # MODIFIED: NormalizedResponse + OpenAICompatibleRequest use ChatMessage/UsageStats/ToolSpec + ai_client.py # MODIFIED: replace 14 globals with _PROVIDER_HISTORIES dict; update _send_grok/_send_minimax/_send_llama + log_registry.py # MODIFIED: add Session + SessionMetadata dataclasses + session_logger.py # MODIFIED: use Session dataclass + log_pruner.py # MODIFIED: use Session dataclass + gui_2.py # MODIFIED: Log Management panel uses Session + api_hooks.py # MODIFIED: add WebSocketMessage dataclass; _serialize_for_api -> JsonValue + +scripts/ + audit_dataclass_coverage.py # NEW: counts anonymous dict[str, Any] per module; --strict mode + audit_dataclass_coverage.baseline.json # NEW: baseline count post-track + audit_weak_types.py # UNCHANGED (still gates the alias convention) + generate_type_registry.py # UNCHANGED (registry generator; auto-includes new modules) + +conductor/ + code_styleguides/ + type_aliases.md # MODIFIED: add §12 "When to Promote TypeAlias to dataclass" + +tests/ + test_mcp_tool_specs.py # NEW + test_openai_schemas.py # NEW + test_provider_state.py # NEW + test_log_registry_dataclasses.py # NEW (or extend existing) + test_api_hooks_dataclasses.py # NEW (or extend existing) + test_audit_dataclass_coverage.py # NEW + (existing test files): # MODIFIED: update call sites; existing tests should pass unchanged + +docs/ + type_registry/ # AUTO-GENERATED: new modules appear automatically + mcp_tool_specs.md # NEW (generated) + openai_schemas.md # NEW (generated) + provider_state.md # NEW (generated) + api_hooks.md # NEW (generated; replaces existing 16-Any-flavored entry) + log_registry.md # NEW (generated) + src_ai_client.md # MODIFIED (generated; ProviderHistory changes shape) + src_openai_compatible.md # MODIFIED (generated; NormalizedResponse changes shape) + src_mcp_client.md # MODIFIED (generated; MCP_TOOL_SPECS changes shape) + +docs/reports/ + TRACK_COMPLETION_any_type_componentization_20260621.md # NEW (end-of-track) +``` + +### 3.5 Coexistence with the Type-Alias Convention + +The new dataclasses **complement** the `TypeAlias` convention (not replace it): + +- **`TypeAlias`** = rename a shape that's still a dict at runtime (cheap; 0 structural cost) +- **`dataclass(frozen=True)`** = give the shape fields + methods + invariants (expensive; changes runtime type) + +The decision tree (now in styleguide §12): + +``` +Is the shape open-ended (extra keys allowed, no invariants)? ──► TypeAlias (Metadata) +Is the shape a closed set of named fields with specific types? ──► dataclass(frozen=True) +Is the shape a JSON wire format (recursive)? ──► JsonValue (TypeAlias) +``` + +The 5 fat-struct candidates are closed sets of named fields. The 112 remaining `dict[str, Any]` sites in the audit's 27 lower-impact files are mostly open-ended (provider payloads, config dicts) and stay as `TypeAlias` (or even raw `dict[str, Any]`) until a future track identifies them as closed-shape candidates. + +## 4. Per-Phase Plan + +### Phase 0: Shared scaffolding (1 task; ~3 commits) + +- **WHERE:** `src/type_aliases.py`, `scripts/audit_dataclass_coverage.py`, `conductor/code_styleguides/type_aliases.md` +- **WHAT:** Add `JsonPrimitive` + `JsonValue` TypeAliases; new audit script that counts anonymous `dict[str, Any]` per module with `--strict` mode (CI gate); styleguide §12 +- **HOW:** Use the existing `audit_weak_types.py` script as the template for the new audit; follow `audit_weak_types.py:130-160` for the `--strict` mode pattern +- **SAFETY:** No behavior change; type aliases + new audit script are additive +- **TESTS:** `tests/test_audit_dataclass_coverage.py` (6+ tests; mirror `tests/test_audit_weak_types.py`) +- **VERIFICATION:** `uv run python scripts/audit_dataclass_coverage.py --strict` exits 0 (baseline == current) +- **COMMIT:** `feat(scaffold): JsonValue TypeAlias + dataclass-coverage audit + styleguide §12` + +### Phase 1: `src/mcp_tool_specs.py` (P1, 8 sites) + +**Current state** (`src/mcp_client.py:1944-2747`): +```python +MCP_TOOL_SPECS: list[dict[str, Any]] = [ + { "name": "py_remove_def", "description": "...", "parameters": {...} }, + # ... 44 more dicts of identical shape +] +TOOL_NAMES: set[str] = {t['name'] for t in MCP_TOOL_SPECS} # line 2747 +``` + +**Refactor target:** +```python +# src/mcp_tool_specs.py (NEW; ~120 lines) +@dataclass(frozen=True) +class ToolParameter: + name: str + type: str # "string" | "integer" | "boolean" | "object" | "array" + description: str + required: bool = False + enum: Optional[list[str]] = None + +@dataclass(frozen=True) +class ToolSpec: + name: str + description: str + parameters: tuple[ToolParameter, ...] + category: str = "file" + +_REGISTRY: dict[str, ToolSpec] = {} + +def register(spec: ToolSpec) -> None: ... +def get_tool_spec(name: str) -> ToolSpec: ... +def get_tool_schemas() -> list[ToolSpec]: ... +def tool_names() -> set[str]: ... +``` + +**Call sites to update:** +- `src/mcp_client.py:1944` `native_names = {t['name'] for t in MCP_TOOL_SPECS}` → `mcp_tool_specs.tool_names()` +- `src/mcp_client.py:1958` `res = list(MCP_TOOL_SPECS)` → `res = mcp_tool_specs.get_tool_schemas()` +- `src/mcp_client.py:1972` `MCP_TOOL_SPECS: list[dict[str, Any]] = [...]` → moved to `mcp_tool_specs.py:_REGISTRY` +- `src/mcp_client.py:2747` `TOOL_NAMES: set[str] = {t['name'] for t in MCP_TOOL_SPECS}` → `mcp_tool_specs.tool_names()` +- `src/ai_client.py:560,582,1012` `mcp_client.TOOL_NAMES` → `mcp_tool_specs.tool_names()` (3 sites) +- `src/app_controller.py:2103,2962,3263` `models.AGENT_TOOL_NAMES` (cross-check; not directly `TOOL_NAMES`) + +**Compatibility shim:** keep `mcp_client.MCP_TOOL_SPECS` and `mcp_client.TOOL_NAMES` as thin re-exports for the duration of this phase, then remove in a follow-up commit if no external test breaks. Alternative: deprecate immediately and fix the 3 callers. + +**Tests:** `tests/test_mcp_tool_specs.py` (8+ tests) +- Verify all 45 tools are registered +- Verify `get_tool_spec("py_remove_def")` returns correct spec +- Verify `tool_names()` matches expected set +- Verify `from_dict()` returns `Result` for valid + invalid inputs +- Verify `TOOL_NAMES` is a subset of `models.AGENT_TOOL_NAMES` (cross-module invariant) + +### Phase 2: `src/openai_schemas.py` (P1, 17 sites) + +**Current state** (`src/openai_compatible.py:10-30`): +```python +@dataclass(frozen=True) +class NormalizedResponse: + text: str + tool_calls: list[dict[str, Any]] # FAT: JSON tool call shape + usage_input_tokens: int + usage_output_tokens: int + usage_cache_read_tokens: int + usage_cache_creation_tokens: int + raw_response: Any # FAT: SDK-specific response (Pattern 3, stay) + +@dataclass +class OpenAICompatibleRequest: + messages: list[dict[str, Any]] # FAT: message shape + model: str + ... + tools: Optional[list[dict[str, Any]]] = None # FAT: tool schema (cross-phase: Phase 1) + extra_body: Optional[dict[str, Any]] = None # FAT: arbitrary params +``` + +**Refactor target:** +```python +# src/openai_schemas.py (NEW; ~150 lines) +@dataclass(frozen=True) +class ToolCall: + id: str + type: str = "function" + function: "ToolCallFunction" + +@dataclass(frozen=True) +class ToolCallFunction: + name: str + arguments: str # JSON string + +@dataclass(frozen=True) +class ChatMessage: + role: str # "system" | "user" | "assistant" | "tool" + content: str + tool_calls: Optional[tuple[ToolCall, ...]] = None + tool_call_id: Optional[str] = None + name: Optional[str] = None + +@dataclass(frozen=True) +class UsageStats: + input_tokens: int + output_tokens: int + cache_read_tokens: int = 0 + cache_creation_tokens: int = 0 + +# NormalizedResponse becomes: +@dataclass(frozen=True) +class NormalizedResponse: + text: str + tool_calls: tuple[ToolCall, ...] + usage: UsageStats # was 4 separate fields + raw_response: Any # Unavoidable: SDK-specific + +# OpenAICompatibleRequest becomes: +@dataclass +class OpenAICompatibleRequest: + messages: list[ChatMessage] + model: str + temperature: float = 0.0 + top_p: float = 1.0 + max_tokens: int = 8192 + tools: Optional[list[dict[str, Any]]] = None # Cross-phase: Phase 1's ToolSpec (deferred) + tool_choice: str = "auto" + stream: bool = False + stream_callback: Optional[Callable[[str], None]] = None + extra_body: Optional[dict[str, Any]] = None +``` + +**Cross-phase coupling (deferred):** `OpenAICompatibleRequest.tools: Optional[list[ToolSpec]]` would reuse Phase 1's `ToolSpec`. This is a follow-up track concern; Phase 2 ships with `list[dict[str, Any]]` for that field with a `# TODO(future-track): migrate to list[ToolSpec]` note. + +**Call sites to update:** +- `src/openai_compatible.py` itself (~5 internal functions consuming `NormalizedResponse`) +- `src/ai_client.py` `_send_grok()`, `_send_minimax()`, `_send_llama()` (~3 functions; they construct `NormalizedResponse` and `OpenAICompatibleRequest`) +- `src/api_hook_client.py` (the API hook payloads may serialize these; cross-check) + +**Tests:** `tests/test_openai_schemas.py` (10+ tests) +- Verify `ChatMessage.from_dict()` round-trip for all 4 roles +- Verify `UsageStats` field access +- Verify `ToolCall.function.arguments` JSON parsing +- Verify `Result[Self, ErrorInfo]` error cases (missing required field, malformed JSON) +- Verify `NormalizedResponse.raw_response` is still `Any` (Pattern 3) + +### Phase 3: `src/provider_state.py` (P2, 41 sites) + +**Current state** (`src/ai_client.py:111-133`): +```python +_anthropic_history: list[Metadata] = [] +_anthropic_history_lock: threading.Lock = threading.Lock() +_deepseek_history: list[Metadata] = [] +_deepseek_history_lock: threading.Lock = threading.Lock() +# ... 7 providers × 2 vars = 14 module globals +``` + +Plus the SDK client holders (Pattern 3, stay): +```python +_gemini_chat: Any = None +_deepseek_client: Any = None +# ... 7 SDK clients stay as-is +``` + +**Refactor target:** +```python +# src/provider_state.py (NEW; ~80 lines) +@dataclass +class ProviderHistory: + messages: list[Metadata] = field(default_factory=list) + lock: threading.Lock = field(default_factory=threading.Lock) + + def append(self, message: Metadata) -> None: ... + def get_all(self) -> list[Metadata]: ... + def replace_all(self, messages: list[Metadata]) -> None: ... + def clear(self) -> None: ... + +_PROVIDER_HISTORIES: dict[str, ProviderHistory] = { + "anthropic": ProviderHistory(), + "deepseek": ProviderHistory(), + "minimax": ProviderHistory(), + "qwen": ProviderHistory(), + "grok": ProviderHistory(), + "llama": ProviderHistory(), +} + +def get_history(provider: str) -> ProviderHistory: + return _PROVIDER_HISTORIES[provider] +``` + +**Call sites to update** (`src/ai_client.py`): +- Lines 463-466: `global _anthropic_history` declarations (4 declarations across `cleanup()` and similar) → removed +- Lines 483-499: 7 `with __history_lock:` blocks in `cleanup()` → `get_history("").clear()` +- Lines 1447, 1457-1460, 1469, 1471, 1475, 1489, 1503, 1506, 1582: ~20 `_anthropic_history` references → `get_history("anthropic").messages` and `.append()` +- Lines 2201-2202, 2221-2222, 2353, 2360, 2418-2420: ~10 `_deepseek_history` references → `get_history("deepseek")` +- Lines 2575-2588, 2605: ~10 `_grok_history` references → `get_history("grok")` +- Lines 2659-2685: ~10 `_minimax_history` references → `get_history("minimax")` +- Lines 2812-2823: ~8 `_qwen_history` references → `get_history("qwen")` +- Lines 2901-2925: ~8 `_llama_history` references → `get_history("llama")` +- The `_repair__history()` and `_trim__history()` helpers (lines 1353, 1381, 2138, 2462, 2482) take `history: list[Metadata]` parameters — they stay as-is; call sites pass `get_history("").messages` + +**Tests:** `tests/test_provider_state.py` (10+ tests) +- Verify `ProviderHistory.append()` is thread-safe (lock semantics) +- Verify `ProviderHistory.clear()` resets the list atomically +- Verify `get_history("anthropic")` returns the same instance across calls (singleton) +- Verify `replace_all()` swaps the list under lock +- Verify `cleanup()` clears all 6 histories +- Verify SDK client holders (`_gemini_chat`, etc.) are NOT touched (Pattern 3 preserved) + +**Risk:** This phase has the largest ripple. The 41 sites include 14 module globals (renames are mechanical) + ~27 call-site updates. The audit may undercount if helper functions in `ai_client.py` reference these globals beyond the listed lines. **Mitigation:** Phase 3 has its own audit baseline snapshot before starting; any new finds get added to the phase's task list. + +### Phase 4: `src/log_registry.py: Session` (P2, 7 sites) + +**Current state** (`src/log_registry.py:58`): +```python +self.data: dict[str, dict[str, Any]] = {} # session_id -> session content +``` + +The outer key is `session_id: str`. The inner dict has implicit fields: `path`, `start_time`, `whitelisted`, `metadata`. + +**Refactor target** (inline in `src/log_registry.py`): +```python +@dataclass(frozen=True) +class SessionMetadata: + message_count: int = 0 + errors: int = 0 + size_kb: int = 0 + whitelisted: bool = False + reason: str = '' + timestamp: Optional[str] = None + +@dataclass(frozen=True) +class Session: + session_id: str + path: str + start_time: str # ISO format + whitelisted: bool = False + metadata: Optional[SessionMetadata] = None + +@dataclass +class LogRegistry: + registry_path: str + data: dict[str, Session] = field(default_factory=dict) # typed! +``` + +**Call sites to update:** +- `src/log_registry.py` `get_old_non_whitelisted_sessions()` and 6 other internal methods +- `src/session_logger.py` `open_session()`, `close_session()` +- `src/log_pruner.py` `prune_old_logs()` +- `src/gui_2.py` Log Management panel (find via `grep "log_registry"` or "session_log") + +**Tests:** `tests/test_log_registry_dataclasses.py` (or extend existing) +- Verify `Session.from_dict()` round-trip +- Verify `Session.metadata` is `Optional[SessionMetadata]` +- Verify `LogRegistry.data: dict[str, Session]` (no longer `dict[str, dict[str, Any]]`) +- Verify `prune_old_logs()` works on the new schema + +### Phase 5: `src/api_hooks.py: WebSocketMessage + JsonValue` (P3, 16 sites) + +**Current state** (`src/api_hooks.py:48-145`): +```python +def _get_app_attr(app: Any, name: str, default: Any = None) -> Any: ... +def _set_app_attr(app: Any, name: str, value: Any) -> None: ... +def _serialize_for_api(obj: Any) -> Any: ... +def broadcast(self, channel: str, payload: dict[str, Any]) -> None: ... +``` + +The `_get_app_attr` / `_set_app_attr` are Pattern 4 (stay as `Any`). +The `_serialize_for_api` and `broadcast` are the JSON wire format. + +**Refactor target** (inline in `src/api_hooks.py`): +```python +from src.type_aliases import JsonValue + +@dataclass(frozen=True) +class WebSocketMessage: + channel: str + payload: JsonValue + +def _serialize_for_api(obj: Any) -> JsonValue: ... + +def broadcast(self, message: WebSocketMessage) -> None: ... +``` + +**Call sites to update:** `broadcast()` callers (~5-10 sites across `src/app_controller.py`, `src/gui_2.py`) + +**Tests:** extend `tests/test_api_hooks.py` +- Verify `WebSocketMessage` is `frozen=True` (cannot mutate) +- Verify `JsonValue` round-trip via `_serialize_for_api` +- Verify `_get_app_attr` / `_set_app_attr` signatures are unchanged (Pattern 4 preserved) + +### Phase 6: Verification + docs + archive + +- Run full audit: `audit_weak_types.py --strict` exits 0; `audit_dataclass_coverage.py --strict` exits 0 +- Run full regression suite: 11-tier batched (per `test_sandbox_hardening_20260619` convention) +- Regenerate `docs/type_registry/` via `scripts/generate_type_registry.py` +- Verify `--check` mode passes +- Write end-of-track report at `docs/reports/TRACK_COMPLETION_any_type_componentization_20260621.md` +- Move `conductor/tracks/any_type_componentization_20260621/` → `conductor/tracks/archive/` +- Update `conductor/tracks.md` + +## 5. The Audit Script as a Permanent CI Gate + +The new `scripts/audit_dataclass_coverage.py` mirrors `audit_weak_types.py`'s design: + +**Modes:** +- Default: informational (exits 0; prints report) +- `--json`: machine-readable +- `--strict`: CI gate (exits 1 if current anonymous `dict[str, Any]` count > baseline) +- `--baseline`: path to baseline file (default: `scripts/audit_dataclass_coverage.baseline.json`) + +**What it counts:** sites where the structural anonymity persists (the 89 this track targets). Aliases that point to `dict[str, Any]` (e.g., `Metadata`, `CommsLogEntry`) are NOT counted; the audit counts actual `dict[str, Any]` / `list[dict[...]]` annotations and the remaining `Any` usages outside the 5 candidates. + +**Baseline:** committed at `scripts/audit_dataclass_coverage.baseline.json` post-Phase-6. Expected: 211 `Any` sites remain (300 - 89 = 211). The audit's 5-pattern taxonomy justifies the boundary. + +## 6. Configuration + +No new dependencies. No new environment variables. No new config files. + +The new dataclasses use stdlib `dataclasses.dataclass(frozen=True)` (Python 3.11+). + +## 7. Testing Strategy + +| Test File | Purpose | Coverage Target | +|---|---|---| +| `tests/test_audit_dataclass_coverage.py` | Verify the audit script's patterns + `--strict` mode + baseline | 90% | +| `tests/test_mcp_tool_specs.py` | Verify 45 tools registered + dispatch + cross-module invariants | 100% | +| `tests/test_openai_schemas.py` | Verify ChatMessage/UsageStats/ToolCall round-trips + Result[T] errors | 100% | +| `tests/test_provider_state.py` | Verify ProviderHistory thread safety + cleanup + singleton semantics | 100% | +| `tests/test_log_registry_dataclasses.py` | Verify Session dataclass + LogRegistry typed | 100% | +| `tests/test_api_hooks.py` (extended) | Verify WebSocketMessage + JsonValue round-trip | 100% | +| `tests/test_ai_client.py` (existing) | No regressions after 41-site Phase 3 refactor | 100% (regression) | +| `tests/test_mcp_client.py` (existing) | No regressions after Phase 1 dispatch refactor | 100% (regression) | +| `tests/test_openai_compatible.py` (existing) | No regressions after Phase 2 refactor | 100% (regression) | +| `tests/test_log_registry.py` (existing) | No regressions after Phase 4 | 100% (regression) | +| `tests/test_api_hooks.py` (existing) | No regressions after Phase 5 | 100% (regression) | + +**Mocking strategy:** Per the project's structural testing contract (`docs/guide_testing.md`), Tier 3 workers do NOT use `unittest.mock.patch` for core infrastructure. The new tests use the real dataclasses with synthetic `Metadata` inputs. + +**Audit baseline check:** Post-Phase-6, `audit_dataclass_coverage.py` should report ≤ baseline count. The dataclass-coverage baseline is expected to be 211 (300 `Any` minus the 89 candidates promoted in this track). + +## 8. Migration / Rollout + +| Phase | What | Risk | Commits | +|---|---|---|---| +| **0 — Scaffolding** | Add `JsonValue`, new audit, styleguide §12 | Low (additive only) | ~3 | +| **1 — `mcp_tool_specs`** | P1 (8 sites) | Medium (45 tools × ~4 params) | ~10 | +| **2 — `openai_schemas`** | P1 (17 sites) | Medium (cross-module: ai_client consumers) | ~10 | +| **3 — `provider_state`** | P2 (41 sites) | **Medium-High** (14 globals + ~27 call sites) | ~15 | +| **4 — `log_registry` Session** | P2 (7 sites) | Low (self-contained file) | ~5 | +| **5 — `api_hooks` WebSocketMessage** | P3 (16 sites) | Low (Pattern 5 preserved) | ~5 | +| **6 — Verify + archive** | Audit + tests + docs | Low | ~2 | +| **Total** | | | **~50 atomic commits** | + +Each phase has its own checkpoint commit and git note (per `conductor/workflow.md` Task Workflow §9-10). + +## 9. Risks & Mitigations + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| Phase 3 (`provider_state`) has more call sites than the audit identified. | Medium | Medium | Snapshot an audit baseline before Phase 3; any new finds get added to the phase's task list. Worst case: Phase 3 grows to ~20 commits (still tractable). | +| Phase 1 (`mcp_tool_specs`) dispatch map (`_dispatch_table`) has dead-code that the typed refactor surfaces. | Medium | Low | The dataclass + registry pattern naturally surfaces dead code. Add a "dead code removal" task to Phase 1 if discovered. | +| The `JsonValue` recursive type fails to type-check in Python 3.11. | Low | Low | Use `TypeAlias` with forward-reference (`"JsonValue"`) in `list` and `dict`; tested in Phase 0. | +| A consumer of `mcp_client.TOOL_NAMES` lives outside `src/` (e.g., `tests/`, `conductor/`) and breaks. | Medium | Low | Compatibility shim (re-export) for 1 commit; remove in follow-up. | +| `frozen=True` dataclasses break code that mutates dict fields. | Medium | Medium | Audit each candidate for mutation patterns before phase; convert mutators to `replace()` (returns new instance) per `dataclasses.replace()`. | +| The new audit script's `--strict` mode is too strict (rejects valid uses). | Low | Medium | Set baseline conservatively (post-Phase-6 actual count); tighten only after 1 week of clean CI. | +| Cross-phase coupling (Phase 2's `tools: list[ToolSpec]`) creates merge conflict with Phase 1. | Low | Low | Explicitly deferred; Phase 2 ships with `list[dict[str, Any]]` + TODO comment. | +| The 5 candidates leave 211 `Any` sites untouched; users expect more. | Low | Low | Document in §10 explicitly; the audit's 5-pattern taxonomy justifies the boundary. | + +## 10. Out of Scope (Explicit) + +- **The remaining 211 `Any` usages** (300 - 89 = 211). The audit's 5-pattern taxonomy identifies these as Patterns 3/4/5 (SDK holders, dynamic dispatch, generic serialization) — they stay as `Any` because they're intentionally flexible. A future track may identify additional fat-struct candidates; this track does not. +- **TypedDict migration** of any alias. Per `data_structure_strengthening_20260606` §10, deferred. +- **Pydantic models.** Not requested; would be a much larger architectural decision. +- **The `JsonValue` recursive type as a runtime validator** (e.g., `jsonschema` validation). The TypeAlias is a type hint, not a runtime guard. +- **Conversion of the `TypeAlias` definitions themselves to `dataclass` (e.g., making `Metadata: TypeAlias = dict[str, Any]` a `class Metadata(dict)`).** The aliases document intent; converting them is a separate decision. +- **Cross-phase coupling** between Phase 1 and Phase 2 (Phase 2's `OpenAICompatibleRequest.tools: list[ToolSpec]`). Deferred to a follow-up track. +- **Wait for `code_path_audit_20260607` to ship.** Per the §1 sequencing revision, the two tracks are orthogonal. +- **Modifying the audit scripts** (`audit_weak_types.py`, `audit_dataclass_coverage.py`) beyond the new `--strict` mode in Phase 0. Future extensions are separate tracks. + +## 11. Decisions Made During Spec Authoring + +The following design choices were resolved during spec drafting (formerly "Open Questions"): + +1. **`ToolSpec.parameters: tuple[ToolParameter, ...]` (RESOLVED)** — Tuple wins. Immutable matches `frozen=True` philosophy; serialization uses explicit `to_dict()` helper. `list[ToolParameter]` would force runtime conversion at every JSON boundary. +2. **`ProviderHistory.clear()` reuses the lock (RESOLVED)** — The lock protects the list, not the lock instance. `default_factory=threading.Lock` in the dataclass field ensures every `ProviderHistory` gets its own lock on construction; `clear()` does NOT reset the lock. +3. **`Session.metadata: Optional[SessionMetadata] = None` (RESOLVED)** — `Optional` with default None wins. Matches existing call patterns in `session_logger.py` where sessions may exist without metadata populated yet. +4. **`JsonValue` lives in `src/type_aliases.py` (RESOLVED)** — Existing file is the canonical location for TypeAliases. New file would split the convention across 2 modules. +5. **No compatibility shim in Phase 1 (RESOLVED)** — Phase 1's 3 call sites in `ai_client.py` are updated immediately. The shim would add a commit of pure re-exports that gets removed in the next commit anyway. + +## 12. See Also + +### 12.1 Project References + +- `docs/reports/ANY_TYPE_AUDIT_20260621.md` — the audit that drove this track (the input artifact) +- `conductor/tracks/data_structure_strengthening_20260606/` — the parent track (the 10 TypeAliases + 1 NamedTuple; this track builds on it) +- `src/vendor_capabilities.py` — the reference pattern (`frozen=True` dataclass + module-level registry + factory) +- `src/type_aliases.py` — the TypeAlias module (extended in Phase 0 with `JsonValue`) +- `scripts/audit_weak_types.py` — the audit script template (`scripts/audit_dataclass_coverage.py` mirrors its design) +- `conductor/code_styleguides/type_aliases.md` — the canonical styleguide (Phase 0 adds §12) +- `conductor/code_styleguides/error_handling.md` — the `Result[T]` convention (used by `from_dict()`) +- `docs/guide_testing.md` — the test infrastructure (live_gui fixture, structural testing contract) +- `docs/reports/TRACK_COMPLETION_data_structure_strengthening_20260606.md` — the parent track's end-of-track report +- `conductor/tracks/code_path_audit_20260607/` — the parallel runtime-cost track (NOT a blocker) + +### 12.2 External References + +- **Python `dataclasses.dataclass(frozen=True)`** — the canonical pattern for immutable named records (PEP 681 for `dataclass_transform`; Python 3.11+ stdlib). +- **Mike Acton's data-oriented design** — the "data is the API" framing that motivates named fields over dict access. +- **Casey Muratori on module layer boundaries** — the convention that each module owns its data and exposes a clear interface. +- **Ryan Fleury's "errors are just cases"** — the `Result[T]` convention adopted by this track for `from_dict()` return types. + +### 12.3 Follow-up Track (planned; NOT in this track) + +- **`any_type_componentization_phase2_2026MMDD`** (placeholder): the 211 remaining `Any` sites not in the 5 candidates. Identified by the audit's Pattern 3/4/5 analysis; may yield additional fat-struct candidates as future tracks touch those code areas. +- **`openai_tools_dataclass_bridge_2026MMDD`** (placeholder): the cross-phase coupling opportunity (Phase 2's `OpenAICompatibleRequest.tools: list[ToolSpec]`). +- **`type_registry_ci_20260606`** (planned in `data_structure_strengthening_20260606` §12.1): wires `generate_type_registry.py --check` into CI. This track ships the new modules; the CI gate is a separate concern. + +## 13. Verification Criteria (Definition of Done) + +- [ ] `src/mcp_tool_specs.py` exists with `ToolParameter` + `ToolSpec` + registry +- [ ] `src/openai_schemas.py` exists with `ToolCall` + `ChatMessage` + `UsageStats` +- [ ] `src/provider_state.py` exists with `ProviderHistory` + `_PROVIDER_HISTORIES` dict +- [ ] `src/log_registry.py` has `Session` + `SessionMetadata` dataclasses +- [ ] `src/api_hooks.py` has `WebSocketMessage` + `JsonValue` TypeAlias usage +- [ ] `src/type_aliases.py` extended with `JsonPrimitive` + `JsonValue` +- [ ] `scripts/audit_dataclass_coverage.py` exists with `--strict` mode +- [ ] `scripts/audit_dataclass_coverage.baseline.json` committed +- [ ] `conductor/code_styleguides/type_aliases.md` has §12 "When to Promote" section +- [ ] 6 new test files exist with 48+ tests (Phase 0 audit: 6, Phase 1: 8, Phase 2: 10, Phase 3: 10, Phase 4: 8, Phase 5: 6) +- [ ] All existing tests pass (no regressions in 11-tier batched run) +- [ ] `audit_weak_types.py --strict` exits 0 +- [ ] `audit_dataclass_coverage.py --strict` exits 0 +- [ ] `generate_type_registry.py --check` exits 0 (5 new .md files appear) +- [ ] `docs/reports/TRACK_COMPLETION_any_type_componentization_20260621.md` written +- [ ] Track archived; `conductor/tracks.md` updated \ No newline at end of file diff --git a/conductor/tracks/any_type_componentization_20260621/state.toml b/conductor/tracks/any_type_componentization_20260621/state.toml new file mode 100644 index 00000000..5650e26f --- /dev/null +++ b/conductor/tracks/any_type_componentization_20260621/state.toml @@ -0,0 +1,129 @@ +# Track state for any_type_componentization_20260621 +# Updated by Tier 2 Tech Lead as tasks complete + +[meta] +track_id = "any_type_componentization_20260621" +name = "Any-Type Componentization (Promote dict[str, Any] to dataclass(frozen=True))" +status = "active" +current_phase = 0 +last_updated = "2026-06-21" + +[blocked_by] +data_structure_strengthening_20260606 = "pending_merge" + +[blocks] +any_type_componentization_phase2_2026MMDD = "planned" +openai_tools_dataclass_bridge_2026MMDD = "planned" + +[phases] +phase_0 = { status = "pending", checkpointsha = "", name = "Shared scaffolding (JsonValue + audit + styleguide)" } +phase_1 = { status = "pending", checkpointsha = "", name = "mcp_tool_specs (P1, 8 sites)" } +phase_2 = { status = "pending", checkpointsha = "", name = "openai_schemas (P1, 17 sites)" } +phase_3 = { status = "pending", checkpointsha = "", name = "provider_state (P2, 41 sites)" } +phase_4 = { status = "pending", checkpointsha = "", name = "log_registry Session (P2, 7 sites)" } +phase_5 = { status = "pending", checkpointsha = "", name = "api_hooks WebSocketMessage (P3, 16 sites)" } +phase_6 = { status = "pending", checkpointsha = "", name = "Verify + docs + archive" } + +[tasks] +# Phase 0: Shared scaffolding +t0_1 = { status = "pending", commit_sha = "", description = "Red: tests/test_audit_dataclass_coverage.py (mirror tests/test_audit_weak_types.py structure; verify regex patterns + Finding dataclass + --strict mode)" } +t0_2 = { status = "pending", commit_sha = "", description = "Green: implement scripts/audit_dataclass_coverage.py (informational + --json + --strict + --baseline modes)" } +t0_3 = { status = "pending", commit_sha = "", description = "Extend src/type_aliases.py with JsonPrimitive + JsonValue TypeAliases" } +t0_4 = { status = "pending", commit_sha = "", description = "Add §12 'When to Promote TypeAlias to dataclass' to conductor/code_styleguides/type_aliases.md" } +t0_5 = { status = "pending", commit_sha = "", description = "Phase 0 checkpoint commit + git note" } +# Phase 1: mcp_tool_specs (P1) +t1_1 = { status = "pending", commit_sha = "", description = "Red: tests/test_mcp_tool_specs.py (verify 45 tools registered; get_tool_spec dispatch; TOOL_NAMES cross-module invariant)" } +t1_2 = { status = "pending", commit_sha = "", description = "Green: create src/mcp_tool_specs.py with ToolParameter + ToolSpec dataclasses + module-level _REGISTRY" } +t1_3 = { status = "pending", commit_sha = "", description = "Migrate MCP_TOOL_SPECS dict literals to ToolSpec instances in src/mcp_tool_specs.py:_REGISTRY" } +t1_4 = { status = "pending", commit_sha = "", description = "Update src/mcp_client.py call sites (lines 1944, 1958, 2747) to use mcp_tool_specs.tool_names() / get_tool_schemas()" } +t1_5 = { status = "pending", commit_sha = "", description = "Update src/ai_client.py:560,582,1012 (3 sites using mcp_client.TOOL_NAMES -> mcp_tool_specs.tool_names())" } +t1_6 = { status = "pending", commit_sha = "", description = "Verify cross-module invariant: TOOL_NAMES is a subset of models.AGENT_TOOL_NAMES" } +t1_7 = { status = "pending", commit_sha = "", description = "Run regression suite on tests/test_mcp_client.py + tests/test_ai_client.py" } +t1_8 = { status = "pending", commit_sha = "", description = "Phase 1 checkpoint commit + git note" } +# Phase 2: openai_schemas (P1) +t2_1 = { status = "pending", commit_sha = "", description = "Red: tests/test_openai_schemas.py (ChatMessage.from_dict round-trip for 4 roles; UsageStats field access; ToolCall.function.arguments JSON parse; Result[T] error cases)" } +t2_2 = { status = "pending", commit_sha = "", description = "Green: create src/openai_schemas.py with ToolCall + ToolCallFunction + ChatMessage + UsageStats dataclasses" } +t2_3 = { status = "pending", commit_sha = "", description = "Refactor src/openai_compatible.py:NormalizedResponse (4 usage fields -> UsageStats; tool_calls -> tuple[ToolCall, ...])" } +t2_4 = { status = "pending", commit_sha = "", description = "Refactor src/openai_compatible.py:OpenAICompatibleRequest (messages -> list[ChatMessage])" } +t2_5 = { status = "pending", commit_sha = "", description = "Update src/openai_compatible.py internal consumers (~5 functions constructing/parsing NormalizedResponse)" } +t2_6 = { status = "pending", commit_sha = "", description = "Update src/ai_client.py _send_grok + _send_minimax + _send_llama (3 functions constructing OpenAICompatibleRequest)" } +t2_7 = { status = "pending", commit_sha = "", description = "Cross-check src/api_hook_client.py for NormalizedResponse/OpenAICompatibleRequest consumers" } +t2_8 = { status = "pending", commit_sha = "", description = "Run regression suite on tests/test_openai_compatible.py + tests/test_ai_client.py" } +t2_9 = { status = "pending", commit_sha = "", description = "Phase 2 checkpoint commit + git note" } +# Phase 3: provider_state (P2) +t3_1 = { status = "pending", commit_sha = "", description = "Audit baseline snapshot: count __history + __history_lock references in src/ai_client.py" } +t3_2 = { status = "pending", commit_sha = "", description = "Red: tests/test_provider_state.py (ProviderHistory.append thread-safety; clear atomicity; get_history singleton; cleanup clears all 6)" } +t3_3 = { status = "pending", commit_sha = "", description = "Green: create src/provider_state.py with ProviderHistory dataclass + _PROVIDER_HISTORIES dict" } +t3_4 = { status = "pending", commit_sha = "", description = "Remove 7 module globals + 7 lock declarations from src/ai_client.py:111-133" } +t3_5 = { status = "pending", commit_sha = "", description = "Update src/ai_client.py:463-466 (cleanup() global declarations removed)" } +t3_6 = { status = "pending", commit_sha = "", description = "Update src/ai_client.py:483-499 (cleanup() 7 lock blocks -> get_history(p).clear())" } +t3_7 = { status = "pending", commit_sha = "", description = "Update src/ai_client.py _send_anthropic (~20 sites at lines 1447, 1457-1460, 1469, 1471, 1475, 1489, 1503, 1506, 1582)" } +t3_8 = { status = "pending", commit_sha = "", description = "Update src/ai_client.py _send_deepseek (~10 sites at lines 2201-2202, 2221-2222, 2353, 2360, 2418-2420)" } +t3_9 = { status = "pending", commit_sha = "", description = "Update src/ai_client.py _send_grok (~10 sites at lines 2575-2588, 2605)" } +t3_10 = { status = "pending", commit_sha = "", description = "Update src/ai_client.py _send_minimax (~10 sites at lines 2659-2685)" } +t3_11 = { status = "pending", commit_sha = "", description = "Update src/ai_client.py _send_qwen (~8 sites at lines 2812-2823)" } +t3_12 = { status = "pending", commit_sha = "", description = "Update src/ai_client.py _send_llama (~8 sites at lines 2901-2925)" } +t3_13 = { status = "pending", commit_sha = "", description = "Verify SDK client holders (_gemini_chat, etc.) NOT touched (Pattern 3 preserved)" } +t3_14 = { status = "pending", commit_sha = "", description = "Run regression suite on tests/test_ai_client*.py (8 files; 27 tests)" } +t3_15 = { status = "pending", commit_sha = "", description = "Phase 3 checkpoint commit + git note" } +# Phase 4: log_registry Session (P2) +t4_1 = { status = "pending", commit_sha = "", description = "Red: extend tests/test_log_registry.py (Session.from_dict round-trip; Session.metadata Optional; LogRegistry.data typed)" } +t4_2 = { status = "pending", commit_sha = "", description = "Green: add Session + SessionMetadata dataclasses inline in src/log_registry.py" } +t4_3 = { status = "pending", commit_sha = "", description = "Refactor LogRegistry.data: dict[str, dict[str, Any]] -> dict[str, Session]" } +t4_4 = { status = "pending", commit_sha = "", description = "Update src/session_logger.py (open_session, close_session)" } +t4_5 = { status = "pending", commit_sha = "", description = "Update src/log_pruner.py (prune_old_logs)" } +t4_6 = { status = "pending", commit_sha = "", description = "Update src/gui_2.py Log Management panel" } +t4_7 = { status = "pending", commit_sha = "", description = "Run regression suite on tests/test_log_registry.py + tests/test_session_logger.py + tests/test_log_pruner.py" } +t4_8 = { status = "pending", commit_sha = "", description = "Phase 4 checkpoint commit + git note" } +# Phase 5: api_hooks WebSocketMessage (P3) +t5_1 = { status = "pending", commit_sha = "", description = "Red: extend tests/test_api_hooks.py (WebSocketMessage frozen=True; JsonValue round-trip via _serialize_for_api; Pattern 4 preserved)" } +t5_2 = { status = "pending", commit_sha = "", description = "Green: add WebSocketMessage dataclass inline in src/api_hooks.py" } +t5_3 = { status = "pending", commit_sha = "", description = "Update broadcast() signature: (channel, payload: dict[str, Any]) -> (message: WebSocketMessage)" } +t5_4 = { status = "pending", commit_sha = "", description = "Update _serialize_for_api return type: Any -> JsonValue" } +t5_5 = { status = "pending", commit_sha = "", description = "Update broadcast() callers (~5-10 sites across src/app_controller.py, src/gui_2.py)" } +t5_6 = { status = "pending", commit_sha = "", description = "Verify Pattern 4 preserved: _get_app_attr, _set_app_attr signatures unchanged" } +t5_7 = { status = "pending", commit_sha = "", description = "Run regression suite on tests/test_api_hooks.py + tests/test_app_controller.py" } +t5_8 = { status = "pending", commit_sha = "", description = "Phase 5 checkpoint commit + git note" } +# Phase 6: Verify + docs + archive +t6_1 = { status = "pending", commit_sha = "", description = "Run scripts/audit_weak_types.py --strict (exit 0)" } +t6_2 = { status = "pending", commit_sha = "", description = "Run scripts/audit_dataclass_coverage.py --strict (exit 0; generate baseline)" } +t6_3 = { status = "pending", commit_sha = "", description = "Run scripts/generate_type_registry.py (auto-include new modules) + --check (exit 0)" } +t6_4 = { status = "pending", commit_sha = "", description = "Run 11-tier batched regression suite (per test_sandbox_hardening_20260619 convention)" } +t6_5 = { status = "pending", commit_sha = "", description = "Write docs/reports/TRACK_COMPLETION_any_type_componentization_20260621.md" } +t6_6 = { status = "pending", commit_sha = "", description = "git mv conductor/tracks/any_type_componentization_20260621 conductor/tracks/archive/" } +t6_7 = { status = "pending", commit_sha = "", description = "Update conductor/tracks.md (move entry to Recently Completed)" } +t6_8 = { status = "pending", commit_sha = "", description = "Final state.toml update + Phase 6 checkpoint commit + git note" } + +[verification] +phase_0_jsonvalue_complete = false +phase_0_audit_script_complete = false +phase_0_styleguide_complete = false +phase_1_mcp_tool_specs_complete = false +phase_2_openai_schemas_complete = false +phase_3_provider_state_complete = false +phase_4_log_registry_complete = false +phase_5_api_hooks_complete = false +phase_6_track_archived = false +full_11_tier_regression_passes = false +audit_weak_types_strict_passes = false +audit_dataclass_coverage_strict_passes = false +type_registry_check_passes = false + +[candidate_progression] +# Filled as phases complete +p1_mcp_tool_specs_sites = 8 +p1_openai_schemas_sites = 17 +p2_provider_state_sites = 41 +p2_log_registry_sites = 7 +p3_api_hooks_sites = 16 +total_candidate_sites = 89 + +[files_modified_or_created] +new = ["src/mcp_tool_specs.py", "src/openai_schemas.py", "src/provider_state.py", "scripts/audit_dataclass_coverage.py", "scripts/audit_dataclass_coverage.baseline.json"] +modified = ["src/type_aliases.py", "src/mcp_client.py", "src/openai_compatible.py", "src/ai_client.py", "src/log_registry.py", "src/session_logger.py", "src/log_pruner.py", "src/gui_2.py", "src/api_hooks.py", "conductor/code_styleguides/type_aliases.md"] + +[input_artifact] +report = "docs/reports/ANY_TYPE_AUDIT_20260621.md" +findings_count = 300 +candidates_count = 5 +candidate_sites = 89 \ No newline at end of file diff --git a/scripts/video_analysis/ocr_frames.py b/scripts/video_analysis/ocr_frames.py new file mode 100644 index 00000000..0b164d5a --- /dev/null +++ b/scripts/video_analysis/ocr_frames.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from scripts.video_analysis.error_types import ErrorInfo, make_error + + +@dataclass +class _Ok: + value: Any + + def is_ok(self) -> bool: + return True + + def is_err(self) -> bool: + return False + + +@dataclass +class _Err: + err: ErrorInfo + + def is_ok(self) -> bool: + return False + + def is_err(self) -> bool: + return True + + +def ok(value: Any) -> _Ok: + return _Ok(value) + + +def err(error: ErrorInfo) -> _Err: + return _Err(error) + + +def list_frame_files(frames_dir: Path) -> list[Path]: + return sorted(p for p in frames_dir.glob("frame_*.jpg")) + + +def _ocr_single_image(image_path: Path, backend: str) -> tuple[str, float]: + if backend == "winsdk": + return _ocr_winsdk(image_path) + if backend == "tesseract": + return _ocr_tesseract(image_path) + raise ValueError(f"Unknown OCR backend: {backend}") + + +def _ocr_winsdk(image_path: Path) -> tuple[str, float]: + from winsdk.windows.storage import StorageFile + from winsdk.windows.graphics.imaging import BitmapDecoder + from winsdk.windows.media.ocr import OcrEngine + from winsdk.windows.globalization import Language + + async def _run() -> str: + file = await StorageFile.get_file_from_path_async(str(image_path.resolve())) + stream = await file.open_read_async() + decoder = await BitmapDecoder.create_async(stream) + bitmap = await decoder.get_software_bitmap_async() + engine = OcrEngine.try_create_from_language(Language("en-US")) + if not engine: + return "" + result = await engine.recognize_async(bitmap) + return "\n".join(line.text for line in result.lines) + + text = asyncio.run(_run()) + return text, 0.9 if text else 0.0 + + +def _ocr_tesseract(image_path: Path) -> tuple[str, float]: + import pytesseract + from PIL import Image + img = Image.open(image_path) + text = pytesseract.image_to_string(img) + return text, 0.85 if text.strip() else 0.0 + + +def format_ocr_markdown(frames: list[tuple[str, str, str]]) -> str: + lines = ["# OCR Results", ""] + for filename, text, _timestamp in frames: + lines.append(f"## {filename}") + lines.append("") + lines.append("```") + lines.append(text or "(no text extracted)") + lines.append("```") + lines.append("") + return "\n".join(lines) + + +def ocr_frames(frames_dir: Path, output: Path, backend: str = "winsdk") -> _Ok | _Err: + if not frames_dir.exists(): + return err(make_error("FramesDirNotFound", "ocr_frames", str(frames_dir))) + frames = list_frame_files(frames_dir) + if not frames: + return err(make_error("NoFramesFound", "ocr_frames", str(frames_dir))) + now = datetime.now(timezone.utc).isoformat() + results: list[tuple[str, str, str]] = [] + for frame_path in frames: + try: + text, confidence = _ocr_single_image(frame_path, backend) + except Exception as e: + return err(make_error("OcrError", "ocr_frames", f"{frame_path}: {e}")) + results.append((frame_path.name, text, now)) + output.write_text(format_ocr_markdown(results), encoding="utf-8") + return ok({"frames_ocrd": len(results), "output": str(output), "backend": backend}) diff --git a/tests/test_video_analysis_ocr_frames.py b/tests/test_video_analysis_ocr_frames.py new file mode 100644 index 00000000..bef02081 --- /dev/null +++ b/tests/test_video_analysis_ocr_frames.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +from scripts.video_analysis.ocr_frames import ( + format_ocr_markdown, + list_frame_files, + ocr_frames, +) + + +def test_list_frame_files_returns_sorted(tmp_path: Path) -> None: + (tmp_path / "frame_00001.jpg").write_bytes(b"x") + (tmp_path / "frame_00002.jpg").write_bytes(b"x") + (tmp_path / "frame_00010.jpg").write_bytes(b"x") + (tmp_path / "ignored.txt").write_bytes(b"x") + files = list_frame_files(tmp_path) + assert len(files) == 3 + assert files[0].name == "frame_00001.jpg" + assert files[2].name == "frame_00010.jpg" + + +def test_format_ocr_markdown_empty() -> None: + out = format_ocr_markdown([]) + assert "# OCR Results" in out + + +def test_format_ocr_markdown_with_frames() -> None: + frames = [("frame_00001.jpg", "Hello world", "2026-06-21T00:00:00Z")] + out = format_ocr_markdown(frames) + assert "frame_00001.jpg" in out + assert "Hello world" in out + + +def test_ocr_frames_calls_backend(tmp_path: Path) -> None: + (tmp_path / "frame_00001.jpg").write_bytes(b"fake-jpg-bytes") + with patch("scripts.video_analysis.ocr_frames._ocr_single_image") as mock_ocr: + mock_ocr.return_value = ("extracted text", 0.95) + result = ocr_frames(tmp_path, tmp_path / "ocr.md", backend="tesseract") + assert result.is_ok() + assert (tmp_path / "ocr.md").exists()