---
phase: 02-search-core
plan: 03
type: tdd
wave: 2
depends_on:
  - 02-01
files_modified:
  - src/corpus_analyzer/search/classifier.py
  - src/corpus_analyzer/search/formatter.py
  - tests/search/test_classifier.py
  - tests/search/test_formatter.py
autonomous: true
requirements:
  - CLASS-01
  - SEARCH-05

must_haves:
  truths:
    - "classify_by_rules() returns the correct construct type for all defined rule signals"
    - "LLM fallback is called only when rules return None"
    - "Unclassified files (rules + LLM both fail) return 'documentation'"
    - "extract_snippet() returns 2-3 lines of text centered on the most query-relevant line"
    - "extract_snippet() truncates at word boundary when result exceeds 200 chars"
  artifacts:
    - path: "src/corpus_analyzer/search/classifier.py"
      provides: "classify_file() top-level function + classify_by_rules() + LLM fallback"
      exports: ["classify_file", "classify_by_rules", "CONSTRUCT_TYPES"]
      min_lines: 60
    - path: "src/corpus_analyzer/search/formatter.py"
      provides: "extract_snippet() function"
      exports: ["extract_snippet"]
      min_lines: 25
  key_links:
    - from: "src/corpus_analyzer/search/classifier.py"
      to: "python-frontmatter"
      via: "frontmatter.loads(text) for YAML frontmatter signals"
      pattern: "frontmatter\\.loads"
    - from: "src/corpus_analyzer/search/classifier.py"
      to: "ollama"
      via: "ollama.generate() called only when rules return None"
      pattern: "ollama\\.generate"
---

<objective>
TDD implementation of ConstructClassifier rule-based logic + LLM fallback, and the snippet extraction formatter.

Purpose: These two modules are independent of the search engine and can run in parallel with Plan 02-02. The classifier produces construct_type labels consumed by the indexer extension (Plan 02-04). The formatter produces snippets consumed by the CLI commands (Plan 02-05).

Output: Working, tested classify_file() and extract_snippet() functions.
</objective>

<execution_context>
@/home/ollie/.claude/get-shit-done/workflows/execute-plan.md
@/home/ollie/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/phases/02-search-core/02-CONTEXT.md
@.planning/phases/02-search-core/02-RESEARCH.md
@.planning/phases/02-search-core/02-01-SUMMARY.md
@src/corpus_analyzer/store/schema.py
@tests/search/test_classifier.py
@tests/search/test_formatter.py
</context>

