katalyst-plugin-context 0.1.1a1__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.
- katalyst_plugin_context/__init__.py +8 -0
- katalyst_plugin_context/adapters/__init__.py +0 -0
- katalyst_plugin_context/adapters/runtime_graph.py +126 -0
- katalyst_plugin_context/cli.py +139 -0
- katalyst_plugin_context/composition.py +49 -0
- katalyst_plugin_context/domain/__init__.py +0 -0
- katalyst_plugin_context/domain/context.py +762 -0
- katalyst_plugin_context/domain/graph.py +97 -0
- katalyst_plugin_context/domain/markdown.py +201 -0
- katalyst_plugin_context/domain/paths.py +114 -0
- katalyst_plugin_context/domain/policy.py +345 -0
- katalyst_plugin_context/domain/settings.py +92 -0
- katalyst_plugin_context/lint.py +149 -0
- katalyst_plugin_context/mcp_tools.py +132 -0
- katalyst_plugin_context/plugin.py +64 -0
- katalyst_plugin_context/py.typed +0 -0
- katalyst_plugin_context/rest.py +133 -0
- katalyst_plugin_context/schema.py +200 -0
- katalyst_plugin_context/service.py +319 -0
- katalyst_plugin_context-0.1.1a1.dist-info/METADATA +13 -0
- katalyst_plugin_context-0.1.1a1.dist-info/RECORD +23 -0
- katalyst_plugin_context-0.1.1a1.dist-info/WHEEL +4 -0
- katalyst_plugin_context-0.1.1a1.dist-info/entry_points.txt +5 -0
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
"""The context of one node: the map around it, and the items an agent asks for.
|
|
2
|
+
|
|
3
|
+
Pure: it walks a :class:`~katalyst_plugin_context.domain.graph.Graph` under a
|
|
4
|
+
:class:`~katalyst_plugin_context.domain.policy.Policy`, and asks a :class:`Schema` what each
|
|
5
|
+
kind declares. The graph, the policy and the schema are built by the service per call.
|
|
6
|
+
Nothing here names a kind, a relationship or a field.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import math
|
|
13
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any, Protocol
|
|
16
|
+
|
|
17
|
+
from katalyst_plugin_context.domain.graph import Graph, GraphNode
|
|
18
|
+
from katalyst_plugin_context.domain.paths import OUT, Step, parse_path, path_text
|
|
19
|
+
from katalyst_plugin_context.domain.policy import DETAIL_LEVELS, FULL, REFS, SUMMARY, Policy
|
|
20
|
+
from katalyst_plugin_context.domain.settings import ItemHints, View
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"ContextError",
|
|
24
|
+
"ContextRequest",
|
|
25
|
+
"Schema",
|
|
26
|
+
"build_map",
|
|
27
|
+
"build_result",
|
|
28
|
+
"estimate_tokens",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
#: How much of a description ``summary`` shows, and of each summary field's value.
|
|
32
|
+
DESCRIPTION_LIMIT = 300
|
|
33
|
+
FIELD_LIMIT = 120
|
|
34
|
+
#: At most this many arrivals per walk: a bound on a pathological fan-out, far above any budget.
|
|
35
|
+
WALK_LIMIT = 5000
|
|
36
|
+
#: At most this many qualified names per group in the "what was cut" report.
|
|
37
|
+
CUT_NAMES = 20
|
|
38
|
+
|
|
39
|
+
_SCALARS = ("string", "integer", "number", "boolean")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ContextError(Exception):
|
|
43
|
+
"""A request that cannot be answered: ``code`` is the envelope's error code."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, code: str, message: str, details: Mapping[str, Any] | None = None) -> None:
|
|
46
|
+
super().__init__(message)
|
|
47
|
+
self.code = code
|
|
48
|
+
self.message = message
|
|
49
|
+
self.details = dict(details or {})
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Schema(Protocol):
|
|
53
|
+
"""What the context needs to know about kinds and relationships."""
|
|
54
|
+
|
|
55
|
+
parent_rel: str
|
|
56
|
+
|
|
57
|
+
def kind_label(self, kind: str) -> str: ...
|
|
58
|
+
def hints(self, kind: str) -> ItemHints: ...
|
|
59
|
+
def declared_fields(self, kind: str) -> Sequence[tuple[str, str]]: ...
|
|
60
|
+
def blocking(self, kind: str, spec: Mapping[str, Any]) -> list[dict[str, Any]]: ...
|
|
61
|
+
def relationship_description(self, rel: str) -> str: ...
|
|
62
|
+
def carried(self, kind: str) -> Sequence[tuple[str, frozenset[str]]]: ...
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True, slots=True)
|
|
66
|
+
class ContextRequest:
|
|
67
|
+
"""What an agent asks for. ``None`` means "per policy"."""
|
|
68
|
+
|
|
69
|
+
paths: tuple[str, ...] | None = None
|
|
70
|
+
views: tuple[str, ...] | None = None
|
|
71
|
+
depth: int | None = None
|
|
72
|
+
detail: str | None = None
|
|
73
|
+
budget: int | None = None
|
|
74
|
+
inherit: bool | None = None
|
|
75
|
+
format: str = "json"
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def names_nothing(self) -> bool:
|
|
79
|
+
return not self.paths and not self.views
|
|
80
|
+
|
|
81
|
+
def as_payload(self) -> dict[str, Any]:
|
|
82
|
+
return {
|
|
83
|
+
"paths": list(self.paths) if self.paths is not None else None,
|
|
84
|
+
"views": list(self.views) if self.views is not None else None,
|
|
85
|
+
"depth": self.depth,
|
|
86
|
+
"detail": self.detail,
|
|
87
|
+
"budget": self.budget,
|
|
88
|
+
"inherit": self.inherit,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def estimate_tokens(text: str) -> int:
|
|
93
|
+
"""Approximate tokens: a quarter of the characters, rounded up."""
|
|
94
|
+
return math.ceil(len(text) / 4)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ── walking ──────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True, slots=True)
|
|
101
|
+
class _Hop:
|
|
102
|
+
source: str
|
|
103
|
+
rel: str
|
|
104
|
+
direction: str
|
|
105
|
+
target: str
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True, slots=True)
|
|
109
|
+
class _Arrival:
|
|
110
|
+
key: str
|
|
111
|
+
start: str
|
|
112
|
+
hops: tuple[_Hop, ...]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _walk(
|
|
116
|
+
graph: Graph,
|
|
117
|
+
start: str,
|
|
118
|
+
steps: Sequence[Step],
|
|
119
|
+
policy: Policy,
|
|
120
|
+
*,
|
|
121
|
+
exclude: frozenset[str],
|
|
122
|
+
inherited: bool,
|
|
123
|
+
parent_rel: str,
|
|
124
|
+
collect_every_level: bool,
|
|
125
|
+
) -> list[_Arrival]:
|
|
126
|
+
"""Every node reached from *start* by following *steps*, with the hops that reached it.
|
|
127
|
+
|
|
128
|
+
A node never appears twice on one branch (cycles stop). *exclude* (the focus node and
|
|
129
|
+
its ancestors) is never reached. From an ancestor, its structural parent edges are
|
|
130
|
+
never followed: they would only reach siblings.
|
|
131
|
+
"""
|
|
132
|
+
frontier: list[tuple[str, tuple[_Hop, ...]]] = [(start, ())]
|
|
133
|
+
arrivals: list[_Arrival] = []
|
|
134
|
+
for index, step in enumerate(steps):
|
|
135
|
+
nxt: list[tuple[str, tuple[_Hop, ...]]] = []
|
|
136
|
+
for current, hops in frontier:
|
|
137
|
+
on_branch = {start, *(h.target for h in hops)}
|
|
138
|
+
for neighbour in graph.neighbours(current):
|
|
139
|
+
if inherited and index == 0 and neighbour.rel == parent_rel:
|
|
140
|
+
continue
|
|
141
|
+
other = graph.nodes[neighbour.other]
|
|
142
|
+
if neighbour.other in exclude or neighbour.other in on_branch:
|
|
143
|
+
continue
|
|
144
|
+
if not step.matches(neighbour.rel, neighbour.direction, other.kind):
|
|
145
|
+
continue
|
|
146
|
+
if not policy.visible(neighbour.rel, neighbour.direction, other.kind):
|
|
147
|
+
continue
|
|
148
|
+
hop = _Hop(current, neighbour.rel, neighbour.direction, neighbour.other)
|
|
149
|
+
nxt.append((neighbour.other, (*hops, hop)))
|
|
150
|
+
if len(nxt) + len(arrivals) > WALK_LIMIT:
|
|
151
|
+
break
|
|
152
|
+
frontier = nxt
|
|
153
|
+
if collect_every_level or index == len(steps) - 1:
|
|
154
|
+
arrivals.extend(_Arrival(key, start, hops) for key, hops in frontier)
|
|
155
|
+
if not frontier:
|
|
156
|
+
break
|
|
157
|
+
return arrivals
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ── items ────────────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass
|
|
164
|
+
class _Entry:
|
|
165
|
+
node: GraphNode
|
|
166
|
+
rank: tuple[Any, ...]
|
|
167
|
+
view: str | None
|
|
168
|
+
path: str
|
|
169
|
+
detail: str
|
|
170
|
+
inherited_from: str | None
|
|
171
|
+
depth: int
|
|
172
|
+
whys: list[tuple[tuple[Any, ...], _Arrival, str]] = field(
|
|
173
|
+
default_factory=lambda: list[tuple[tuple[Any, ...], _Arrival, str]]()
|
|
174
|
+
)
|
|
175
|
+
blocking: list[dict[str, Any]] = field(default_factory=lambda: list[dict[str, Any]]())
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@dataclass(frozen=True, slots=True)
|
|
179
|
+
class _Source:
|
|
180
|
+
"""One thing walked: a view's path, an asked path, or the exploration."""
|
|
181
|
+
|
|
182
|
+
section: int
|
|
183
|
+
view: View | None
|
|
184
|
+
steps: tuple[Step, ...]
|
|
185
|
+
path_index: int
|
|
186
|
+
detail: str
|
|
187
|
+
inherit: bool
|
|
188
|
+
repeat: int
|
|
189
|
+
"""For a single step, how many times to follow it; else 1."""
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _label(graph: Graph, schema: Schema, key: str) -> str:
|
|
193
|
+
node = graph.nodes[key]
|
|
194
|
+
return f"{node.kind} {key}"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _why_payload(graph: Graph, schema: Schema, arrival: _Arrival, focus: str) -> dict[str, Any]:
|
|
198
|
+
inherited = arrival.start != focus
|
|
199
|
+
words: list[str] = []
|
|
200
|
+
if inherited:
|
|
201
|
+
words.append(_label(graph, schema, arrival.start))
|
|
202
|
+
for i, hop in enumerate(arrival.hops):
|
|
203
|
+
arrow = f"─{hop.rel}→" if hop.direction == OUT else f"←{hop.rel}─"
|
|
204
|
+
words.append(arrow)
|
|
205
|
+
if i < len(arrival.hops) - 1:
|
|
206
|
+
words.append(_label(graph, schema, hop.target))
|
|
207
|
+
if inherited:
|
|
208
|
+
words.append("(inherited)")
|
|
209
|
+
return {
|
|
210
|
+
"path": "/".join(f"{h.rel}:{h.direction}" for h in arrival.hops),
|
|
211
|
+
"from": arrival.start,
|
|
212
|
+
"inherited": inherited,
|
|
213
|
+
"hops": [
|
|
214
|
+
{"from": h.source, "rel": h.rel, "dir": h.direction, "to": h.target}
|
|
215
|
+
for h in arrival.hops
|
|
216
|
+
],
|
|
217
|
+
"text": " ".join(words),
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _shorten(text: str, limit: int) -> str:
|
|
222
|
+
if len(text) <= limit:
|
|
223
|
+
return text
|
|
224
|
+
cut = text[:limit]
|
|
225
|
+
end = max(cut.rfind(". "), cut.rfind(".\n"))
|
|
226
|
+
if end >= limit // 2:
|
|
227
|
+
return cut[: end + 1]
|
|
228
|
+
return cut.rstrip() + "…"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _scalar(value: object) -> bool:
|
|
232
|
+
return isinstance(value, (str, int, float, bool))
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _title(node: GraphNode, hints: ItemHints) -> str:
|
|
236
|
+
if hints.title_field:
|
|
237
|
+
value = node.spec.get(hints.title_field)
|
|
238
|
+
if isinstance(value, str) and value.strip():
|
|
239
|
+
return value.strip()
|
|
240
|
+
return node.name
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _item_payload(
|
|
244
|
+
entry: _Entry, level: str, graph: Graph, schema: Schema, focus: str
|
|
245
|
+
) -> dict[str, Any]:
|
|
246
|
+
node = entry.node
|
|
247
|
+
hints = schema.hints(node.kind)
|
|
248
|
+
whys = sorted(entry.whys, key=lambda w: w[0])
|
|
249
|
+
payload: dict[str, Any] = {
|
|
250
|
+
"qualifiedName": node.key,
|
|
251
|
+
"kind": node.kind,
|
|
252
|
+
"title": _title(node, hints),
|
|
253
|
+
"view": entry.view,
|
|
254
|
+
"path": entry.path,
|
|
255
|
+
"depth": entry.depth,
|
|
256
|
+
"inheritedFrom": entry.inherited_from,
|
|
257
|
+
"detail": level,
|
|
258
|
+
"why": [_why_payload(graph, schema, arrival, focus) for _, arrival, _ in whys],
|
|
259
|
+
"blocking": (entry.blocking[0] if len(entry.blocking) == 1 else (entry.blocking or None)),
|
|
260
|
+
}
|
|
261
|
+
if level in (SUMMARY, FULL):
|
|
262
|
+
description = node.description or ""
|
|
263
|
+
payload["description"] = (
|
|
264
|
+
description if level == FULL else _shorten(description, DESCRIPTION_LIMIT)
|
|
265
|
+
)
|
|
266
|
+
payload["descriptionLength"] = len(description)
|
|
267
|
+
declared = schema.declared_fields(node.kind)
|
|
268
|
+
wanted = hints.summary_fields
|
|
269
|
+
fields: dict[str, Any] = {}
|
|
270
|
+
for name, field_type in declared:
|
|
271
|
+
if wanted is not None and name not in wanted:
|
|
272
|
+
continue
|
|
273
|
+
if wanted is None and field_type not in _SCALARS:
|
|
274
|
+
continue
|
|
275
|
+
value = node.spec.get(name)
|
|
276
|
+
if value is None or (hints.title_field and name == hints.title_field):
|
|
277
|
+
continue
|
|
278
|
+
if _scalar(value):
|
|
279
|
+
fields[name] = _shorten(value, FIELD_LIMIT) if isinstance(value, str) else value
|
|
280
|
+
elif level == FULL or wanted is not None:
|
|
281
|
+
fields[name] = value
|
|
282
|
+
payload["fields"] = fields
|
|
283
|
+
if level == FULL:
|
|
284
|
+
payload["spec"] = {
|
|
285
|
+
name: node.spec[name]
|
|
286
|
+
for name, _ in schema.declared_fields(node.kind)
|
|
287
|
+
if name in node.spec
|
|
288
|
+
}
|
|
289
|
+
payload["body"] = node.body
|
|
290
|
+
return payload
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _lower(level: str) -> list[str]:
|
|
294
|
+
"""*level* and every level below it, highest first."""
|
|
295
|
+
return list(reversed(DETAIL_LEVELS[: DETAIL_LEVELS.index(level) + 1]))
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# ── markdown sizes (shared with the renderer) ───────────────────────────
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
SizeOf = Callable[[dict[str, Any]], int]
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def json_size(item: dict[str, Any]) -> int:
|
|
305
|
+
return estimate_tokens(json.dumps(item, separators=(",", ":"), default=str))
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# ── the result ───────────────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _sources(
|
|
312
|
+
request: ContextRequest,
|
|
313
|
+
policy: Policy,
|
|
314
|
+
views: Mapping[str, View],
|
|
315
|
+
*,
|
|
316
|
+
always: Sequence[str],
|
|
317
|
+
depth: int,
|
|
318
|
+
detail_for: Callable[[str | None], str],
|
|
319
|
+
inherit: bool,
|
|
320
|
+
refused: list[dict[str, Any]],
|
|
321
|
+
notes: list[dict[str, Any]],
|
|
322
|
+
) -> list[_Source]:
|
|
323
|
+
sources: list[_Source] = []
|
|
324
|
+
|
|
325
|
+
def add_view(view: View, section: int) -> None:
|
|
326
|
+
for index, steps in enumerate(view.paths):
|
|
327
|
+
hidden = next(
|
|
328
|
+
(by for by in (policy.step_hidden_by(s) for s in steps) if by is not None), None
|
|
329
|
+
)
|
|
330
|
+
if hidden is not None:
|
|
331
|
+
notes.append(
|
|
332
|
+
{
|
|
333
|
+
"view": view.name,
|
|
334
|
+
"path": path_text(steps),
|
|
335
|
+
"limitedBy": hidden,
|
|
336
|
+
"text": f"view {view.name!r} path {path_text(steps)!r} is hidden by {hidden}",
|
|
337
|
+
}
|
|
338
|
+
)
|
|
339
|
+
continue
|
|
340
|
+
if len(steps) > policy.max_depth:
|
|
341
|
+
notes.append(
|
|
342
|
+
{
|
|
343
|
+
"view": view.name,
|
|
344
|
+
"path": path_text(steps),
|
|
345
|
+
"limitedBy": policy.max_depth_by,
|
|
346
|
+
"text": f"view {view.name!r} path {path_text(steps)!r} is longer than "
|
|
347
|
+
f"max_depth {policy.max_depth} ({policy.max_depth_by})",
|
|
348
|
+
}
|
|
349
|
+
)
|
|
350
|
+
steps = steps[: policy.max_depth]
|
|
351
|
+
if not steps:
|
|
352
|
+
continue
|
|
353
|
+
sources.append(
|
|
354
|
+
_Source(
|
|
355
|
+
section=section,
|
|
356
|
+
view=view,
|
|
357
|
+
steps=steps,
|
|
358
|
+
path_index=index,
|
|
359
|
+
detail=detail_for(request.detail or view.detail),
|
|
360
|
+
inherit=view.inherit and inherit,
|
|
361
|
+
repeat=1,
|
|
362
|
+
)
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
if request.names_nothing:
|
|
366
|
+
for name in sorted(always, key=lambda n: -views[n].priority):
|
|
367
|
+
add_view(views[name], 0)
|
|
368
|
+
if not sources and not always:
|
|
369
|
+
sources.append(
|
|
370
|
+
_Source(3, None, (Step("*"),), 0, detail_for(request.detail), inherit, depth)
|
|
371
|
+
)
|
|
372
|
+
return sources
|
|
373
|
+
for name in sorted(request.views or (), key=lambda n: -views[n].priority):
|
|
374
|
+
add_view(views[name], 1)
|
|
375
|
+
for index, text in enumerate(request.paths or ()):
|
|
376
|
+
steps = parse_path(text)
|
|
377
|
+
hidden = next(
|
|
378
|
+
(by for by in (policy.step_hidden_by(s) for s in steps) if by is not None), None
|
|
379
|
+
)
|
|
380
|
+
if hidden is not None:
|
|
381
|
+
refused.append(
|
|
382
|
+
{"path": text, "limitedBy": hidden, "text": f"{text!r} is hidden by {hidden}"}
|
|
383
|
+
)
|
|
384
|
+
continue
|
|
385
|
+
if len(steps) > policy.max_depth:
|
|
386
|
+
refused.append(
|
|
387
|
+
{
|
|
388
|
+
"path": text,
|
|
389
|
+
"limitedBy": policy.max_depth_by,
|
|
390
|
+
"text": f"{text!r} is longer than max_depth {policy.max_depth} "
|
|
391
|
+
f"({policy.max_depth_by})",
|
|
392
|
+
}
|
|
393
|
+
)
|
|
394
|
+
continue
|
|
395
|
+
sources.append(
|
|
396
|
+
_Source(
|
|
397
|
+
2,
|
|
398
|
+
None,
|
|
399
|
+
steps,
|
|
400
|
+
index,
|
|
401
|
+
detail_for(request.detail),
|
|
402
|
+
inherit,
|
|
403
|
+
depth if len(steps) == 1 else 1,
|
|
404
|
+
)
|
|
405
|
+
)
|
|
406
|
+
return sources
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _collect(
|
|
410
|
+
graph: Graph,
|
|
411
|
+
schema: Schema,
|
|
412
|
+
focus: str,
|
|
413
|
+
ancestors: Sequence[str],
|
|
414
|
+
policy: Policy,
|
|
415
|
+
sources: Sequence[_Source],
|
|
416
|
+
) -> tuple[dict[str, _Entry], list[dict[str, Any]]]:
|
|
417
|
+
entries: dict[str, _Entry] = {}
|
|
418
|
+
exclude = frozenset({focus, *ancestors})
|
|
419
|
+
group_members: dict[tuple[str | None, str], set[str]] = {}
|
|
420
|
+
for source in sources:
|
|
421
|
+
starts = [focus, *(ancestors if source.inherit else ())]
|
|
422
|
+
steps = source.steps * source.repeat if source.repeat > 1 else source.steps
|
|
423
|
+
text = path_text(source.steps)
|
|
424
|
+
group = group_members.setdefault((source.view.name if source.view else None, text), set())
|
|
425
|
+
for distance, start in enumerate(starts):
|
|
426
|
+
arrivals = _walk(
|
|
427
|
+
graph,
|
|
428
|
+
start,
|
|
429
|
+
steps,
|
|
430
|
+
policy,
|
|
431
|
+
exclude=exclude,
|
|
432
|
+
inherited=start != focus,
|
|
433
|
+
parent_rel=schema.parent_rel,
|
|
434
|
+
collect_every_level=source.repeat > 1,
|
|
435
|
+
)
|
|
436
|
+
for arrival in arrivals:
|
|
437
|
+
node = graph.nodes[arrival.key]
|
|
438
|
+
hints = schema.hints(node.kind)
|
|
439
|
+
blocking = schema.blocking(node.kind, node.spec)
|
|
440
|
+
view_priority = source.view.priority if source.view else 0
|
|
441
|
+
blocking_rank = (
|
|
442
|
+
(0 if any(b.get("blocking") for b in blocking) else 1)
|
|
443
|
+
if source.view is not None and source.view.blocking_first
|
|
444
|
+
else 0
|
|
445
|
+
)
|
|
446
|
+
rank = (
|
|
447
|
+
source.section,
|
|
448
|
+
-view_priority,
|
|
449
|
+
source.view.name if source.view else "",
|
|
450
|
+
blocking_rank,
|
|
451
|
+
source.path_index,
|
|
452
|
+
distance,
|
|
453
|
+
len(arrival.hops),
|
|
454
|
+
-hints.priority,
|
|
455
|
+
node.key,
|
|
456
|
+
)
|
|
457
|
+
group.add(node.key)
|
|
458
|
+
entry = entries.get(node.key)
|
|
459
|
+
if entry is None or rank < entry.rank:
|
|
460
|
+
previous = entry.whys if entry is not None else []
|
|
461
|
+
entry = _Entry(
|
|
462
|
+
node=node,
|
|
463
|
+
rank=rank,
|
|
464
|
+
view=source.view.name if source.view else None,
|
|
465
|
+
path=text,
|
|
466
|
+
detail=source.detail,
|
|
467
|
+
inherited_from=None if start == focus else start,
|
|
468
|
+
depth=len(arrival.hops),
|
|
469
|
+
whys=previous,
|
|
470
|
+
blocking=blocking,
|
|
471
|
+
)
|
|
472
|
+
entries[node.key] = entry
|
|
473
|
+
if not any(w[1].hops == arrival.hops for w in entry.whys):
|
|
474
|
+
entry.whys.append((rank, arrival, text))
|
|
475
|
+
groups = [
|
|
476
|
+
{"view": view, "path": path, "count": len(members)}
|
|
477
|
+
for (view, path), members in group_members.items()
|
|
478
|
+
]
|
|
479
|
+
return entries, groups
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def build_result(
|
|
483
|
+
graph: Graph,
|
|
484
|
+
schema: Schema,
|
|
485
|
+
focus: str,
|
|
486
|
+
policy: Policy,
|
|
487
|
+
views: Mapping[str, View],
|
|
488
|
+
request: ContextRequest,
|
|
489
|
+
*,
|
|
490
|
+
size_of: SizeOf = json_size,
|
|
491
|
+
) -> dict[str, Any]:
|
|
492
|
+
"""The items *request* asks for around *focus*, budgeted, with why each is here."""
|
|
493
|
+
unknown = [name for name in request.views or () if name not in views]
|
|
494
|
+
if unknown:
|
|
495
|
+
raise ContextError(
|
|
496
|
+
"INVALID_INPUT",
|
|
497
|
+
f"{graph.nodes[focus].kind} has no view {unknown[0]!r}; it has: "
|
|
498
|
+
+ (", ".join(sorted(views)) or "none"),
|
|
499
|
+
{"views": sorted(views)},
|
|
500
|
+
)
|
|
501
|
+
if request.detail is not None and request.detail not in DETAIL_LEVELS:
|
|
502
|
+
raise ContextError(
|
|
503
|
+
"INVALID_INPUT",
|
|
504
|
+
f"detail must be one of {', '.join(DETAIL_LEVELS)}; got {request.detail!r}",
|
|
505
|
+
)
|
|
506
|
+
clamped: list[dict[str, Any]] = []
|
|
507
|
+
refused: list[dict[str, Any]] = []
|
|
508
|
+
notes = list(policy.notes)
|
|
509
|
+
depth, note = policy.clamp_depth(request.depth)
|
|
510
|
+
clamped.extend([note] if note else [])
|
|
511
|
+
budget, note = policy.clamp_budget(request.budget)
|
|
512
|
+
clamped.extend([note] if note else [])
|
|
513
|
+
inherit, note = policy.clamp_inherit(request.inherit)
|
|
514
|
+
clamped.extend([note] if note else [])
|
|
515
|
+
requested_detail_note: list[dict[str, Any]] = []
|
|
516
|
+
|
|
517
|
+
def detail_for(wanted: str | None) -> str:
|
|
518
|
+
level, note = policy.clamp_detail(wanted, REFS)
|
|
519
|
+
if note and note not in requested_detail_note:
|
|
520
|
+
requested_detail_note.append(note)
|
|
521
|
+
return level
|
|
522
|
+
|
|
523
|
+
ancestors = graph.ancestors(focus, schema.parent_rel) if inherit else ()
|
|
524
|
+
always = [name for name in policy.always or () if name in views]
|
|
525
|
+
sources = _sources(
|
|
526
|
+
request,
|
|
527
|
+
policy,
|
|
528
|
+
views,
|
|
529
|
+
always=always,
|
|
530
|
+
depth=depth,
|
|
531
|
+
detail_for=detail_for,
|
|
532
|
+
inherit=inherit,
|
|
533
|
+
refused=refused,
|
|
534
|
+
notes=notes,
|
|
535
|
+
)
|
|
536
|
+
clamped.extend(requested_detail_note)
|
|
537
|
+
entries, groups = _collect(graph, schema, focus, ancestors, policy, sources)
|
|
538
|
+
ordered = sorted(entries.values(), key=lambda e: e.rank)
|
|
539
|
+
|
|
540
|
+
items: list[dict[str, Any]] = []
|
|
541
|
+
downgraded: list[dict[str, Any]] = []
|
|
542
|
+
cut: list[_Entry] = []
|
|
543
|
+
used = 0
|
|
544
|
+
for position, entry in enumerate(ordered):
|
|
545
|
+
placed = False
|
|
546
|
+
for level in _lower(entry.detail):
|
|
547
|
+
payload = _item_payload(entry, level, graph, schema, focus)
|
|
548
|
+
cost = size_of(payload)
|
|
549
|
+
if used + cost <= budget:
|
|
550
|
+
items.append(payload)
|
|
551
|
+
used += cost
|
|
552
|
+
if level != entry.detail:
|
|
553
|
+
downgraded.append(
|
|
554
|
+
{"qualifiedName": entry.node.key, "from": entry.detail, "to": level}
|
|
555
|
+
)
|
|
556
|
+
placed = True
|
|
557
|
+
break
|
|
558
|
+
if not placed:
|
|
559
|
+
cut = ordered[position:]
|
|
560
|
+
break
|
|
561
|
+
|
|
562
|
+
returned = {item["qualifiedName"] for item in items}
|
|
563
|
+
for group in groups:
|
|
564
|
+
group["returned"] = sum(
|
|
565
|
+
1
|
|
566
|
+
for e in entries.values()
|
|
567
|
+
if e.node.key in returned and (e.view, e.path) == (group["view"], group["path"])
|
|
568
|
+
)
|
|
569
|
+
truncated: dict[str, Any] | None = None
|
|
570
|
+
if cut or downgraded:
|
|
571
|
+
by_group: dict[tuple[str | None, str], list[str]] = {}
|
|
572
|
+
for entry in cut:
|
|
573
|
+
by_group.setdefault((entry.view, entry.path), []).append(entry.node.key)
|
|
574
|
+
whole = sum(size_of(_item_payload(e, e.detail, graph, schema, focus)) for e in ordered)
|
|
575
|
+
truncated = {
|
|
576
|
+
"budget": budget,
|
|
577
|
+
"used": used,
|
|
578
|
+
"downgraded": downgraded,
|
|
579
|
+
"cut": [
|
|
580
|
+
{
|
|
581
|
+
"view": view,
|
|
582
|
+
"path": path,
|
|
583
|
+
"count": len(names),
|
|
584
|
+
"qualifiedNames": names[:CUT_NAMES],
|
|
585
|
+
}
|
|
586
|
+
for (view, path), names in by_group.items()
|
|
587
|
+
],
|
|
588
|
+
"cutTotal": len(cut),
|
|
589
|
+
"hint": {"budget": min(max(whole, budget + 1), policy.budget_max)},
|
|
590
|
+
}
|
|
591
|
+
return {
|
|
592
|
+
"node": _node_payload(graph, schema, focus),
|
|
593
|
+
"request": request.as_payload(),
|
|
594
|
+
"effective": {
|
|
595
|
+
"views": list(dict.fromkeys(s.view.name for s in sources if s.view is not None)),
|
|
596
|
+
"paths": [path_text(s.steps) for s in sources if s.view is None],
|
|
597
|
+
"depth": depth,
|
|
598
|
+
"detail": detail_for(request.detail) if request.detail else None,
|
|
599
|
+
"budget": budget,
|
|
600
|
+
"inherit": inherit,
|
|
601
|
+
},
|
|
602
|
+
"items": items,
|
|
603
|
+
"groups": groups,
|
|
604
|
+
"truncated": truncated,
|
|
605
|
+
"used": used,
|
|
606
|
+
"dropped": {"unresolved": graph.dropped},
|
|
607
|
+
"policy": {
|
|
608
|
+
"layers": list(policy.layers),
|
|
609
|
+
"notes": notes,
|
|
610
|
+
"clamped": clamped,
|
|
611
|
+
"refused": refused,
|
|
612
|
+
},
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _node_payload(graph: Graph, schema: Schema, key: str) -> dict[str, Any]:
|
|
617
|
+
node = graph.nodes[key]
|
|
618
|
+
return {
|
|
619
|
+
"qualifiedName": key,
|
|
620
|
+
"kind": node.kind,
|
|
621
|
+
"kindLabel": schema.kind_label(node.kind),
|
|
622
|
+
"title": _title(node, schema.hints(node.kind)),
|
|
623
|
+
"description": _shorten(node.description or "", DESCRIPTION_LIMIT),
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
# ── the map ──────────────────────────────────────────────────────────────
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def build_map(
|
|
631
|
+
graph: Graph,
|
|
632
|
+
schema: Schema,
|
|
633
|
+
focus: str,
|
|
634
|
+
policy: Policy,
|
|
635
|
+
views: Mapping[str, View],
|
|
636
|
+
*,
|
|
637
|
+
size_of: SizeOf = json_size,
|
|
638
|
+
) -> dict[str, Any]:
|
|
639
|
+
"""Every relationship around *focus*, what it inherits, its views and the options."""
|
|
640
|
+
ancestors = graph.ancestors(focus, schema.parent_rel) if policy.inheritance else ()
|
|
641
|
+
rows: list[dict[str, Any]] = []
|
|
642
|
+
hidden: dict[str, str] = {}
|
|
643
|
+
steps: set[str] = set()
|
|
644
|
+
for start in (focus, *ancestors):
|
|
645
|
+
grouped: dict[tuple[str, str], dict[str, int]] = {}
|
|
646
|
+
for neighbour in graph.neighbours(start):
|
|
647
|
+
if start != focus and neighbour.rel == schema.parent_rel:
|
|
648
|
+
continue
|
|
649
|
+
other = graph.nodes[neighbour.other]
|
|
650
|
+
if neighbour.other == focus:
|
|
651
|
+
continue
|
|
652
|
+
text = f"{neighbour.rel}:{neighbour.direction}"
|
|
653
|
+
by = policy.hidden_by(neighbour.rel, neighbour.direction, other.kind)
|
|
654
|
+
if by is not None:
|
|
655
|
+
hidden.setdefault(text, by)
|
|
656
|
+
continue
|
|
657
|
+
if start == focus:
|
|
658
|
+
steps.add(text)
|
|
659
|
+
kinds = grouped.setdefault((neighbour.rel, neighbour.direction), {})
|
|
660
|
+
kinds[other.kind] = kinds.get(other.kind, 0) + 1
|
|
661
|
+
for (rel, direction), kinds in sorted(grouped.items()):
|
|
662
|
+
row: dict[str, Any] = {
|
|
663
|
+
"path": f"{rel}:{direction}",
|
|
664
|
+
"rel": rel,
|
|
665
|
+
"dir": direction,
|
|
666
|
+
"description": schema.relationship_description(rel),
|
|
667
|
+
"count": sum(kinds.values()),
|
|
668
|
+
"kinds": dict(sorted(kinds.items())),
|
|
669
|
+
"from": "self" if start == focus else start,
|
|
670
|
+
}
|
|
671
|
+
if start != focus:
|
|
672
|
+
row["inherited"] = True
|
|
673
|
+
rows.append(row)
|
|
674
|
+
for rel, targets in schema.carried(graph.nodes[focus].kind):
|
|
675
|
+
text = f"{rel}:{OUT}"
|
|
676
|
+
visible = (
|
|
677
|
+
any(policy.visible(rel, OUT, kind) for kind in targets)
|
|
678
|
+
if targets
|
|
679
|
+
else policy.step_hidden_by(Step(rel, OUT)) is None
|
|
680
|
+
)
|
|
681
|
+
if visible:
|
|
682
|
+
steps.add(text)
|
|
683
|
+
for text in list(hidden):
|
|
684
|
+
if text in steps:
|
|
685
|
+
del hidden[text]
|
|
686
|
+
|
|
687
|
+
view_rows: list[dict[str, Any]] = []
|
|
688
|
+
always = [name for name in policy.always or () if name in views]
|
|
689
|
+
for name, view in sorted(views.items(), key=lambda kv: (-kv[1].priority, kv[0])):
|
|
690
|
+
counted = build_result(
|
|
691
|
+
graph,
|
|
692
|
+
schema,
|
|
693
|
+
focus,
|
|
694
|
+
policy,
|
|
695
|
+
views,
|
|
696
|
+
ContextRequest(views=(name,), detail=REFS, budget=policy.budget_max),
|
|
697
|
+
size_of=lambda _item: 0,
|
|
698
|
+
)
|
|
699
|
+
view_rows.append(
|
|
700
|
+
{
|
|
701
|
+
"name": name,
|
|
702
|
+
"title": view.title,
|
|
703
|
+
"description": view.description,
|
|
704
|
+
"paths": view.path_texts,
|
|
705
|
+
"inherit": view.inherit,
|
|
706
|
+
"detail": view.detail,
|
|
707
|
+
"always": name in always,
|
|
708
|
+
"count": len(counted["items"]),
|
|
709
|
+
}
|
|
710
|
+
)
|
|
711
|
+
always_result = (
|
|
712
|
+
build_result(graph, schema, focus, policy, views, ContextRequest(), size_of=size_of)
|
|
713
|
+
if always
|
|
714
|
+
else None
|
|
715
|
+
)
|
|
716
|
+
address = focus
|
|
717
|
+
suggestions = [
|
|
718
|
+
f"taxonomy_context(node={address!r}, views=[{name!r}])"
|
|
719
|
+
for name in sorted(views)
|
|
720
|
+
if name not in always
|
|
721
|
+
]
|
|
722
|
+
present = [r["path"] for r in rows if r["from"] == "self" and r["rel"] != schema.parent_rel]
|
|
723
|
+
if present or steps:
|
|
724
|
+
first = present[0] if present else sorted(steps)[0]
|
|
725
|
+
suggestions.append(
|
|
726
|
+
f"taxonomy_context(node={address!r}, paths=[{first!r}], detail='summary')"
|
|
727
|
+
)
|
|
728
|
+
return {
|
|
729
|
+
"node": _node_payload(graph, schema, focus),
|
|
730
|
+
"ancestors": [
|
|
731
|
+
{
|
|
732
|
+
"qualifiedName": key,
|
|
733
|
+
"kind": graph.nodes[key].kind,
|
|
734
|
+
"title": _title(graph.nodes[key], schema.hints(graph.nodes[key].kind)),
|
|
735
|
+
}
|
|
736
|
+
for key in ancestors
|
|
737
|
+
],
|
|
738
|
+
"relationships": rows,
|
|
739
|
+
"hiddenByPolicy": [{"path": text, "limitedBy": by} for text, by in sorted(hidden.items())],
|
|
740
|
+
"views": view_rows,
|
|
741
|
+
"always": always_result,
|
|
742
|
+
"options": {
|
|
743
|
+
"steps": sorted(steps),
|
|
744
|
+
"maxDepth": policy.max_depth,
|
|
745
|
+
"detail": list(policy.detail),
|
|
746
|
+
"defaultDetail": policy.default_detail
|
|
747
|
+
if policy.default_detail in policy.detail
|
|
748
|
+
else REFS,
|
|
749
|
+
"budget": {
|
|
750
|
+
"default": min(policy.default_budget, policy.budget_max),
|
|
751
|
+
"max": policy.budget_max,
|
|
752
|
+
},
|
|
753
|
+
"inheritance": policy.inheritance,
|
|
754
|
+
},
|
|
755
|
+
"policy": {
|
|
756
|
+
"layers": list(policy.layers),
|
|
757
|
+
"notes": list(policy.notes),
|
|
758
|
+
"clamped": [],
|
|
759
|
+
"refused": [],
|
|
760
|
+
},
|
|
761
|
+
"next": suggestions,
|
|
762
|
+
}
|