|
| 1 | +# Phase 1 — Data Model: Fix BehaviorAnalyzer Category Injection |
| 2 | + |
| 3 | +**Feature**: 036-fix-analyzer-categories |
| 4 | +**Date**: 2026-04-10 |
| 5 | + |
| 6 | +This feature reshapes existing types rather than introducing new ones. The "data model" is therefore a before/after table for each affected type. |
| 7 | + |
| 8 | +## Type 1 — `IdentifiedBehavior` (Spectra.CLI.Agent.Analysis) |
| 9 | + |
| 10 | +| Aspect | Before | After | |
| 11 | +|--------|--------|-------| |
| 12 | +| Category field | `string CategoryRaw` (with `[JsonPropertyName("category")]`) | `string Category` (same JSON name) | |
| 13 | +| Derived enum getter | `BehaviorCategory Category => ParseCategory(CategoryRaw)` (collapses unknowns to `HappyPath`) | **REMOVED** | |
| 14 | +| Default for empty/null | Falls into HappyPath via the parser's `_ =>` arm | Stored as-is; the analyzer's grouping step substitutes `"uncategorized"` for empty/null | |
| 15 | + |
| 16 | +### Validation rules |
| 17 | +- `Category` MUST be preserved exactly as the AI sent it (no normalization, no rebadging) — this is the field-level guarantee for FR-006. |
| 18 | +- The model carries no validation on the category string; the analyzer is responsible for the "uncategorized" fallback at grouping time. |
| 19 | + |
| 20 | +## Type 2 — `BehaviorAnalysisResult` (Spectra.CLI.Agent.Analysis) |
| 21 | + |
| 22 | +| Aspect | Before | After | |
| 23 | +|--------|--------|-------| |
| 24 | +| `Breakdown` type | `IReadOnlyDictionary<BehaviorCategory, int>` | `IReadOnlyDictionary<string, int>` | |
| 25 | +| `GetRemainingByCategory` parameter | `IReadOnlyList<BehaviorCategory>?` | `IReadOnlyList<string>?` | |
| 26 | +| `GetRemainingByCategory` return | `IReadOnlyDictionary<BehaviorCategory, int>` | `IReadOnlyDictionary<string, int>` | |
| 27 | +| Implementation of `GetRemainingByCategory` | Looks up enum keys, zeroes them | Looks up string keys, zeroes them | |
| 28 | + |
| 29 | +### Validation rules |
| 30 | +- `Breakdown` keys are arbitrary non-empty strings (the "uncategorized" bucket included). |
| 31 | +- `GetRemainingByCategory` MUST treat the parameter as a closed set of identifiers to subtract; behaviors with categories *not* in the parameter list are preserved. |
| 32 | + |
| 33 | +## Type 3 — `BehaviorAnalyzer` (Spectra.CLI.Agent.Copilot) |
| 34 | + |
| 35 | +| Aspect | Before | After | |
| 36 | +|--------|--------|-------| |
| 37 | +| Constructor signature | `BehaviorAnalyzer(SpectraProviderConfig? provider, Action<string>? onStatus = null)` | `BehaviorAnalyzer(SpectraProviderConfig? provider, Action<string>? onStatus = null, SpectraConfig? config = null, PromptTemplateLoader? templateLoader = null)` | |
| 38 | +| Private fields | `_provider`, `_onStatus` | + `_config`, `_templateLoader` | |
| 39 | +| `BuildAnalysisPrompt` call in `AnalyzeAsync` | `BuildAnalysisPrompt(documents, focusArea)` | `BuildAnalysisPrompt(documents, focusArea, _config, _templateLoader)` | |
| 40 | +| Breakdown grouping in `AnalyzeAsync` | `behaviors.GroupBy(b => b.Category)` (enum) | `behaviors.GroupBy(b => string.IsNullOrWhiteSpace(b.Category) ? "uncategorized" : b.Category)` | |
| 41 | +| `FilterByFocus` body | Enum keyword expansion + `behaviors.Where(b => matchingCategories.Contains(b.Category))` | String tokenization + substring match against normalized `b.Category` | |
| 42 | +| `FilterByFocus` parameter type | `List<IdentifiedBehavior> behaviors, string focusArea` | unchanged | |
| 43 | + |
| 44 | +### Validation rules |
| 45 | +- The new constructor params remain optional with `null` defaults — required by FR-005's "Internal/test-only callers MAY pass null" clause. |
| 46 | +- When `templateLoader` is null, the legacy fallback path runs unchanged (this is the path existing tests exercise). |
| 47 | + |
| 48 | +## Type 4 — `AnalysisPresenter` (Spectra.CLI.Output) |
| 49 | + |
| 50 | +| Aspect | Before | After | |
| 51 | +|--------|--------|-------| |
| 52 | +| `CategoryLabels` | `Dictionary<BehaviorCategory, string>` with 5 fixed entries | `Dictionary<string, string>` keyed by category ID, with the same 5 entries plus the 6 Spec 030 defaults; additional/unknown IDs render via fallback formatter `id.Replace('_', ' ').Replace('-', ' ')` | |
| 53 | +| `RenderXxx(IReadOnlyList<BehaviorCategory>?)` parameter (line 65) | enum list | `IReadOnlyList<string>?` | |
| 54 | + |
| 55 | +### Display rules |
| 56 | +- A category ID without a label entry MUST render as a human-readable string by replacing `_` and `-` with spaces (e.g., `keyboard_interaction` → "keyboard interaction"). No exception, no warning. |
| 57 | +- Order in display follows the order of keys in the breakdown dictionary as returned by the analyzer (no alphabetical re-sort) — preserves the AI's original ordering. |
| 58 | + |
| 59 | +## Type 5 — `CountSelector` (Spectra.CLI.Interactive) |
| 60 | + |
| 61 | +| Aspect | Before | After | |
| 62 | +|--------|--------|-------| |
| 63 | +| `SelectedCategories` property | `IReadOnlyList<BehaviorCategory>?` | `IReadOnlyList<string>?` | |
| 64 | +| Internal `Categories` property (line 152) | `IReadOnlyList<BehaviorCategory>?` | `IReadOnlyList<string>?` | |
| 65 | +| `CategoryLabels` dict | enum-keyed | string-keyed (same fallback formatter as `AnalysisPresenter`) | |
| 66 | +| `analysis.Breakdown.OrderBy(...)` (line 97) | iterates enum keys | iterates string keys; ordering remains insertion order | |
| 67 | + |
| 68 | +## Type 6 — `BehaviorCategory` enum (Spectra.Core.Models) |
| 69 | + |
| 70 | +| Aspect | Before | After | |
| 71 | +|--------|--------|-------| |
| 72 | +| Existence | 5-value enum (`HappyPath`, `Negative`, `EdgeCase`, `Security`, `Performance`) with `[JsonStringEnumConverter]` | **DELETED** | |
| 73 | + |
| 74 | +### Migration rules |
| 75 | +- Every reference to `BehaviorCategory.HappyPath` becomes the literal string `"happy_path"`. |
| 76 | +- Every reference to `BehaviorCategory.Negative` becomes `"negative"`. |
| 77 | +- `BehaviorCategory.EdgeCase` → `"edge_case"`. |
| 78 | +- `BehaviorCategory.Security` → `"security"`. |
| 79 | +- `BehaviorCategory.Performance` → `"performance"`. |
| 80 | +- Spec 030 defaults that were not in the legacy enum (`boundary`, `error_handling`) gain first-class display labels in `AnalysisPresenter.CategoryLabels` for nicer rendering. |
| 81 | + |
| 82 | +## State transition: behavior category lifecycle |
| 83 | + |
| 84 | +``` |
| 85 | +AI returns JSON [string identifier from prompt-allowed set or invented] |
| 86 | + │ |
| 87 | + ▼ |
| 88 | +JSON deserialize [IdentifiedBehavior.Category : string, preserved verbatim] |
| 89 | + │ |
| 90 | + ▼ |
| 91 | +GroupBy in AnalyzeAsync [empty/null/whitespace → "uncategorized"; everything else preserved] |
| 92 | + │ |
| 93 | + ▼ |
| 94 | +BehaviorAnalysisResult.Breakdown [IReadOnlyDictionary<string,int>] |
| 95 | + │ |
| 96 | + ├──────► AnalysisPresenter renders → CategoryLabels lookup → fallback formatter |
| 97 | + ├──────► CountSelector lists → CategoryLabels lookup → fallback formatter |
| 98 | + ├──────► JSON output → key/value preserved as-is |
| 99 | + └──────► SuggestionBuilder consumes → unchanged semantics |
| 100 | +``` |
| 101 | + |
| 102 | +## Type-shape diff summary |
| 103 | + |
| 104 | +| File | Lines changed (estimate) | Net production line delta | |
| 105 | +|------|--------------------------|---------------------------| |
| 106 | +| `IdentifiedBehavior.cs` | ~12 | -10 (drop derived getter) | |
| 107 | +| `BehaviorAnalysisResult.cs` | ~4 | 0 | |
| 108 | +| `BehaviorAnalyzer.cs` | ~30 | -5 (FilterByFocus simpler) | |
| 109 | +| `AnalysisPresenter.cs` | ~10 | +5 (fallback formatter helper) | |
| 110 | +| `CountSelector.cs` | ~10 | 0 | |
| 111 | +| `GenerateHandler.cs` | ~6 (3 call sites × 2 lines each) | +6 | |
| 112 | +| `BehaviorCategory.cs` | -16 | -16 (deleted) | |
| 113 | +| **Total** | ~88 | ≈ −20 | |
| 114 | + |
| 115 | +--- |
| 116 | + |
| 117 | +**Status**: Phase 1 data model complete. |
0 commit comments