From 09f6850d277c6659ca2c79f3278a07583cc6873d Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Sun, 23 Aug 2026 00:28:18 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20NoiseFilter=20=E3=82=92=E5=AE=9F?= =?UTF-8?q?=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit star数閾値ではなくtopicsタグ(macos/macos-app)とインストール可能な 資産(.dmg/.zip)の存在ANDでリポジトリの発見対象を絞り込む。 Strategyパターンとして差し替え可能なNoiseFilteringプロトコルを定義。 -m "topics有無×asset有無の4象限、.pkgのみ資産の除外、大文字小文字の 非依存一致を含む7ケースをSwift Testingでカバー" Co-Authored-By: Claude Sonnet 5 --- .../Cairn/Classification/NoiseFilter.swift | 40 ++++++ .../Classification/NoiseFilterTests.swift | 119 ++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 Sources/Cairn/Classification/NoiseFilter.swift create mode 100644 Tests/CairnTests/Classification/NoiseFilterTests.swift diff --git a/Sources/Cairn/Classification/NoiseFilter.swift b/Sources/Cairn/Classification/NoiseFilter.swift new file mode 100644 index 0000000..a51ac3e --- /dev/null +++ b/Sources/Cairn/Classification/NoiseFilter.swift @@ -0,0 +1,40 @@ +import Foundation + +/// リポジトリを「発見」対象に含めるか判定するプロトコル。 +/// 将来的に別の判定戦略(例: star数フィルタ)へ差し替えられるようStrategyパターン化する。 +protocol NoiseFiltering: Sendable { + func shouldInclude(repository: Repository, releases: [Release]) -> Bool +} + +/// topicsタグの一致(条件A)とインストール可能な資産の存在(条件B)のANDで判定する実装。 +/// star数閾値は採用しない(知る人ぞ知る新規アプリの取りこぼしを避けるため)。 +struct NoiseFilter: NoiseFiltering { + let requiredTopicsAny: Set + let validAssetExtensions: Set + + init( + requiredTopicsAny: Set = ["macos", "macos-app"], + // .pkgのみの資産は今回スコープ外(Phase7のInstallerが.pkgに未対応のため)。 + // 将来Installer側が対応したらここに"pkg"を追加する。 + validAssetExtensions: Set = ["dmg", "zip"] + ) { + self.requiredTopicsAny = requiredTopicsAny + self.validAssetExtensions = validAssetExtensions + } + + func shouldInclude(repository: Repository, releases: [Release]) -> Bool { + hasRequiredTopic(repository) && hasValidAsset(in: releases) + } + + private func hasRequiredTopic(_ repository: Repository) -> Bool { + !requiredTopicsAny.isDisjoint(with: Set(repository.topics.map { $0.lowercased() })) + } + + private func hasValidAsset(in releases: [Release]) -> Bool { + releases.contains { release in + release.assets.contains { asset in + validAssetExtensions.contains((asset.name as NSString).pathExtension.lowercased()) + } + } + } +} diff --git a/Tests/CairnTests/Classification/NoiseFilterTests.swift b/Tests/CairnTests/Classification/NoiseFilterTests.swift new file mode 100644 index 0000000..c0c563e --- /dev/null +++ b/Tests/CairnTests/Classification/NoiseFilterTests.swift @@ -0,0 +1,119 @@ +import Foundation +import Testing + +@testable import Cairn + +@Suite("ノイズ除去フィルタ") +struct NoiseFilterTests { + private func makeRepository(topics: [String]) -> Repository { + Repository( + id: 1, + name: "SampleApp", + fullName: "owner/SampleApp", + owner: GitHubUser( + id: 1, + login: "owner", + avatarURL: URL(string: "https://example.com/avatar.png")!, + htmlURL: URL(string: "https://github.com/owner")!, + type: "User" + ), + htmlURL: URL(string: "https://github.com/owner/SampleApp")!, + description: nil, + stargazersCount: 0, + topics: topics, + language: "Swift", + updatedAt: Date(timeIntervalSince1970: 0), + pushedAt: Date(timeIntervalSince1970: 0), + defaultBranch: "main", + archived: false, + fork: false + ) + } + + private func makeRelease(assetNames: [String]) -> Release { + Release( + id: 1, + tagName: "v1.0.0", + name: nil, + body: nil, + publishedAt: nil, + draft: false, + prerelease: false, + assets: assetNames.enumerated().map { index, name in + ReleaseAsset( + id: index, + name: name, + browserDownloadURL: URL(string: "https://example.com/\(name)")!, + size: 0, + contentType: "application/octet-stream" + ) + } + ) + } + + @Test("topicsとdmg資産の両方があれば含める") + func includesWhenTopicAndDmgAssetPresent() { + let filter = NoiseFilter() + let repository = makeRepository(topics: ["macos"]) + let releases = [makeRelease(assetNames: ["App.dmg"])] + + #expect(filter.shouldInclude(repository: repository, releases: releases)) + } + + @Test("topicsはあるが資産がなければ除外する") + func excludesWhenNoAsset() { + let filter = NoiseFilter() + let repository = makeRepository(topics: ["macos"]) + let releases = [makeRelease(assetNames: [])] + + #expect(!filter.shouldInclude(repository: repository, releases: releases)) + } + + @Test("topicsがなければ資産があっても除外する") + func excludesWhenNoRequiredTopic() { + let filter = NoiseFilter() + let repository = makeRepository(topics: ["cli"]) + let releases = [makeRelease(assetNames: ["App.dmg"])] + + #expect(!filter.shouldInclude(repository: repository, releases: releases)) + } + + @Test("topicsも資産もなければ除外する") + func excludesWhenNeitherTopicNorAsset() { + let filter = NoiseFilter() + let repository = makeRepository(topics: []) + let releases = [makeRelease(assetNames: [])] + + #expect(!filter.shouldInclude(repository: repository, releases: releases)) + } + + @Test(".pkgのみの資産は対象外とする") + func excludesPkgOnlyAssets() { + let filter = NoiseFilter() + let repository = makeRepository(topics: ["macos-app"]) + let releases = [makeRelease(assetNames: ["Installer.pkg"])] + + #expect(!filter.shouldInclude(repository: repository, releases: releases)) + } + + @Test("複数リリースのうちいずれかがdmg資産を持てば含める") + func includesWhenAnyReleaseHasValidAsset() { + let filter = NoiseFilter() + let repository = makeRepository(topics: ["macos"]) + let releases = [ + makeRelease(assetNames: ["notes.txt"]), + makeRelease(assetNames: ["App.dmg"]), + ] + + #expect(filter.shouldInclude(repository: repository, releases: releases)) + } + + @Test("topicsの大文字小文字が異なっても一致する") + func matchesTopicsCaseInsensitively() { + let filter = NoiseFilter() + let repository = makeRepository(topics: ["MacOS"]) + let releases = [makeRelease(assetNames: ["App.zip"])] + + #expect(filter.shouldInclude(repository: repository, releases: releases)) + } +} From 2165bd6e6d8d1ecb14cecbfacf767cb8fafd6daf Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Sun, 23 Aug 2026 00:28:55 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20Category=20enum=20=E3=81=A8=20Categ?= =?UTF-8?q?oryKeywords=20=E8=BE=9E=E6=9B=B8=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 固定カテゴリenumと、キーワード辞書をResources/CategoryKeywords.json として外部化する読み込みロジックをセットで追加。リビルドなしで 手動メンテできるようにするための外部化。 -m "rawValueはenumケース名と同一camelCaseとし、JSONキーと直接一致 させることで変換テーブルを不要にした。未知キーはデコード時に無視する" Co-Authored-By: Claude Sonnet 5 --- Sources/Cairn/Classification/Category.swift | 15 +++++++++ .../Classification/CategoryKeywords.swift | 31 +++++++++++++++++++ Sources/Cairn/Resources/CategoryKeywords.json | 12 +++++++ 3 files changed, 58 insertions(+) create mode 100644 Sources/Cairn/Classification/Category.swift create mode 100644 Sources/Cairn/Classification/CategoryKeywords.swift create mode 100644 Sources/Cairn/Resources/CategoryKeywords.json diff --git a/Sources/Cairn/Classification/Category.swift b/Sources/Cairn/Classification/Category.swift new file mode 100644 index 0000000..38945f5 --- /dev/null +++ b/Sources/Cairn/Classification/Category.swift @@ -0,0 +1,15 @@ +/// アプリのジャンル分類。rawValueは`Resources/CategoryKeywords.json`のキーと一致させる。 +/// 表示用の日本語ラベルはUI層(Phase5)で追加するためここでは持たせない。 +enum Category: String, Codable, CaseIterable, Sendable { + case developerTools + case productivity + case mediaCreation + case music + case photography + case utilities + case system + case games + case communication + case education + case other +} diff --git a/Sources/Cairn/Classification/CategoryKeywords.swift b/Sources/Cairn/Classification/CategoryKeywords.swift new file mode 100644 index 0000000..ea633ae --- /dev/null +++ b/Sources/Cairn/Classification/CategoryKeywords.swift @@ -0,0 +1,31 @@ +import Foundation + +/// `Resources/CategoryKeywords.json`から読み込んだカテゴリ→キーワード辞書。 +/// リビルドなしで手動メンテできるようキーワードをコードから外部化している。 +struct CategoryKeywords: Sendable { + let keywordsByCategory: [Category: [String]] + + static func loadBundled() -> CategoryKeywords { + guard let url = Bundle.module.url(forResource: "CategoryKeywords", withExtension: "json"), + let data = try? Data(contentsOf: url) + else { + fatalError("CategoryKeywords.json is missing from the bundle") + } + do { + return try decode(data) + } catch { + fatalError("CategoryKeywords.json is malformed: \(error)") + } + } + + static func decode(_ data: Data) throws -> CategoryKeywords { + let raw = try JSONDecoder().decode([String: [String]].self, from: data) + var mapping: [Category: [String]] = [:] + for (key, keywords) in raw { + // 未知のキー(typo等)は無視する + guard let category = Category(rawValue: key) else { continue } + mapping[category] = keywords.map { $0.lowercased() } + } + return CategoryKeywords(keywordsByCategory: mapping) + } +} diff --git a/Sources/Cairn/Resources/CategoryKeywords.json b/Sources/Cairn/Resources/CategoryKeywords.json new file mode 100644 index 0000000..966db18 --- /dev/null +++ b/Sources/Cairn/Resources/CategoryKeywords.json @@ -0,0 +1,12 @@ +{ + "developerTools": ["cli", "compiler", "sdk", "devtools", "ide", "linter", "build-tool", "xcode"], + "productivity": ["todo", "task-manager", "note-taking", "calendar", "productivity", "notes"], + "mediaCreation": ["video-editor", "design-tool", "3d", "animation", "illustration", "vector"], + "music": ["music", "audio", "daw", "midi", "synth", "audio-player"], + "photography": ["photo", "photography", "raw", "image-editor", "camera"], + "utilities": ["utility", "menu-bar", "toolbox", "system-utility"], + "system": ["window-manager", "dotfiles", "shell", "terminal", "launcher"], + "games": ["game", "puzzle-game", "arcade", "gaming"], + "communication": ["chat", "messaging", "email-client", "irc", "messenger"], + "education": ["education", "learning", "flashcards", "language-learning", "study"] +} From cda3514fb719118bc0a847f000b96110c48c1d66 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Sun, 23 Aug 2026 00:30:01 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20CategoryClassifier=20=E3=81=AE?= =?UTF-8?q?=E3=82=B9=E3=82=B3=E3=82=A2=E3=83=AA=E3=83=B3=E3=82=B0=E3=83=AD?= =?UTF-8?q?=E3=82=B8=E3=83=83=E3=82=AF=E3=82=92=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit topics完全一致(3点)>リポジトリ名部分一致(2点)>README冒頭一致(1点)の 優先順位でスコアリングし、最高得点のカテゴリを採用するStrategyパターン 実装。全カテゴリ0点ならotherにフォールバックし、subTagsには分類先に 関わらず常に元のtopicsを併記する。 -m "README冒頭は先頭500文字と定義。同点タイブレークはCategory.allCases 宣言順で決定的に解決する。固定フィクスチャ辞書を注入し本番JSONの将来 変更に依存しないテストを8ケース追加" Co-Authored-By: Claude Sonnet 5 --- .../Classification/CategoryClassifier.swift | 54 ++++++++ .../CategoryClassifierTests.swift | 126 ++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 Sources/Cairn/Classification/CategoryClassifier.swift create mode 100644 Tests/CairnTests/Classification/CategoryClassifierTests.swift diff --git a/Sources/Cairn/Classification/CategoryClassifier.swift b/Sources/Cairn/Classification/CategoryClassifier.swift new file mode 100644 index 0000000..80da662 --- /dev/null +++ b/Sources/Cairn/Classification/CategoryClassifier.swift @@ -0,0 +1,54 @@ +import Foundation + +/// 分類結果。subTagsは分類先カテゴリに関わらず常に元のtopicsをそのまま併記する +/// (分類済みアプリでもtopicsベースの絞り込み・検索を可能にするための設計判断)。 +struct ClassificationResult: Equatable, Sendable { + let category: Category + let subTags: [String] +} + +/// リポジトリをCategoryへ分類するプロトコル。 +/// 将来的にスコアリング方式を差し替えられるようStrategyパターン化する。 +protocol CategoryClassifying: Sendable { + func classify(repository: Repository, readme: String?) -> ClassificationResult +} + +/// topics完全一致(3点) > リポジトリ名部分一致(2点) > README冒頭一致(1点)の +/// スコアリングで最高得点のカテゴリを採用する実装。全カテゴリ0点なら`.other`。 +struct CategoryClassifier: CategoryClassifying { + private let keywords: CategoryKeywords + // README「冒頭」の範囲。数値指定の要件はないため、タイトル+概要段落程度を + // カバーしつつ詳細セクションには踏み込まない目安として500文字を採用する。 + private static let readmePrefixLength = 500 + + init(keywords: CategoryKeywords = .loadBundled()) { + self.keywords = keywords + } + + func classify(repository: Repository, readme: String?) -> ClassificationResult { + let topics = Set(repository.topics.map { $0.lowercased() }) + let nameLowercased = repository.name.lowercased() + let readmePrefix = readme.map { String($0.prefix(Self.readmePrefixLength)).lowercased() } + + var bestCategory: Category? + var bestScore = 0 + + // Category.allCasesの宣言順で走査する(Dictionaryのキー順は不定なため、 + // 同点タイブレークを決定的にするにはこの順序依存が必須)。 + for category in Category.allCases where category != .other { + guard let categoryKeywords = keywords.keywordsByCategory[category] else { continue } + var score = 0 + for keyword in categoryKeywords { + if topics.contains(keyword) { score += 3 } + if nameLowercased.contains(keyword) { score += 2 } + if let readmePrefix, readmePrefix.contains(keyword) { score += 1 } + } + if score > bestScore { + bestScore = score + bestCategory = category + } + } + + return ClassificationResult(category: bestCategory ?? .other, subTags: repository.topics) + } +} diff --git a/Tests/CairnTests/Classification/CategoryClassifierTests.swift b/Tests/CairnTests/Classification/CategoryClassifierTests.swift new file mode 100644 index 0000000..8825dd1 --- /dev/null +++ b/Tests/CairnTests/Classification/CategoryClassifierTests.swift @@ -0,0 +1,126 @@ +import Foundation +import Testing + +@testable import Cairn + +@Suite("カテゴリ分類") +struct CategoryClassifierTests { + private func makeRepository( + name: String = "SampleApp", + topics: [String] = [] + ) -> Repository { + Repository( + id: 1, + name: name, + fullName: "owner/\(name)", + owner: GitHubUser( + id: 1, + login: "owner", + avatarURL: URL(string: "https://example.com/avatar.png")!, + htmlURL: URL(string: "https://github.com/owner")!, + type: "User" + ), + htmlURL: URL(string: "https://github.com/owner/\(name)")!, + description: nil, + stargazersCount: 0, + topics: topics, + language: "Swift", + updatedAt: Date(timeIntervalSince1970: 0), + pushedAt: Date(timeIntervalSince1970: 0), + defaultBranch: "main", + archived: false, + fork: false + ) + } + + private func makeFixtureKeywords() -> CategoryKeywords { + CategoryKeywords(keywordsByCategory: [ + .developerTools: ["cli"], + .productivity: ["todo"], + .education: ["study"], + ]) + } + + @Test("topics完全一致が名前・README一致より優先される") + func topicsMatchOutranksOthers() { + let classifier = CategoryClassifier(keywords: makeFixtureKeywords()) + let repository = makeRepository(name: "TodoMaster", topics: ["cli"]) + let readme = String(repeating: "x", count: 100) + " study guide" + + let result = classifier.classify(repository: repository, readme: readme) + + #expect(result.category == .developerTools) + } + + @Test("topics一致がなければリポジトリ名の部分一致が採用される") + func fallsBackToNameMatch() { + let classifier = CategoryClassifier(keywords: makeFixtureKeywords()) + let repository = makeRepository(name: "MyTodoApp", topics: []) + + let result = classifier.classify(repository: repository, readme: nil) + + #expect(result.category == .productivity) + } + + @Test("topics・名前一致がなければREADME冒頭一致が採用される") + func fallsBackToReadmePrefixMatch() { + let classifier = CategoryClassifier(keywords: makeFixtureKeywords()) + let repository = makeRepository(name: "SampleApp", topics: []) + let readme = "A great app for study sessions." + + let result = classifier.classify(repository: repository, readme: readme) + + #expect(result.category == .education) + } + + @Test("README冒頭範囲(先頭500文字)より後のキーワードは無視される") + func ignoresKeywordsBeyondReadmePrefix() { + let classifier = CategoryClassifier(keywords: makeFixtureKeywords()) + let repository = makeRepository(name: "SampleApp", topics: []) + let readme = String(repeating: "x", count: 500) + "study" + + let result = classifier.classify(repository: repository, readme: readme) + + #expect(result.category == .other) + } + + @Test("全カテゴリ0点なら other になり、subTagsに元のtopicsがそのまま入る") + func fallsBackToOtherWithSubTags() { + let classifier = CategoryClassifier(keywords: makeFixtureKeywords()) + let repository = makeRepository(name: "Unrelated", topics: ["design-tool"]) + + let result = classifier.classify(repository: repository, readme: nil) + + #expect(result.category == .other) + #expect(result.subTags == ["design-tool"]) + } + + @Test("同点スコア時はCategory.allCases宣言順で先に出現するカテゴリが採用される") + func tieBreaksByDeclarationOrder() { + let classifier = CategoryClassifier(keywords: makeFixtureKeywords()) + // "cli"(developerTools)と"todo"(productivity)がどちらもtopics完全一致で3点のタイ。 + // Category.allCasesの宣言順ではdeveloperToolsがproductivityより先。 + let repository = makeRepository(name: "SampleApp", topics: ["cli", "todo"]) + + let result = classifier.classify(repository: repository, readme: nil) + + #expect(result.category == .developerTools) + } + + @Test("READMEがnilでもクラッシュせずtopics/名前のみでスコアリングされる") + func handlesNilReadmeGracefully() { + let classifier = CategoryClassifier(keywords: makeFixtureKeywords()) + let repository = makeRepository(name: "MyTodoApp", topics: []) + + let result = classifier.classify(repository: repository, readme: nil) + + #expect(result.category == .productivity) + } + + @Test("実運用のCategoryKeywords.jsonがバンドルから正しく読み込める") + func loadsBundledKeywordsWithoutCrashing() { + let keywords = CategoryKeywords.loadBundled() + + #expect(!(keywords.keywordsByCategory[.developerTools] ?? []).isEmpty) + } +} From 942be57ded59139b4daf7c776f0088ea4aabdbbf Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Sun, 23 Aug 2026 00:30:30 +0900 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20Phase3=E3=81=AE=E3=83=81=E3=82=A7?= =?UTF-8?q?=E3=83=83=E3=82=AF=E3=83=AA=E3=82=B9=E3=83=88=E3=82=92=E5=AE=8C?= =?UTF-8?q?=E4=BA=86=E3=81=AB=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase3(NoiseFilter/Category/CategoryClassifier)の4項目を[x]化。 実装時にユーザーへ確認して確定した設計判断(subTags一律付与、 README冒頭=500文字、タイブレーク規則、プロトコル命名の動名詞スタイル) を「実装時に確定した設計判断」として追記した。 Co-Authored-By: Claude Sonnet 5 --- docs/progress.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/progress.md b/docs/progress.md index efa151c..37afbbc 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -53,16 +53,16 @@ **今回のスコープ外(次フェーズへ)**: `GitHubClient`/`AuthenticationState`を含む本格的な`AppEnvironment`へのDI配線は、引き続き「UI機能フェーズ」で行う。 -## Phase 3: ノイズ除去 + 分類ロジック — 未着手 +## Phase 3: ノイズ除去 + 分類ロジック — 完了 **判断背景**: ノイズ除去条件は当初「star数の閾値でフィルタ」する案も検討したが、「知る人ぞ知る新規の良質アプリを取りこぼしてしまい、Cairnの『発見重視』というコアバリューと相性が悪い」という理由で不採用にした。代わりに「リポジトリのtopicsタグに`macos`/`macos-app`等が含まれる」と「GitHub Releasesに`.dmg`または`.zip`資産が存在する」の2条件ANDのみを採用する。 ジャンル分類は「固定カテゴリ + キーワードマッピングのハイブリッド」方式。キーワード辞書をSwiftコード内のenumではなく`Resources/CategoryKeywords.json`として外部化するのは、「今後手動でメンテしていく前提」という要件に合わせ、リビルドなしで調整できるようにするため。スコアで一件もマッチしない場合は「その他」に分類しつつ、元のtopicsを常にサブタグとして併記する(両方採用)。これはユーザーに確認したわけではなく、実装計画側の判断——「その他」だけで大量の未分類アプリが並ぶと発見体験が悪化するため、サブタグでの絞り込み・検索を可能にする、という設計意図。 -- [ ] `NoiseFilter`実装(`requiredTopicsAny`と`validAssetExtensions`のAND条件判定。`.pkg`のみの資産は今回スコープ外、コード上にコメントで将来拡張ポイントを残す) -- [ ] `Category` enum定義(developerTools, productivity, mediaCreation, music, photography, utilities, system, games, communication, education, other) -- [ ] `Resources/CategoryKeywords.json`初期辞書作成 -- [ ] `CategoryClassifier`実装(スコアリング: topics完全一致3点 > リポジトリ名部分一致2点 > README冒頭一致1点。最高スコアのカテゴリを採用、全スコア0なら`.other`+元topicsをサブタグ併記) +- [x] `NoiseFilter`実装(`requiredTopicsAny`と`validAssetExtensions`のAND条件判定。`.pkg`のみの資産は今回スコープ外、コード上にコメントで将来拡張ポイントを残す) +- [x] `Category` enum定義(developerTools, productivity, mediaCreation, music, photography, utilities, system, games, communication, education, other) +- [x] `Resources/CategoryKeywords.json`初期辞書作成 +- [x] `CategoryClassifier`実装(スコアリング: topics完全一致3点 > リポジトリ名部分一致2点 > README冒頭一致1点。最高スコアのカテゴリを採用、全スコア0なら`.other`+元topicsをサブタグ併記) **該当するデザインパターン**: Strategy(`NoiseFilter`/`CategoryClassifier`を判定ロジックとして差し替え可能にする) @@ -70,6 +70,12 @@ - `NoiseFilterTests`: topics有無×asset有無の4象限を検証 - `CategoryClassifierTests`: topics/名前/README一致の優先順位、フォールバック"other"+サブタグ併記を検証 +**実装時に確定した設計判断(要件に数値・範囲の明記がなかった点)**: +- `subTags`は`.other`に限定せず、全カテゴリで一律`repository.topics`を併記する(分類済みアプリでもtopicsベースの絞り込み・検索を可能にするため。ユーザー確認済み) +- README「冒頭」の範囲は先頭500文字と定義(タイトル+概要段落程度をカバーしつつ詳細セクションには踏み込まない目安。ユーザー確認済み) +- スコア同点時のタイブレークは`Category.allCases`の宣言順で最初に最高得点になったカテゴリを採用する決定的ルールとした(`Dictionary`のキー順は不定なため必須の設計) +- Strategyパターンのプロトコル命名は動名詞スタイル(`NoiseFiltering`/`CategoryClassifying`)を採用(ユーザー確認済み。既存コードには名詞+Protocol派もあるため今後混在に注意) + ## Phase 4: 検索アーキテクチャ — 未着手 **判断背景(最重要・当初計画からの転換点)**: 当初は「未認証のGitHub Search APIのみでスタートし、トークンバケット式レートリミッター+バックグラウンドのローリング更新+同梱シードデータでコールドスタートに対応する」という設計まで一度確定していた(未認証だとSearch 10req/min・Core 60req/hしか使えないため、キャッシュ優先の妥協的な設計が前提だった)。しかしPlan承認の直前になって「認証すればレート制限の枠が広がるなら、普段からAPI検索してその結果をキャッシュに反映すればいいのでは」という方針転換があり、GitHub OAuth Device Flow認証を最初のスコープに含めることが決定した(Phase 1として実装済み。Search 30req/min・Core 5000req/hに拡大)。この転換によって検索方式も「キャッシュ内のみを検索」から「入力に応じてAPI即応検索し、結果をキャッシュへ反映する(stale-while-revalidate)」に変わった。