refactor(subtitles): inject default rules into SubtitleRuleSet.resolve()

aggregates.py used to call SubtitleKnowledgeBase().default_rules() via a
DEFAULT_RULES() helper, which silently pulled the infrastructure layer
(YAML loader) into the domain on every resolve.

Make the dependency explicit: resolve() now takes the default rules as
a parameter, and the caller (the ManageSubtitles use case) loads them
from the KB once and passes them in. Domain stays I/O-free.

- Drop DEFAULT_RULES helper and the SubtitleKnowledgeBase import from
  alfred/domain/subtitles/aggregates.py
- SubtitleRuleSet.resolve(default_rules: SubtitleMatchingRules)
- manage_subtitles use case passes kb.default_rules() at the call site
- Tests use a local SubtitleMatchingRules stand-in instead of relying
  on KB defaults
This commit is contained in:
2026-05-19 15:10:06 +02:00
parent 903e9e7117
commit 6e252d1e81
5 changed files with 50 additions and 30 deletions
@@ -163,7 +163,7 @@ class ManageSubtitlesUseCase:
subtitle_prefs = memory.ltm.subtitle_preferences subtitle_prefs = memory.ltm.subtitle_preferences
except Exception: except Exception:
pass pass
rules = repo.load(release_group, subtitle_prefs).resolve() rules = repo.load(release_group, subtitle_prefs).resolve(kb.default_rules())
matcher = SubtitleMatcher() matcher = SubtitleMatcher()
matched, unresolved = matcher.match(metadata.external_tracks, rules) matched, unresolved = matcher.match(metadata.external_tracks, rules)
+9 -10
View File
@@ -3,17 +3,10 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
from alfred.infrastructure.knowledge.subtitles.base import SubtitleKnowledgeBase
from ..shared.value_objects import ImdbId from ..shared.value_objects import ImdbId
from .value_objects import RuleScope, SubtitleMatchingRules from .value_objects import RuleScope, SubtitleMatchingRules
def DEFAULT_RULES() -> SubtitleMatchingRules:
"""Load default matching rules from subtitles.yaml (defaults section)."""
return SubtitleKnowledgeBase().default_rules()
@dataclass @dataclass
class SubtitleRuleSet: class SubtitleRuleSet:
""" """
@@ -37,12 +30,18 @@ class SubtitleRuleSet:
_format_priority: list[str] | None = field(default=None, repr=False) _format_priority: list[str] | None = field(default=None, repr=False)
_min_confidence: float | None = field(default=None, repr=False) _min_confidence: float | None = field(default=None, repr=False)
def resolve(self) -> SubtitleMatchingRules: def resolve(self, default_rules: SubtitleMatchingRules) -> SubtitleMatchingRules:
""" """
Walk the parent chain and merge deltas into effective rules. Walk the parent chain and merge deltas into effective rules.
Falls back to DEFAULT_RULES at the top of the chain.
``default_rules`` seeds the top of the chain — it is the caller's
responsibility to load these from the knowledge base (infrastructure).
Keeping the default rules as a parameter keeps the domain free of
any I/O dependency.
""" """
base = self.parent.resolve() if self.parent else DEFAULT_RULES() base = (
self.parent.resolve(default_rules) if self.parent else default_rules
)
return SubtitleMatchingRules( return SubtitleMatchingRules(
preferred_languages=self._languages or base.preferred_languages, preferred_languages=self._languages or base.preferred_languages,
preferred_formats=self._formats or base.preferred_formats, preferred_formats=self._formats or base.preferred_formats,
@@ -54,7 +54,7 @@ class RuleSetRepository:
Build and return the resolved RuleSet chain. Build and return the resolved RuleSet chain.
If subtitle_preferences is provided, it seeds the global base rule set If subtitle_preferences is provided, it seeds the global base rule set
from LTM (overriding the hardcoded DEFAULT_RULES). from LTM (overriding the knowledge-base defaults at resolve time).
Returns global default if no overrides exist. Returns global default if no overrides exist.
""" """
base = SubtitleRuleSet.global_default() base = SubtitleRuleSet.global_default()
+19 -10
View File
@@ -30,9 +30,19 @@ from alfred.domain.subtitles.value_objects import (
RuleScope, RuleScope,
SubtitleFormat, SubtitleFormat,
SubtitleLanguage, SubtitleLanguage,
SubtitleMatchingRules,
SubtitleType, SubtitleType,
) )
# Test fixture: stand-in for what the KB would provide at runtime.
_DEFAULT_RULES = SubtitleMatchingRules(
preferred_languages=["eng"],
preferred_formats=["srt"],
allowed_types=["standard"],
format_priority=["srt", "ass"],
min_confidence=0.7,
)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Value objects # # Value objects #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -230,18 +240,17 @@ class TestAvailableSubtitles:
class TestSubtitleRuleSet: class TestSubtitleRuleSet:
def test_global_default_uses_kb_defaults(self): def test_global_default_returns_injected_defaults(self):
rs = SubtitleRuleSet.global_default() rs = SubtitleRuleSet.global_default()
rules = rs.resolve() rules = rs.resolve(_DEFAULT_RULES)
# Loaded from subtitles.yaml — defaults must be non-empty. assert rules.preferred_languages == _DEFAULT_RULES.preferred_languages
assert rules.preferred_languages assert rules.preferred_formats == _DEFAULT_RULES.preferred_formats
assert rules.preferred_formats assert rules.min_confidence == _DEFAULT_RULES.min_confidence
assert 0 < rules.min_confidence <= 1
def test_override_persists(self): def test_override_persists(self):
rs = SubtitleRuleSet.global_default() rs = SubtitleRuleSet.global_default()
rs.override(languages=["eng"], min_confidence=0.9) rs.override(languages=["eng"], min_confidence=0.9)
rules = rs.resolve() rules = rs.resolve(_DEFAULT_RULES)
assert rules.preferred_languages == ["eng"] assert rules.preferred_languages == ["eng"]
assert rules.min_confidence == 0.9 assert rules.min_confidence == 0.9
@@ -252,10 +261,10 @@ class TestSubtitleRuleSet:
parent=parent, parent=parent,
) )
child.override(languages=["jpn"]) child.override(languages=["jpn"])
rules = child.resolve() rules = child.resolve(_DEFAULT_RULES)
assert rules.preferred_languages == ["jpn"] assert rules.preferred_languages == ["jpn"]
# min_confidence not overridden at child or parent → falls back to defaults # min_confidence not overridden at child or parent → falls back to defaults
assert rules.min_confidence == parent.resolve().min_confidence assert rules.min_confidence == parent.resolve(_DEFAULT_RULES).min_confidence
def test_to_dict_only_emits_set_deltas(self): def test_to_dict_only_emits_set_deltas(self):
rs = SubtitleRuleSet(scope=RuleScope(level="show", identifier="tt1")) rs = SubtitleRuleSet(scope=RuleScope(level="show", identifier="tt1"))
@@ -286,4 +295,4 @@ class TestSubtitleRuleSet:
# code uses `is not None` explicitly. Verify 0.0 doesn't fall back. # code uses `is not None` explicitly. Verify 0.0 doesn't fall back.
rs = SubtitleRuleSet.global_default() rs = SubtitleRuleSet.global_default()
rs.override(min_confidence=0.0) rs.override(min_confidence=0.0)
assert rs.resolve().min_confidence == 0.0 assert rs.resolve(_DEFAULT_RULES).min_confidence == 0.0
+20 -8
View File
@@ -17,6 +17,7 @@ from pathlib import Path
import yaml import yaml
from alfred.domain.subtitles.value_objects import SubtitleMatchingRules
from alfred.infrastructure.persistence.memory.ltm.components.subtitle_preferences import ( from alfred.infrastructure.persistence.memory.ltm.components.subtitle_preferences import (
SubtitlePreferences, SubtitlePreferences,
) )
@@ -25,6 +26,15 @@ from alfred.infrastructure.subtitle.rule_repository import (
_filter_override, _filter_override,
) )
# Stand-in for KB defaults, injected at resolve().
_DEFAULT_RULES = SubtitleMatchingRules(
preferred_languages=["eng"],
preferred_formats=["srt"],
allowed_types=["standard"],
format_priority=["srt", "ass"],
min_confidence=0.7,
)
def _write(path: Path, data: dict) -> None: def _write(path: Path, data: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
@@ -71,17 +81,17 @@ class TestLoad:
def test_no_files_returns_global_default(self, tmp_path): def test_no_files_returns_global_default(self, tmp_path):
repo = RuleSetRepository(tmp_path) repo = RuleSetRepository(tmp_path)
rs = repo.load() rs = repo.load()
# Should resolve cleanly using the hardcoded defaults. # With no overrides, resolve returns the injected defaults unchanged.
rules = rs.resolve() rules = rs.resolve(_DEFAULT_RULES)
assert rules.preferred_languages # non-empty assert rules.preferred_languages == _DEFAULT_RULES.preferred_languages
assert rules.min_confidence > 0 assert rules.min_confidence == _DEFAULT_RULES.min_confidence
def test_subtitle_preferences_override_base(self, tmp_path): def test_subtitle_preferences_override_base(self, tmp_path):
prefs = SubtitlePreferences( prefs = SubtitlePreferences(
languages=["jpn"], formats=["ass"], types=["standard"] languages=["jpn"], formats=["ass"], types=["standard"]
) )
repo = RuleSetRepository(tmp_path) repo = RuleSetRepository(tmp_path)
rules = repo.load(subtitle_preferences=prefs).resolve() rules = repo.load(subtitle_preferences=prefs).resolve(_DEFAULT_RULES)
assert rules.preferred_languages == ["jpn"] assert rules.preferred_languages == ["jpn"]
assert rules.preferred_formats == ["ass"] assert rules.preferred_formats == ["ass"]
assert rules.allowed_types == ["standard"] assert rules.allowed_types == ["standard"]
@@ -92,7 +102,7 @@ class TestLoad:
{"override": {"languages": ["spa"], "min_confidence": 0.95}}, {"override": {"languages": ["spa"], "min_confidence": 0.95}},
) )
repo = RuleSetRepository(tmp_path) repo = RuleSetRepository(tmp_path)
rules = repo.load().resolve() rules = repo.load().resolve(_DEFAULT_RULES)
assert rules.preferred_languages == ["spa"] assert rules.preferred_languages == ["spa"]
assert rules.min_confidence == 0.95 assert rules.min_confidence == 0.95
@@ -102,7 +112,7 @@ class TestLoad:
{"override": {"format_priority": ["ass", "srt"]}}, {"override": {"format_priority": ["ass", "srt"]}},
) )
repo = RuleSetRepository(tmp_path) repo = RuleSetRepository(tmp_path)
rules = repo.load(release_group="KONTRAST").resolve() rules = repo.load(release_group="KONTRAST").resolve(_DEFAULT_RULES)
assert rules.format_priority == ["ass", "srt"] assert rules.format_priority == ["ass", "srt"]
def test_full_three_level_chain(self, tmp_path): def test_full_three_level_chain(self, tmp_path):
@@ -119,7 +129,9 @@ class TestLoad:
{"override": {"min_confidence": 0.99}}, {"override": {"min_confidence": 0.99}},
) )
repo = RuleSetRepository(tmp_path) repo = RuleSetRepository(tmp_path)
rules = repo.load(release_group="GRP", subtitle_preferences=prefs).resolve() rules = repo.load(release_group="GRP", subtitle_preferences=prefs).resolve(
_DEFAULT_RULES
)
# All three levels visible — local overrides on top # All three levels visible — local overrides on top
assert rules.preferred_languages == ["jpn"] assert rules.preferred_languages == ["jpn"]
assert rules.format_priority == ["ass"] assert rules.format_priority == ["ass"]