brain-framework 9.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
bf/config.py ADDED
@@ -0,0 +1,152 @@
1
+ """Strict YAML, one brain configuration, and the user's brain registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import cast
8
+
9
+ import yaml
10
+ import yaml.resolver
11
+ from pydantic import ValidationError
12
+
13
+ from bf.models import Config, Error, Registration, UserConfig, explain
14
+ from bf.storage import Store, writer
15
+
16
+
17
+ class _Loader(yaml.SafeLoader):
18
+ """Reject duplicate mappings rather than accepting a hidden override."""
19
+
20
+
21
+ def _mapping(loader: _Loader, node: yaml.MappingNode) -> dict[str, object]:
22
+ result: dict[str, object] = {}
23
+ for key_node, value_node in node.value:
24
+ key = loader.construct_object(key_node)
25
+ if not isinstance(key, str) or key in result:
26
+ raise Error("YAML mapping keys must be unique strings")
27
+ result[key] = loader.construct_object(value_node)
28
+ return result
29
+
30
+
31
+ _Loader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _mapping)
32
+ # Dates are validated by their owning models; YAML must not turn them into other types.
33
+ _Loader.add_constructor("tag:yaml.org,2002:timestamp", lambda loader, node: loader.construct_scalar(node))
34
+
35
+
36
+ def yaml_object(data: bytes) -> dict[str, object]:
37
+ """No aliases, anchors, duplicate keys, deep documents or large node graphs."""
38
+ if len(data) > 1 << 20:
39
+ raise Error("YAML exceeds 1 MiB")
40
+ try:
41
+ depth = 0
42
+ for count, event in enumerate(yaml.parse(data), 1):
43
+ if isinstance(event, yaml.AliasEvent) or getattr(event, "anchor", None):
44
+ raise Error("YAML anchors and aliases are not supported")
45
+ if isinstance(event, (yaml.MappingStartEvent, yaml.SequenceStartEvent)):
46
+ depth += 1
47
+ if isinstance(event, (yaml.MappingEndEvent, yaml.SequenceEndEvent)):
48
+ depth -= 1
49
+ if depth > 32 or count > 20_000:
50
+ raise Error("YAML structure exceeds its limit")
51
+ value = yaml.load(data, Loader=_Loader) # noqa: S506 - restricted SafeLoader subclass
52
+ except (yaml.YAMLError, UnicodeError) as error:
53
+ raise Error("invalid YAML") from error
54
+ if value is None:
55
+ return {}
56
+ if not isinstance(value, dict):
57
+ raise Error("YAML must contain one mapping")
58
+ return cast(dict[str, object], value)
59
+
60
+
61
+ def load(store: Store) -> Config:
62
+ value = yaml_object(store.read("bf.yaml", 1 << 20))
63
+ try:
64
+ return Config.model_validate(value)
65
+ except ValidationError as error:
66
+ raise Error("invalid bf.yaml: " + explain(error)) from error
67
+
68
+
69
+ def user_path() -> Path:
70
+ root = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
71
+ return Path(root).expanduser() / "bf" / "config.yaml"
72
+
73
+
74
+ def user_config() -> UserConfig:
75
+ """The optional user registry; a missing file means no registered brains."""
76
+ path = user_path()
77
+ try:
78
+ with path.open("rb") as stream:
79
+ data = stream.read((1 << 20) + 1)
80
+ except FileNotFoundError:
81
+ return UserConfig()
82
+ try:
83
+ return UserConfig.model_validate(yaml_object(data))
84
+ except ValidationError as error:
85
+ raise Error(f"invalid {path}: " + explain(error)) from error
86
+
87
+
88
+ def register(store: Store, *, collect: bool) -> dict[str, object]:
89
+ """Add or update this brain in the user registry, keyed by its configured name."""
90
+ name = load(store).name
91
+ path = user_path()
92
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
93
+ registry_store = Store(path.parent)
94
+ # Registration is one shared read/modify/write transaction, independent of each brain's lock.
95
+ with writer(registry_store, wait=30):
96
+ registry = user_config()
97
+ for other, entry in registry.brains.items():
98
+ if other != name and Path(entry.path).expanduser().resolve() == store.root:
99
+ raise Error(f"this directory is already registered as {other}")
100
+ existing = registry.brains.get(name)
101
+ if existing and Path(existing.path).expanduser().resolve() != store.root:
102
+ raise Error(f"another brain is already registered as {name}; rename one of them in bf.yaml")
103
+ registry.brains[name] = Registration(path=str(store.root), collect=collect)
104
+ document = {"brains": {key: value.model_dump() for key, value in sorted(registry.brains.items())}}
105
+ registry_store.write(
106
+ path.name,
107
+ ("# https://fmind.github.io/brain-framework/\n" + yaml.safe_dump(document, sort_keys=True)).encode(),
108
+ )
109
+ return {"brain": name, "path": str(store.root), "collect": collect, "config": str(path)}
110
+
111
+
112
+ def _located(value: str) -> Store:
113
+ registry = user_config()
114
+ if value in registry.brains:
115
+ return Store(Path(registry.brains[value].path).expanduser())
116
+ return Store(Path(value).expanduser())
117
+
118
+
119
+ def _nearest() -> Store | None:
120
+ for candidate in (Path.cwd(), *Path.cwd().parents):
121
+ if (candidate / "bf.yaml").is_file():
122
+ return Store(candidate)
123
+ return None
124
+
125
+
126
+ def select(value: str = "") -> list[Store]:
127
+ """--brain NAME|PATH, then BF_BRAIN, then the enclosing brain, then every registered brain."""
128
+ chosen = value or os.environ.get("BF_BRAIN", "")
129
+ if chosen:
130
+ return [_located(chosen)]
131
+ if nearest := _nearest():
132
+ return [nearest]
133
+ # A shared user configuration may list brains that exist only on some machines; skip absent ones.
134
+ paths = [Path(entry.path).expanduser() for _, entry in sorted(user_config().brains.items())]
135
+ stores = [Store(path) for path in paths if path.is_dir()]
136
+ if not stores:
137
+ raise Error("no brain selected; pass --brain, run inside a brain, or register one with bf register")
138
+ return stores
139
+
140
+
141
+ def one(value: str = "") -> Store:
142
+ stores = select(value)
143
+ if len(stores) != 1:
144
+ raise Error("several brains are registered; pass --brain NAME")
145
+ return stores[0]
146
+
147
+
148
+ def may_collect(store: Store) -> bool:
149
+ return any(
150
+ entry.collect and Path(entry.path).expanduser().resolve() == store.root
151
+ for entry in user_config().brains.values()
152
+ )
bf/evaluate.py ADDED
@@ -0,0 +1,84 @@
1
+ """Owner-written retrieval cases: the questions a brain must keep answering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated, Literal, cast
6
+
7
+ from pydantic import Field, ValidationError
8
+
9
+ from bf.config import yaml_object
10
+ from bf.markdown import authored, split_ref
11
+ from bf.models import Error, Model, Query, Status, explain, moment
12
+ from bf.retrieve import search
13
+ from bf.storage import Store
14
+
15
+
16
+ class Case(Model):
17
+ name: str
18
+ query: str = ""
19
+ since: str = ""
20
+ until: str = ""
21
+ source: str = ""
22
+ type: str = ""
23
+ status: Status = ""
24
+ recent: bool = False
25
+ changed_since: str = ""
26
+ current: bool = False
27
+ limit: Annotated[int, Field(ge=1, le=50)] = 10
28
+ # A note path without #fragment matches any of its sections.
29
+ expect: list[str] = Field(default_factory=list)
30
+ forbid: list[str] = Field(default_factory=list)
31
+ # Each text must appear in a returned title or excerpt: the answer is delivered, not only its location.
32
+ text: list[str] = Field(default_factory=list)
33
+ empty: bool = False
34
+
35
+
36
+ class Suite(Model):
37
+ version: Literal[3] = 3
38
+ cases: Annotated[list[Case], Field(min_length=1, max_length=200)]
39
+
40
+
41
+ def _matches(expected: str, refs: list[str]) -> bool:
42
+ return any(ref == expected or (authored(expected) and split_ref(ref)[0] == expected) for ref in refs)
43
+
44
+
45
+ def evaluate(store: Store, path: str = "queries.yaml") -> dict[str, object]:
46
+ try:
47
+ suite = Suite.model_validate(yaml_object(store.read(path, 1 << 20)))
48
+ except ValidationError as error:
49
+ raise Error(f"invalid {path}: " + explain(error)) from error
50
+ results = []
51
+ for case in suite.cases:
52
+ if case.empty == bool(case.expect or case.text):
53
+ raise Error(f"case {case.name}: use expect/text, or empty: true")
54
+ query = Query(
55
+ text=case.query,
56
+ since=moment(case.since) if case.since else "",
57
+ until=moment(case.until) if case.until else "",
58
+ source=case.source,
59
+ type=case.type,
60
+ status=case.status,
61
+ limit=case.limit,
62
+ recent=case.recent,
63
+ changed_since=moment(case.changed_since) if case.changed_since else "",
64
+ current=case.current,
65
+ )
66
+ reply = search([store], query, counted=False)
67
+ items = cast("list[dict[str, object]]", reply["items"])
68
+ refs = [str(item["ref"]) for item in items]
69
+ delivered = "\n".join(f"{item.get('title', '')}\n{item.get('excerpt', '')}" for item in items)
70
+ missing = [ref for ref in case.expect if not _matches(ref, refs)]
71
+ forbidden = [ref for ref in case.forbid if _matches(ref, refs)]
72
+ absent = [text for text in case.text if text.casefold() not in delivered.casefold()]
73
+ passed = not (missing or forbidden or absent or reply.get("problems") or reply.get("stale")) and (
74
+ not case.empty or not items
75
+ )
76
+ result: dict[str, object] = {"name": case.name, "passed": passed}
77
+ if not passed:
78
+ result.update(missing=missing, forbidden=forbidden, absent=absent, returned=refs)
79
+ for field in ("problems", "stale"):
80
+ if field in reply:
81
+ result[field] = reply[field]
82
+ results.append(result)
83
+ passed = sum(bool(r["passed"]) for r in results)
84
+ return {"passed": passed == len(results), "score": f"{passed}/{len(results)}", "cases": results}
bf/health.py ADDED
@@ -0,0 +1,48 @@
1
+ """Collection coverage shared by status and retrieval; freshness never implies complete history."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from datetime import UTC, datetime, timedelta
7
+
8
+ from bf.collect import state
9
+ from bf.config import load, may_collect
10
+ from bf.storage import Store
11
+
12
+
13
+ def source_health(
14
+ store: Store, names: Iterable[str] = (), *, now: datetime | None = None
15
+ ) -> dict[str, dict[str, object]]:
16
+ """Describe active, disabled and historical sources without running them or exposing logs."""
17
+ now = now or datetime.now(UTC)
18
+ config, history, trusted = load(store), state(store), may_collect(store)
19
+ result: dict[str, dict[str, object]] = {}
20
+ for name in sorted({*config.sensors, *names}):
21
+ settings = config.sensors.get(name)
22
+ entry = history.get(name, {})
23
+ success = str(entry.get("success", ""))
24
+ item: dict[str, object] = {
25
+ "state": "historical" if settings is None else "active" if settings.enabled else "disabled",
26
+ "freshness": "unknown",
27
+ }
28
+ if success:
29
+ item["last_collected"] = success
30
+ if settings is not None:
31
+ item["mode"] = settings.mode
32
+ if (settings is None or settings.mode == "window") and entry.get("start") and entry.get("end"):
33
+ item["window"] = {"since": entry["start"], "until": entry["end"]}
34
+ if entry.get("error"):
35
+ item["failed"] = True
36
+ if settings is not None and settings.enabled:
37
+ if not settings.refresh:
38
+ item["freshness"] = "manual"
39
+ elif trusted:
40
+ item["freshness"] = (
41
+ "never"
42
+ if not success
43
+ else "stale"
44
+ if datetime.fromisoformat(success) < now - timedelta(seconds=2 * settings.refresh)
45
+ else "fresh"
46
+ )
47
+ result[name] = item
48
+ return result