Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .ai-team/agents/linus/history.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,18 @@
- v1.1 enables observability (skills usage, burndown metrics)
- Key implementation: DecisionService.search() and HealthCheck diagnostic command
- Roadmap session logged to .ai-team/log/2026-02-23-feature-roadmap.md

### DecisionSearchService (#69) (2026-02-24)
- Created DecisionSearchService in src/services/DecisionSearchService.ts - pure service, no file I/O, no VS Code deps
- Methods: search(), filterByDate(), filterByAuthor(), filter() (combined criteria)
- Search ranking: title match (weight 10) > author match (weight 5) > content match (weight 3)
- Multi-word queries split on whitespace; each term scored independently and summed
- filterByDate() uses string comparison on YYYY-MM-DD keys (avoids timezone issues)
- filterByAuthor() is case-insensitive substring match
- filter() chains: search first (preserves ranking order) then date range then author
- Open-ended date ranges: omit startDate or endDate in DecisionSearchCriteria
- Decisions without date excluded from date filters; decisions without author excluded from author filters
- Exported DecisionSearchCriteria and ScoredDecision interfaces for consumer use
- 37 tests in decisionSearchService.test.ts covering all methods and edge cases
- Service is decoupled from DecisionService - operates on DecisionEntry[], not files
- UI integration (tree view search box, filter controls) is Rusty's follow-up
33 changes: 33 additions & 0 deletions .ai-team/decisions/inbox/linus-decision-search-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Decision: DecisionSearchService API Design

**Date:** 2026-02-24
**Author:** Linus
**Issue:** #69

## Context

Issue #69 requires search and filtering for decisions. The existing `DecisionService` handles parsing; we need a separate service for query operations.

## Decision

Created `DecisionSearchService` as a pure, stateless service operating on `DecisionEntry[]`:

- **`search(decisions, query)`** — full-text search with relevance ranking (title 10× > author 5× > content 3×)
- **`filterByDate(decisions, startDate, endDate)`** — inclusive date range using YYYY-MM-DD string comparison
- **`filterByAuthor(decisions, author)`** — case-insensitive substring match
- **`filter(decisions, criteria)`** — chains all three: search first (preserves ranking), then date, then author

Exported types: `DecisionSearchCriteria`, `ScoredDecision`.

## Rationale

- Separation from `DecisionService` keeps parsing and querying decoupled — each can evolve independently
- Pure functions on arrays = trivially testable, no file I/O or VS Code deps
- Search-first chaining preserves relevance ordering through subsequent filters
- String comparison on YYYY-MM-DD avoids timezone issues that plague Date object comparisons

## Impact

- Rusty can consume `DecisionSearchService` from tree view code to wire up the search UI
- The `filter()` method accepts a `DecisionSearchCriteria` object — Rusty should bind UI inputs to this interface
- No changes to `DecisionEntry` model or `DecisionService` were needed
165 changes: 165 additions & 0 deletions src/services/DecisionSearchService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/**
* Service for searching and filtering parsed decisions.
*
* Pure service layer — operates on DecisionEntry[] arrays, no file I/O.
* Designed to be consumed by tree providers and webview controllers.
*
* Ranking strategy: title match > body match > metadata match.
*/

import { DecisionEntry } from '../models';

/**
* Criteria bundle for combined search + filter operations.
* All fields are optional — omitted fields are not applied.
*/
export interface DecisionSearchCriteria {
/** Free-text query matched against title, content, and author */
query?: string;
/** Inclusive start date (YYYY-MM-DD) */
startDate?: string;
/** Inclusive end date (YYYY-MM-DD) */
endDate?: string;
/** Author name (case-insensitive substring match) */
author?: string;
}

/**
* A decision paired with its relevance score from a search operation.
*/
export interface ScoredDecision {
decision: DecisionEntry;
score: number;
}

// Relevance weights — title matches are worth more than body/metadata
const TITLE_WEIGHT = 10;
const CONTENT_WEIGHT = 3;
const AUTHOR_WEIGHT = 5;

