Skip to content

Commit 569771a

Browse files
authored
Extract Rule Cache Catalog (#23)
1 parent 8130fb7 commit 569771a

4 files changed

Lines changed: 274 additions & 103 deletions

File tree

CONTEXT.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,15 @@ is only the adapter for request validation, dependency handoff, HTTP exception m
100100
serialization.
101101
_Avoid_: rules route, rules engine route, rule API implementation.
102102

103+
**Rule Cache Catalog**:
104+
The read-only Pathfinder module behavior behind cached ruling browse operations: listing rulings
105+
for a topic, recent ruling history across topics, topic activity summaries, malformed-cache skip
106+
policy, and cache ordering by `last_reused_at`. The code module lives at
107+
`modules/pathfinder/app/rule_cache_catalog.py`. The HTTP routes in
108+
`modules/pathfinder/app/routes/rule.py` only validate request shape, enforce initialization, and
109+
serialize the catalog result.
110+
_Avoid_: rule list route logic, cached ruling helper, rule history adapter.
111+
103112
**PF Archive Import Plan**:
104113
The side-effect-free Pathfinder module behavior that turns an on-disk archive root into planned
105114
Pathfinder Vault writes: markdown walk, known NPC slug discovery, route decisions, large-import
@@ -200,6 +209,7 @@ Adapters should do only translation/auth/delegation.
200209
- PF2e Foundry NeDB chat import: `modules/pathfinder/app/foundry_chat_import.py`
201210
- PF2e Rule Query: `modules/pathfinder/app/rule_query.py`
202211
- Pathfinder Player Interaction: `modules/pathfinder/app/player_interaction_orchestrator.py`
212+
- Rule Cache Catalog: `modules/pathfinder/app/rule_cache_catalog.py`
203213

204214
### Authoritative flows
205215
- Message flow:

modules/pathfinder/app/routes/rule.py

Lines changed: 4 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
"""
1717
from __future__ import annotations
1818

19-
import logging
2019
import re
2120

2221
from fastapi import APIRouter, HTTPException
@@ -31,6 +30,7 @@
3130
generate_ruling_from_passages,
3231
)
3332
from app.resolve_model import resolve
33+
from app.rule_cache_catalog import RuleCacheCatalog
3434
from app.rule_query import (
3535
RuleQueryCompositionError,
3636
RuleQueryDependencies,
@@ -40,15 +40,10 @@
4040
)
4141
from app.rules import (
4242
MAX_QUERY_CHARS,
43-
RULING_CACHE_PATH_PREFIX,
4443
RulesIndex,
45-
_parse_ruling_cache,
46-
coerce_topic,
4744
keyword_classify_topic,
4845
)
4946

50-
logger = logging.getLogger(__name__)
51-
5247
router = APIRouter(prefix="/rule", tags=["rule"])
5348

5449
# Module-level singletons — set by main.py lifespan, patchable in tests.
@@ -204,58 +199,14 @@ async def rule_query(req: RuleQueryRequest) -> JSONResponse:
204199
# --- Enumeration endpoints — no LLM, no embedding, pure Obsidian directory walks ---
205200

206201

207-
def _build_ruling_index_entry(path: str, parsed: dict, topic: str | None = None) -> dict:
208-
"""Extract the summary fields a UI / bot would list from a cached ruling."""
209-
# hash is the last path component without .md.
210-
hash_part = path.rsplit("/", 1)[-1].removesuffix(".md")
211-
return {
212-
"hash": hash_part,
213-
"topic": topic or parsed.get("topic"),
214-
"question": parsed.get("question", ""),
215-
"composed_at": parsed.get("composed_at", ""),
216-
"last_reused_at": parsed.get("last_reused_at", parsed.get("composed_at", "")),
217-
"marker": parsed.get("marker", ""),
218-
"source": parsed.get("source"),
219-
}
220-
221-
222-
async def _collect_rulings_under(prefix: str) -> list[tuple[str, dict]]:
223-
"""Walk a topic-prefix (or root rulings prefix), return list of (path, parsed_frontmatter)."""
224-
if obsidian is None:
225-
return []
226-
try:
227-
paths = await obsidian.list_directory(prefix)
228-
except Exception as exc:
229-
logger.warning("_collect_rulings_under: list_directory %s failed: %s", prefix, exc)
230-
return []
231-
out: list[tuple[str, dict]] = []
232-
for p in paths:
233-
if not p.endswith(".md"):
234-
continue
235-
text = await obsidian.get_note(p)
236-
if not text:
237-
continue
238-
parsed = _parse_ruling_cache(text)
239-
if parsed is None:
240-
logger.warning("_collect_rulings_under: malformed cache at %s — skipping", p)
241-
continue
242-
out.append((p, parsed))
243-
return out
244-
245-
246202
@router.post("/show")
247203
async def rule_show(req: RuleShowRequest) -> JSONResponse:
248204
"""List rulings under a given topic folder. Sorted by last_reused_at desc."""
249205
if obsidian is None:
250206
raise HTTPException(
251207
status_code=503, detail={"error": "rule subsystem not initialised"}
252208
)
253-
topic = coerce_topic(req.topic)
254-
prefix = f"{RULING_CACHE_PATH_PREFIX}/{topic}/"
255-
collected = await _collect_rulings_under(prefix)
256-
entries = [_build_ruling_index_entry(p, parsed, topic=topic) for p, parsed in collected]
257-
entries.sort(key=lambda e: e.get("last_reused_at", ""), reverse=True)
258-
return JSONResponse({"topic": topic, "count": len(entries), "rulings": entries})
209+
return JSONResponse(await RuleCacheCatalog(obsidian).show_topic(req.topic))
259210

260211

261212
@router.post("/history")
@@ -265,29 +216,7 @@ async def rule_history(req: RuleHistoryRequest) -> JSONResponse:
265216
raise HTTPException(
266217
status_code=503, detail={"error": "rule subsystem not initialised"}
267218
)
268-
n = req.n # already clamped to [1, 100] by field_validator
269-
root_prefix = f"{RULING_CACHE_PATH_PREFIX}/"
270-
try:
271-
all_paths = await obsidian.list_directory(root_prefix)
272-
except Exception as exc:
273-
logger.warning("rule_history: root list_directory failed: %s", exc)
274-
all_paths = []
275-
all_entries: list[dict] = []
276-
for p in all_paths:
277-
if not p.endswith(".md"):
278-
continue
279-
text = await obsidian.get_note(p)
280-
if not text:
281-
continue
282-
parsed = _parse_ruling_cache(text)
283-
if parsed is None:
284-
continue
285-
# topic = path segment between the root prefix and the final filename.
286-
stripped = p.removeprefix(root_prefix)
287-
topic = stripped.split("/", 1)[0] if "/" in stripped else "misc"
288-
all_entries.append(_build_ruling_index_entry(p, parsed, topic=topic))
289-
all_entries.sort(key=lambda e: e.get("last_reused_at", ""), reverse=True)
290-
return JSONResponse({"n": n, "rulings": all_entries[:n]})
219+
return JSONResponse(await RuleCacheCatalog(obsidian).history(req.n))
291220

292221

293222
@router.post("/list")
@@ -297,32 +226,4 @@ async def rule_list() -> JSONResponse:
297226
raise HTTPException(
298227
status_code=503, detail={"error": "rule subsystem not initialised"}
299228
)
300-
root_prefix = f"{RULING_CACHE_PATH_PREFIX}/"
301-
try:
302-
all_paths = await obsidian.list_directory(root_prefix)
303-
except Exception as exc:
304-
logger.warning("rule_list: root list_directory failed: %s", exc)
305-
all_paths = []
306-
# Group by topic slug (first segment after root_prefix).
307-
per_topic: dict[str, dict] = {}
308-
for p in all_paths:
309-
if not p.endswith(".md"):
310-
continue
311-
stripped = p.removeprefix(root_prefix)
312-
if "/" not in stripped:
313-
continue
314-
topic = stripped.split("/", 1)[0]
315-
text = await obsidian.get_note(p)
316-
if not text:
317-
continue
318-
parsed = _parse_ruling_cache(text)
319-
if parsed is None:
320-
continue
321-
bucket = per_topic.setdefault(topic, {"slug": topic, "count": 0, "last_activity": ""})
322-
bucket["count"] += 1
323-
last_act = parsed.get("last_reused_at", parsed.get("composed_at", ""))
324-
if last_act > bucket["last_activity"]:
325-
bucket["last_activity"] = last_act
326-
topics = list(per_topic.values())
327-
topics.sort(key=lambda t: t.get("last_activity", ""), reverse=True)
328-
return JSONResponse({"topics": topics})
229+
return JSONResponse(await RuleCacheCatalog(obsidian).topics())
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""Cached Pathfinder ruling catalog."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from typing import Any
7+
8+
from app.rules import RULING_CACHE_PATH_PREFIX, _parse_ruling_cache, coerce_topic
9+
10+
logger = logging.getLogger(__name__)
11+
12+
13+
class RuleCacheCatalog:
14+
"""Read-only catalog for cached Pathfinder rulings."""
15+
16+
def __init__(self, obsidian: Any) -> None:
17+
self._obsidian = obsidian
18+
19+
async def show_topic(self, topic: str) -> dict:
20+
"""List rulings under one topic, newest reuse first."""
21+
coerced = coerce_topic(topic)
22+
prefix = f"{RULING_CACHE_PATH_PREFIX}/{coerced}/"
23+
collected = await self._collect_rulings_under(prefix)
24+
entries = [
25+
self._build_ruling_index_entry(path, parsed, topic=coerced)
26+
for path, parsed in collected
27+
]
28+
entries.sort(key=lambda entry: entry.get("last_reused_at", ""), reverse=True)
29+
return {"topic": coerced, "count": len(entries), "rulings": entries}
30+
31+
async def history(self, n: int) -> dict:
32+
"""Return the N most recent rulings across all topics."""
33+
root_prefix = f"{RULING_CACHE_PATH_PREFIX}/"
34+
entries: list[dict] = []
35+
for path, parsed in await self._collect_rulings_under(root_prefix):
36+
stripped = path.removeprefix(root_prefix)
37+
topic = stripped.split("/", 1)[0] if "/" in stripped else "misc"
38+
entries.append(self._build_ruling_index_entry(path, parsed, topic=topic))
39+
entries.sort(key=lambda entry: entry.get("last_reused_at", ""), reverse=True)
40+
return {"n": n, "rulings": entries[:n]}
41+
42+
async def topics(self) -> dict:
43+
"""Enumerate topic folders and their cache activity."""
44+
root_prefix = f"{RULING_CACHE_PATH_PREFIX}/"
45+
per_topic: dict[str, dict] = {}
46+
for path, parsed in await self._collect_rulings_under(root_prefix):
47+
stripped = path.removeprefix(root_prefix)
48+
if "/" not in stripped:
49+
continue
50+
topic = stripped.split("/", 1)[0]
51+
bucket = per_topic.setdefault(
52+
topic, {"slug": topic, "count": 0, "last_activity": ""}
53+
)
54+
bucket["count"] += 1
55+
last_activity = parsed.get("last_reused_at", parsed.get("composed_at", ""))
56+
if last_activity > bucket["last_activity"]:
57+
bucket["last_activity"] = last_activity
58+
topics = list(per_topic.values())
59+
topics.sort(key=lambda item: item.get("last_activity", ""), reverse=True)
60+
return {"topics": topics}
61+
62+
async def _collect_rulings_under(self, prefix: str) -> list[tuple[str, dict]]:
63+
"""Walk a ruling prefix and return parsed cache notes."""
64+
try:
65+
paths = await self._obsidian.list_directory(prefix)
66+
except Exception as exc:
67+
logger.warning("rule catalog: list_directory %s failed: %s", prefix, exc)
68+
return []
69+
70+
collected: list[tuple[str, dict]] = []
71+
for path in paths:
72+
if not path.endswith(".md"):
73+
continue
74+
text = await self._obsidian.get_note(path)
75+
if not text:
76+
continue
77+
parsed = _parse_ruling_cache(text)
78+
if parsed is None:
79+
logger.warning("rule catalog: malformed cache at %s, skipping", path)
80+
continue
81+
collected.append((path, parsed))
82+
return collected
83+
84+
@staticmethod
85+
def _build_ruling_index_entry(
86+
path: str, parsed: dict, topic: str | None = None
87+
) -> dict:
88+
"""Extract the summary fields a UI or Discord adapter would list."""
89+
hash_part = path.rsplit("/", 1)[-1].removesuffix(".md")
90+
return {
91+
"hash": hash_part,
92+
"topic": topic or parsed.get("topic"),
93+
"question": parsed.get("question", ""),
94+
"composed_at": parsed.get("composed_at", ""),
95+
"last_reused_at": parsed.get(
96+
"last_reused_at", parsed.get("composed_at", "")
97+
),
98+
"marker": parsed.get("marker", ""),
99+
"source": parsed.get("source"),
100+
}
101+
102+
103+
__all__ = ["RuleCacheCatalog"]

0 commit comments

Comments
 (0)