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 Sources/Cairn/Classification/Category.swift
Original file line number Diff line number Diff line change
@@ -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
}
54 changes: 54 additions & 0 deletions Sources/Cairn/Classification/CategoryClassifier.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
31 changes: 31 additions & 0 deletions Sources/Cairn/Classification/CategoryKeywords.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
40 changes: 40 additions & 0 deletions Sources/Cairn/Classification/NoiseFilter.swift
Original file line number Diff line number Diff line change
@@ -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<String>
let validAssetExtensions: Set<String>

init(
requiredTopicsAny: Set<String> = ["macos", "macos-app"],
// .pkgのみの資産は今回スコープ外(Phase7のInstallerが.pkgに未対応のため)。
// 将来Installer側が対応したらここに"pkg"を追加する。
validAssetExtensions: Set<String> = ["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())
}
}
}
}
12 changes: 12 additions & 0 deletions Sources/Cairn/Resources/CategoryKeywords.json
Original file line number Diff line number Diff line change
@@ -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"]
}
126 changes: 126 additions & 0 deletions Tests/CairnTests/Classification/CategoryClassifierTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading