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/__init__.py +10 -0
- bf/__main__.py +5 -0
- bf/cli.py +298 -0
- bf/collect.py +274 -0
- bf/config.py +152 -0
- bf/evaluate.py +84 -0
- bf/health.py +48 -0
- bf/index.py +559 -0
- bf/markdown.py +277 -0
- bf/mcp.py +78 -0
- bf/models.py +279 -0
- bf/py.typed +0 -0
- bf/records.py +277 -0
- bf/retrieve.py +173 -0
- bf/storage.py +223 -0
- bf/update.py +58 -0
- bf/usage.py +56 -0
- bf/validate.py +77 -0
- brain_framework-9.0.1.dist-info/METADATA +136 -0
- brain_framework-9.0.1.dist-info/RECORD +24 -0
- brain_framework-9.0.1.dist-info/WHEEL +4 -0
- brain_framework-9.0.1.dist-info/entry_points.txt +3 -0
- brain_framework-9.0.1.dist-info/licenses/LICENSE +21 -0
- brain_framework-9.0.1.dist-info/licenses/THIRD_PARTY_NOTICES.md +2187 -0
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
|