<feature>
  <name>ConstructClassifier and snippet formatter</name>
  <files>src/corpus_analyzer/search/classifier.py, src/corpus_analyzer/search/formatter.py, tests/search/test_classifier.py, tests/search/test_formatter.py</files>
  <behavior>
    **classify_file(file_path: Path, text: str, model: str, use_llm: bool = True) -> str**

    Two-tier classifier. Returns one of: "skill", "prompt", "workflow", "agent_config", "code", "documentation".

    Tier 1 — classify_by_rules(file_path, text) -> str | None:
    Rules (checked in priority order, first match wins):
    - file_path.suffix in {".py", ".ts", ".js"} → "code"
    - Any path part (lowercased) == "skills" or contains "skill" → "skill"
    - Any path part (lowercased) == "prompts" or contains "prompt" → "prompt"
    - Any path part (lowercased) == "workflows" or contains "workflow" → "workflow"
    - file_path.suffix in {".md", ".yaml", ".yml"} AND frontmatter has name + description + tools → "agent_config"
    - file_path.suffix in {".md", ".yaml", ".yml"} AND frontmatter has name + description (no tools) → "skill"
    - file_path.stem (lowercased) contains "workflow" → "workflow"
    - file_path.stem (lowercased) contains "prompt" → "prompt"
    - Returns None if no rule matches

    Tier 2 — LLM fallback (only when Tier 1 returns None AND use_llm=True):
    - Call ollama.generate() with a short prompt asking for one of the six labels
    - Parse response: find first word in response that matches CONSTRUCT_TYPES set
    - If no match found, return "documentation"
    - If use_llm=False or LLM call raises, return "documentation"

    Test cases (input -> expected output):
    - Path("skills/my-skill.md"), any text → "skill"
    - Path("prompts/rewrite.md"), any text → "prompt"
    - Path("workflows/deploy.md"), any text → "workflow"
    - Path("some/dir/tool.py"), any text → "code"
    - Path("some/dir/tool.ts"), any text → "code"
    - MD text with frontmatter {name: X, description: Y, tools: []} → "agent_config"
    - MD text with frontmatter {name: X, description: Y} → "skill"
    - Ambiguous file with no rule signal, use_llm=True → LLM called (mock returns "prompt") → "prompt"
    - Ambiguous file with no rule signal, use_llm=False → "documentation"
    - LLM call raises exception → "documentation" (no crash)

    **extract_snippet(text: str, query: str, max_lines: int = 3) -> str**

    Returns a window of up to max_lines lines from text, centered on the line with the highest
    count of query term matches. If text has <= max_lines lines, returns text in full.
    Truncates output at 200 chars on a word boundary, appending "…" if truncated.

    Test cases:
    - text with 2 lines, max_lines=3 → return full text unchanged
    - text where line 5 has most query terms → window starts at line 4
    - output > 200 chars → truncated at last space before char 200, ends with "…"
    - query="" (empty) → return first max_lines lines without error
  </behavior>
  <implementation>
    **src/corpus_analyzer/search/classifier.py:**

    ```python
    from __future__ import annotations
    from pathlib import Path
    import frontmatter  # type: ignore[import-untyped]
    import ollama  # type: ignore[import-untyped]

    CONSTRUCT_TYPES = frozenset({"skill", "prompt", "workflow", "agent_config", "code", "documentation"})

    LLM_CLASSIFY_PROMPT = """You are classifying a file in an AI agent library.
    Choose exactly one label from: skill, prompt, workflow, agent_config, code, documentation.

    - skill: A reusable capability or tool definition
    - prompt: A prompt template or instruction set
    - workflow: A multi-step process or pipeline definition
    - agent_config: An agent configuration with name, description, and tools
    - code: Source code (implementation file)
    - documentation: Everything else (readme, guide, reference)

    File: {filename}
    Content (first 500 chars): {content}

    Respond with only the label (one word):"""

    def classify_by_rules(file_path: Path, text: str) -> str | None: ...
    def classify_file(file_path: Path, text: str, model: str, *, use_llm: bool = True) -> str: ...
    ```

    **src/corpus_analyzer/search/formatter.py:**

    ```python
    from __future__ import annotations

    def extract_snippet(text: str, query: str, max_lines: int = 3) -> str: ...
    ```

    For test_classifier.py: mock `ollama.generate` using `unittest.mock.patch` to avoid needing a live Ollama instance. Return a mock response object with `.response` attribute set to the desired label string.

    For frontmatter tests: use inline YAML strings like:
    ```
    ---
    name: My Skill
    description: Does something
    tools:
      - bash
    ---
    Content here
    ```

    All functions require full type annotations and PEP 257 docstrings. Run mypy after GREEN.
  </implementation>
</feature>

<verification>
After GREEN phase (all tests passing):
`uv run pytest tests/search/test_classifier.py tests/search/test_formatter.py -v`

All 12 tests must show PASSED (not XFAIL).

Run lint and type check:
`uv run ruff check src/corpus_analyzer/search/classifier.py src/corpus_analyzer/search/formatter.py && uv run mypy src/corpus_analyzer/search/classifier.py src/corpus_analyzer/search/formatter.py`
</verification>

<success_criteria>
- All test_classifier.py tests pass (8 tests)
- All test_formatter.py tests pass (4 tests)
- classify_by_rules() covers all 8 rule signals from the research doc
- LLM fallback is mocked in tests (no live Ollama required for unit tests)
- extract_snippet() handles empty query, short text, and long text truncation
- mypy and ruff pass on both files
</success_criteria>

<output>
After completion, create `.planning/phases/02-search-core/02-03-SUMMARY.md` with:
- classify_file() and extract_snippet() import paths
- List of rule signals implemented (for reference in Plan 02-04)
- Any behavior deviations from plan
</output>