export class DecisionSearchService {

/**
* Full-text search across decisions with relevance ranking.
* Matches against title, content, and author fields.
* Results are sorted by score descending (most relevant first).
*
* Returns all decisions (unfiltered) when query is empty/whitespace.
*/
search(decisions: DecisionEntry[], query: string): DecisionEntry[] {
const trimmed = query.trim();
if (!trimmed) {
return decisions;
}

const scored = this.scoreDecisions(decisions, trimmed);
return scored
.filter(s => s.score > 0)
.sort((a, b) => b.score - a.score)
.map(s => s.decision);
}

/**
* Filters decisions to those within an inclusive date range.
* Compares against the decision's `date` field (YYYY-MM-DD strings).
* Decisions without a date are excluded.
*/
filterByDate(decisions: DecisionEntry[], startDate: Date, endDate: Date): DecisionEntry[] {
const startKey = toDateKey(startDate);
const endKey = toDateKey(endDate);

return decisions.filter(d => {
if (!d.date) { return false; }
return d.date >= startKey && d.date <= endKey;
});
}

/**
* Filters decisions by author (case-insensitive substring match).
* Returns all decisions when author is empty/whitespace.
* Decisions without an author field are excluded.
*/
filterByAuthor(decisions: DecisionEntry[], author: string): DecisionEntry[] {
const trimmed = author.trim().toLowerCase();
if (!trimmed) {
return decisions;
}

return decisions.filter(d => {
if (!d.author) { return false; }
return d.author.toLowerCase().includes(trimmed);
});
}

/**
* Combined filter that chains query search, date range, and author filter.
* Applies search first (to get ranking), then narrows with date/author.
*/
filter(decisions: DecisionEntry[], criteria: DecisionSearchCriteria): DecisionEntry[] {
let results = decisions;

// Apply text search first (preserves relevance ordering)
if (criteria.query && criteria.query.trim()) {
results = this.search(results, criteria.query);
}

// Apply date range filter
if (criteria.startDate || criteria.endDate) {
const start = criteria.startDate || '0000-00-00';
const end = criteria.endDate || '9999-99-99';
results = results.filter(d => {
if (!d.date) { return false; }
return d.date >= start && d.date <= end;
});
}

// Apply author filter
if (criteria.author && criteria.author.trim()) {
results = this.filterByAuthor(results, criteria.author);
}

return results;
}

// ─── Private Helpers ────────────────────────────────────────────────

/**
* Scores each decision against the query.
* Splits the query into individual terms for multi-word matching.
*/
private scoreDecisions(decisions: DecisionEntry[], query: string): ScoredDecision[] {
const terms = query.toLowerCase().split(/\s+/).filter(t => t.length > 0);

return decisions.map(decision => {
let score = 0;
const titleLower = (decision.title || '').toLowerCase();
const contentLower = (decision.content || '').toLowerCase();
const authorLower = (decision.author || '').toLowerCase();

for (const term of terms) {
if (titleLower.includes(term)) {
score += TITLE_WEIGHT;
}
if (contentLower.includes(term)) {
score += CONTENT_WEIGHT;
}
if (authorLower.includes(term)) {
score += AUTHOR_WEIGHT;
}
}

return { decision, score };
});
}
}

/**
* Converts a Date to a YYYY-MM-DD string using local time.
* Avoids timezone-shifting issues that toISOString() causes.
*/
function toDateKey(d: Date): string {
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
5 changes: 5 additions & 0 deletions src/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export {
} from './GitHubIssuesService';
export { SkillCatalogService } from './SkillCatalogService';
export { DecisionService } from './DecisionService';
export {
DecisionSearchService,
type DecisionSearchCriteria,
type ScoredDecision
} from './DecisionSearchService';
export { SquadVersionService, type UpgradeCheckResult } from './SquadVersionService';
export {
StandupReportService,
Expand Down
Loading
Loading