Skip to content
Draft
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
39 changes: 38 additions & 1 deletion src/arjev/config.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
"""Config: arjev.toml (tomllib reads; the static template writer lives in init.py).
The default field mapping is the Harmoniqs paper-note schema; every mapped name is
remappable so any Obsidian/Zotero vault works (spec: Paper-note schema v2)."""
remappable so any Obsidian/Zotero vault works (spec: Paper-note schema v2).

Per-root field mapping (harmoniqs/arJev#97): a [[vault.root_configs]] table array
lets each root carry its own field slots and an optional rating value map. A root
without an entry inherits the global [vault.fields] mapping unchanged.
"""

from __future__ import annotations

Expand All @@ -19,6 +24,20 @@
}


@dataclass
class RootConfig:
"""Per-root field mapping and optional rating value map (issue #97).

`fields` mirrors the global mapping slots (identity, read_date, rating, why,
tags, status); absent slots inherit from the global mapping at fold time.
`rating_values` maps source enum values to the canonical RATING_ENUM set
(e.g. {"high": "core", "medium": "useful", "low": "marginal"}).
"""

fields: dict[str, Any] = field(default_factory=dict)
rating_values: dict[str, str] = field(default_factory=dict)


def _expand(p: str) -> str:

return str(Path(p).expanduser()) if p.startswith("~") else p
Expand All @@ -30,6 +49,9 @@ class Config:
include: str = "**/*.md"
discriminator_type: str = "paper"
fields: dict[str, Any] = field(default_factory=lambda: dict(DEFAULT_FIELDS))
# Per-root field mapping (issue #97): keyed by the expanded root path string.
# A root absent from this dict inherits the global `fields` mapping unchanged.
root_configs: dict[str, RootConfig] = field(default_factory=dict)
half_life_days: float = 180.0
# field-neutral: no category is ever defaulted — a missing feeds list is a
# config error the digest names plainly (issue #53)
Expand Down Expand Up @@ -109,6 +131,21 @@ def load_config(path: Path | None = None) -> Config:
cfg.candidate_pace_s = float(jev.get("candidate_pace_s", cfg.candidate_pace_s))
cfg.directives_path = raw.get("directives_path") or vault.get("directives_path")
cfg.enrich_command = raw.get("enrich_command") or vault.get("enrich_command")
# Per-root field mapping (issue #97): [[vault.root_configs]] array of tables.
# Each entry must carry a `path` key; `fields` and `rating_values` are optional.
for rc_raw in vault.get("root_configs", []):
rc_path = rc_raw.get("path")
if not rc_path:
continue
rc_path = _expand(str(rc_path))
rc_fields_raw = rc_raw.get("fields", {})
rc_fields: dict[str, Any] = {}
if rc_fields_raw:
rc_fields = dict(rc_fields_raw)
if "identity" in rc_fields and isinstance(rc_fields["identity"], str):
rc_fields["identity"] = [rc_fields["identity"]]
rc_rating_values = {str(k): str(v) for k, v in rc_raw.get("rating_values", {}).items()}
cfg.root_configs[rc_path] = RootConfig(fields=rc_fields, rating_values=rc_rating_values)
slack = raw.get("slack", {})
cfg.slack_channel = slack.get("channel")
cfg.slack_keepers = list(slack.get("keepers", []))
Expand Down
141 changes: 129 additions & 12 deletions src/arjev/fold.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
- wikilink signal: a paper note's term weights scale by (1 + 0.5 × min(3, inlinks))
where an inlink is a wikilink from a NON-PAPER note (no mapped identity) (#16).
- "blank" means ABSENT: a rating outside the closed enum is dropped with a warning.
- per-root field mapping (issue #97): each root may carry its own field slots via
cfg.root_configs; a root without an entry inherits the global cfg.fields mapping.
A rating_values map in the RootConfig remaps source enum values to canonical ones.
A sentence-valued rating/why field (when rating and why map to the same frontmatter
key) is consumed as the why-line instead of triggering an 'outside enum' warning.
"""

from __future__ import annotations
Expand Down Expand Up @@ -117,16 +122,119 @@ def _date_from_mtime(path: Path) -> date | None:
return None


def _effective_fields(cfg, root: Path) -> dict:
"""Resolve the effective field mapping for a root (issue #97).
The root's own field overrides take precedence over the global mapping;
a root with no RootConfig entry inherits the global mapping unchanged."""
root_configs = getattr(cfg, "root_configs", {}) or {}
rc = root_configs.get(str(root))
if rc is None or not rc.fields:
return cfg.fields
# Merge: start from global, override with per-root entries.
merged = dict(cfg.fields)
merged.update(rc.fields)
if isinstance(merged.get("identity"), str):
merged["identity"] = [merged["identity"]]
return merged


def _rating_values(cfg, root: Path) -> dict[str, str]:
"""Rating value map for this root (issue #97), or empty dict for no remapping."""
root_configs = getattr(cfg, "root_configs", {}) or {}
rc = root_configs.get(str(root))
if rc is None:
return {}
return rc.rating_values or {}


def _build_paper(
fm: dict, body: str, path: Path, identity: str, arxiv: str | None, cfg, warnings: list[str]
fm: dict,
body: str,
path: Path,
identity: str,
arxiv: str | None,
cfg,
warnings: list[str],
*,
fields: dict | None = None,
rating_values: dict[str, str] | None = None,
) -> PaperNote:
fields = cfg.fields
rating = fm.get(fields["rating"])
rating = rating.strip() if isinstance(rating, str) else None
why = fm.get(fields["why"])
why = why.strip() if isinstance(why, str) and why.strip() else None
# back-compat: the literal legacy `relevance` field (only when not itself remapped)
if fields["rating"] != "relevance" and fields["why"] != "relevance":
"""Build a PaperNote from parsed frontmatter.

`fields` and `rating_values` are the effective per-root overrides (issue #97);
when absent they fall back to cfg.fields with no value remapping (back-compat).
"""
if fields is None:
fields = cfg.fields
if rating_values is None:
rating_values = {}

rating_field = fields["rating"]
why_field = fields["why"]
same_field = (rating_field == why_field) # e.g. both map to "relevance"

raw_rating_val = fm.get(rating_field)
raw_rating = raw_rating_val.strip() if isinstance(raw_rating_val, str) else None

raw_why_val = fm.get(why_field)
raw_why = raw_why_val.strip() if isinstance(raw_why_val, str) and raw_why_val.strip() else None

rating: str | None = None
why: str | None = None

if same_field:
# Rating and why come from the same frontmatter key.
# Precedence: canonical enum > rating_values remap > sentence → why.
# An empty string is invalid (blank = absent), not a sentence.
val = raw_rating # they point to the same key
if val is not None:
if not val:
# empty string: outside enum, warn and drop
warnings.append(f"{path.name}: rating '' outside enum — dropped")
elif val in RATING_ENUM:
rating = val
elif val in rating_values:
remapped = rating_values[val]
if remapped in RATING_ENUM:
rating = remapped
else:
# misconfigured value map — warn and drop
warnings.append(
f"{path.name}: rating_values maps {val!r} → {remapped!r} which is outside enum — dropped"
)
else:
# sentence-valued: treat as why-line (no 'outside enum' warning)
why = val
else:
# Separate rating and why fields.
if raw_rating is not None:
if not raw_rating:
# empty string: outside enum, warn and drop
warnings.append(f"{path.name}: rating '' outside enum — dropped")
elif raw_rating in RATING_ENUM:
rating = raw_rating
elif raw_rating in rating_values:
remapped = rating_values[raw_rating]
if remapped in RATING_ENUM:
rating = remapped
else:
warnings.append(
f"{path.name}: rating_values maps {raw_rating!r} → {remapped!r} which is outside enum — dropped"
)
else:
# sentence-valued rating field with separate why field: treat as why if why is absent
if raw_why is None:
why = raw_rating
else:
# Both are present and rating isn't an enum value — drop rating with warning
warnings.append(f"{path.name}: rating {raw_rating!r} outside enum — dropped")

if raw_why is not None and why is None:
why = raw_why

# back-compat: the literal legacy `relevance` field (only when not itself remapped by the
# active fields — if rating or why already point to "relevance", the per-root mapping handles it)
if rating_field != "relevance" and why_field != "relevance":
legacy = fm.get("relevance")
if isinstance(legacy, str) and legacy.strip():
legacy = legacy.strip()
Expand All @@ -135,10 +243,12 @@ def _build_paper(
elif why is None:
why = legacy
else:
warnings.append(f"{path.name}: prose `relevance` ignored — explicit `{fields['why']}` wins")
warnings.append(f"{path.name}: prose `relevance` ignored — explicit `{why_field}` wins")

if rating is not None and rating not in RATING_ENUM:
warnings.append(f"{path.name}: rating {rating!r} outside enum — dropped")
rating = None

read_date = _as_date(fm.get(fields["read_date"])) or _as_date(fm.get("date")) or _date_from_mtime(path)
status = fm.get(fields["status"])
status = status.strip().lower() if isinstance(status, str) else "distilled"
Expand All @@ -162,13 +272,17 @@ def _build_paper(


def fold_roots(roots: list[Path], cfg) -> FoldResult:
"""Fold every configured root into one result. Read-only; missing roots warn."""
"""Fold every configured root into one result. Read-only; missing roots warn.
Each root uses its own effective field mapping (issue #97): a root without a
per-root entry in cfg.root_configs inherits the global cfg.fields unchanged."""
result = FoldResult()
nonpaper_bodies: list[str] = []
for root in roots:
if not root.is_dir():
result.warnings.append(f"vault root missing: {root}")
continue
root_fields = _effective_fields(cfg, root)
root_rating_values = _rating_values(cfg, root)
for path in sorted(root.glob(cfg.include)):
if not path.is_file() or path.suffix != ".md":
continue
Expand All @@ -181,11 +295,14 @@ def fold_roots(roots: list[Path], cfg) -> FoldResult:
fm, body = fm_body
if isinstance(fm.get("type"), str) and fm["type"].strip() == cfg.discriminator_type:
result.type_matched += 1
identity, arxiv = _identity(fm, cfg.fields)
identity, arxiv = _identity(fm, root_fields)
if identity is None:
nonpaper_bodies.append(body)
continue
result.papers.append(_build_paper(fm, body, path, identity, arxiv, cfg, result.warnings))
result.papers.append(_build_paper(
fm, body, path, identity, arxiv, cfg, result.warnings,
fields=root_fields, rating_values=root_rating_values,
))
_apply_wikilink_graph(result, nonpaper_bodies)
seen: set[str] = set()
deduped: list[PaperNote] = []
Expand Down
26 changes: 23 additions & 3 deletions src/arjev/inspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def run_inspect(cfg: Config, today: date | None = None) -> InspectReport:
profile = build_profile(fold, cfg, today, directives=directives)
report = InspectReport(
lines=_checklist_lines(cfg)
+ _fold_lines(fold, profile, len(staged_worklist(fold)), directives)
+ _fold_lines(fold, profile, len(staged_worklist(fold)), directives, cfg=cfg)
)
if not cfg.feeds:
report.blocked.append(BLOCK_NO_FEEDS)
Expand Down Expand Up @@ -88,9 +88,17 @@ def _checklist_lines(cfg: Config) -> list[str]:
]


def _fold_lines(fold: FoldResult, profile: Profile, staged: int, directives: Directives) -> list[str]:
def _fold_lines(
fold: FoldResult, profile: Profile, staged: int, directives: Directives, cfg: Config | None = None
) -> list[str]:
"""The fold's view: the scan funnel, the profile it yields, and the fold's
warnings verbatim — `warning: <text>`, one per line, never reworded."""
warnings verbatim — `warning: <text>`, one per line, never reworded.

When the config carries per-root field mappings (issue #97) one additional
line per configured root_config is emitted:
fold root-mapping: <path> (custom: rating=<field>[, rating_values=<n>])
Roots that inherit the global mapping produce no root-mapping line.
"""
lines = [
f"fold scanned: {fold.scanned}",
f"fold type-matched: {fold.type_matched}",
Expand All @@ -103,5 +111,17 @@ def _fold_lines(fold: FoldResult, profile: Profile, staged: int, directives: Dir
lines.append(f"fold directives: loaded ({len(directives.all_terms())} terms)")
else:
lines.append("fold directives: none")
# Per-root mapping state (issue #97): one line per root with a custom mapping.
if cfg is not None:
root_configs = getattr(cfg, "root_configs", {}) or {}
for root_path, rc in sorted(root_configs.items()):
parts = []
rf = rc.fields or {}
if rf.get("rating"):
parts.append(f"rating={rf['rating']}")
if rc.rating_values:
parts.append(f"rating_values={len(rc.rating_values)}")
detail = ", ".join(parts) if parts else "custom"
lines.append(f"fold root-mapping: {root_path} ({detail})")
lines.append(f"fold warnings: {len(fold.warnings) if fold.warnings else 'none'}")
return lines + [f"warning: {w}" for w in fold.warnings]
Loading
Loading