katalyst-plugin-context 0.1.1a2__tar.gz → 0.2.1a4__tar.gz
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-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/PKG-INFO +1 -1
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/pyproject.toml +1 -1
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/src/katalyst_plugin_context/cli.py +3 -2
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/src/katalyst_plugin_context/domain/context.py +159 -39
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/src/katalyst_plugin_context/domain/markdown.py +21 -15
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/src/katalyst_plugin_context/domain/policy.py +8 -0
- katalyst_plugin_context-0.2.1a4/src/katalyst_plugin_context/lint.py +339 -0
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/src/katalyst_plugin_context/mcp_tools.py +7 -5
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/src/katalyst_plugin_context/service.py +2 -4
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/tests/test_context.py +158 -7
- katalyst_plugin_context-0.2.1a4/tests/test_lint.py +135 -0
- katalyst_plugin_context-0.1.1a2/src/katalyst_plugin_context/lint.py +0 -149
- katalyst_plugin_context-0.1.1a2/tests/test_lint.py +0 -71
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/.gitignore +0 -0
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/src/katalyst_plugin_context/__init__.py +0 -0
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/src/katalyst_plugin_context/adapters/__init__.py +0 -0
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/src/katalyst_plugin_context/adapters/runtime_graph.py +0 -0
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/src/katalyst_plugin_context/composition.py +0 -0
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/src/katalyst_plugin_context/domain/__init__.py +0 -0
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/src/katalyst_plugin_context/domain/graph.py +0 -0
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/src/katalyst_plugin_context/domain/paths.py +0 -0
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/src/katalyst_plugin_context/domain/settings.py +0 -0
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/src/katalyst_plugin_context/plugin.py +0 -0
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/src/katalyst_plugin_context/py.typed +0 -0
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/src/katalyst_plugin_context/rest.py +0 -0
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/src/katalyst_plugin_context/schema.py +0 -0
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/tests/conftest.py +0 -0
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/tests/context_support.py +0 -0
- {katalyst_plugin_context-0.1.1a2 → katalyst_plugin_context-0.2.1a4}/tests/test_surfaces.py +0 -0
|
@@ -94,8 +94,9 @@ def create_command() -> click.Command:
|
|
|
94
94
|
|
|
95
95
|
NODE is a qualified name, 'backend:fqtn' (or the FQTN when one backend holds it).
|
|
96
96
|
Without --map or --policy, prints the views NODE's kind always includes, or the
|
|
97
|
-
whole neighbourhood at depth 1 if it declares none; with --path or --view,
|
|
98
|
-
|
|
97
|
+
whole neighbourhood at depth 1 if it declares none; with --path or --view, those
|
|
98
|
+
and the always-included views. Policy from the schema, the node and the profile can
|
|
99
|
+
only narrow a request, and only policy can take an always-included view out.
|
|
99
100
|
|
|
100
101
|
Examples:
|
|
101
102
|
|
|
@@ -12,7 +12,7 @@ import json
|
|
|
12
12
|
import math
|
|
13
13
|
from collections.abc import Callable, Mapping, Sequence
|
|
14
14
|
from dataclasses import dataclass, field
|
|
15
|
-
from typing import Any, Protocol
|
|
15
|
+
from typing import Any, Protocol, cast
|
|
16
16
|
|
|
17
17
|
from katalyst_plugin_context.domain.graph import Graph, GraphNode
|
|
18
18
|
from katalyst_plugin_context.domain.paths import OUT, Step, parse_path, path_text
|
|
@@ -25,6 +25,7 @@ __all__ = [
|
|
|
25
25
|
"Schema",
|
|
26
26
|
"build_map",
|
|
27
27
|
"build_result",
|
|
28
|
+
"content_size",
|
|
28
29
|
"estimate_tokens",
|
|
29
30
|
]
|
|
30
31
|
|
|
@@ -119,6 +120,7 @@ def _walk(
|
|
|
119
120
|
policy: Policy,
|
|
120
121
|
*,
|
|
121
122
|
exclude: frozenset[str],
|
|
123
|
+
open_to_named: frozenset[str] = frozenset(),
|
|
122
124
|
inherited: bool,
|
|
123
125
|
parent_rel: str,
|
|
124
126
|
collect_every_level: bool,
|
|
@@ -126,8 +128,10 @@ def _walk(
|
|
|
126
128
|
"""Every node reached from *start* by following *steps*, with the hops that reached it.
|
|
127
129
|
|
|
128
130
|
A node never appears twice on one branch (cycles stop). *exclude* (the focus node and
|
|
129
|
-
its ancestors) is never reached
|
|
130
|
-
|
|
131
|
+
its ancestors) is never reached, except that a step which names its relationship
|
|
132
|
+
(not ``*``) may reach the nodes of *open_to_named*: asking for ``CHILD_OF:out`` is
|
|
133
|
+
asking for the ancestors. From an ancestor, its structural parent edges are never
|
|
134
|
+
followed: they would only reach siblings.
|
|
131
135
|
"""
|
|
132
136
|
frontier: list[tuple[str, tuple[_Hop, ...]]] = [(start, ())]
|
|
133
137
|
arrivals: list[_Arrival] = []
|
|
@@ -139,7 +143,11 @@ def _walk(
|
|
|
139
143
|
if inherited and index == 0 and neighbour.rel == parent_rel:
|
|
140
144
|
continue
|
|
141
145
|
other = graph.nodes[neighbour.other]
|
|
142
|
-
if neighbour.other in
|
|
146
|
+
if neighbour.other in on_branch:
|
|
147
|
+
continue
|
|
148
|
+
if neighbour.other in exclude and not (
|
|
149
|
+
step.rel != "*" and neighbour.other in open_to_named
|
|
150
|
+
):
|
|
143
151
|
continue
|
|
144
152
|
if not step.matches(neighbour.rel, neighbour.direction, other.kind):
|
|
145
153
|
continue
|
|
@@ -290,19 +298,50 @@ def _item_payload(
|
|
|
290
298
|
return payload
|
|
291
299
|
|
|
292
300
|
|
|
301
|
+
def _higher(a: str, b: str) -> str:
|
|
302
|
+
return max(a, b, key=DETAIL_LEVELS.index)
|
|
303
|
+
|
|
304
|
+
|
|
293
305
|
def _lower(level: str) -> list[str]:
|
|
294
306
|
"""*level* and every level below it, highest first."""
|
|
295
307
|
return list(reversed(DETAIL_LEVELS[: DETAIL_LEVELS.index(level) + 1]))
|
|
296
308
|
|
|
297
309
|
|
|
298
|
-
# ──
|
|
310
|
+
# ── the budget's measure ─────────────────────────────────────────────────
|
|
299
311
|
|
|
300
312
|
|
|
301
313
|
SizeOf = Callable[[dict[str, Any]], int]
|
|
302
314
|
|
|
303
315
|
|
|
304
|
-
def
|
|
305
|
-
return
|
|
316
|
+
def _text(value: object) -> str:
|
|
317
|
+
return value if isinstance(value, str) else json.dumps(value, default=str, ensure_ascii=False)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def content_size(item: Mapping[str, Any]) -> int:
|
|
321
|
+
"""The item's content in estimated tokens, whatever format the answer is given in.
|
|
322
|
+
|
|
323
|
+
Counts what a reader reads: the qualified name, kind and title, each ``why``, each
|
|
324
|
+
blocking value, the description, the fields, and at ``full`` the spec and the body.
|
|
325
|
+
No keys, punctuation or markup, so a JSON and a markdown answer hold the same items
|
|
326
|
+
for the same budget.
|
|
327
|
+
"""
|
|
328
|
+
parts: list[str] = [str(item["qualifiedName"]), str(item["kind"]), str(item["title"])]
|
|
329
|
+
parts.extend(str(why["text"]) for why in item.get("why") or ())
|
|
330
|
+
blocking = item.get("blocking")
|
|
331
|
+
entries = cast(
|
|
332
|
+
"list[Mapping[str, Any]]",
|
|
333
|
+
blocking if isinstance(blocking, list) else [blocking] if blocking else [],
|
|
334
|
+
)
|
|
335
|
+
parts.extend(f"{entry['field']}: {entry['value']}" for entry in entries)
|
|
336
|
+
if item.get("description"):
|
|
337
|
+
parts.append(str(item["description"]))
|
|
338
|
+
fields: Mapping[str, Any] = item.get("fields") or {}
|
|
339
|
+
parts.extend(f"{name}: {_text(value)}" for name, value in fields.items())
|
|
340
|
+
spec: Mapping[str, Any] = item.get("spec") or {}
|
|
341
|
+
parts.extend(f"{name}: {_text(value)}" for name, value in spec.items() if name not in fields)
|
|
342
|
+
if item.get("body"):
|
|
343
|
+
parts.append(str(item["body"]))
|
|
344
|
+
return estimate_tokens("\n".join(parts))
|
|
306
345
|
|
|
307
346
|
|
|
308
347
|
# ── the result ───────────────────────────────────────────────────────────
|
|
@@ -319,10 +358,20 @@ def _sources(
|
|
|
319
358
|
inherit: bool,
|
|
320
359
|
refused: list[dict[str, Any]],
|
|
321
360
|
notes: list[dict[str, Any]],
|
|
361
|
+
dropped: list[dict[str, Any]],
|
|
322
362
|
) -> list[_Source]:
|
|
363
|
+
"""What to walk: the always-included views first, then what the request names.
|
|
364
|
+
|
|
365
|
+
An always-included view the request does not name keeps its own detail and
|
|
366
|
+
inheritance (clamped by policy): the request's ``detail`` and ``inherit`` apply to what
|
|
367
|
+
it names. A bare request names the always-included views.
|
|
368
|
+
"""
|
|
323
369
|
sources: list[_Source] = []
|
|
324
370
|
|
|
325
|
-
def add_view(view: View, section: int) -> None:
|
|
371
|
+
def add_view(view: View, section: int, *, detail: str, walks_up: bool) -> str | None:
|
|
372
|
+
"""Add *view*'s paths; the layer that left it none, or ``None``."""
|
|
373
|
+
limited: str | None = None
|
|
374
|
+
kept = False
|
|
326
375
|
for index, steps in enumerate(view.paths):
|
|
327
376
|
hidden = next(
|
|
328
377
|
(by for by in (policy.step_hidden_by(s) for s in steps) if by is not None), None
|
|
@@ -336,6 +385,7 @@ def _sources(
|
|
|
336
385
|
"text": f"view {view.name!r} path {path_text(steps)!r} is hidden by {hidden}",
|
|
337
386
|
}
|
|
338
387
|
)
|
|
388
|
+
limited = limited or hidden
|
|
339
389
|
continue
|
|
340
390
|
if len(steps) > policy.max_depth:
|
|
341
391
|
notes.append(
|
|
@@ -349,29 +399,58 @@ def _sources(
|
|
|
349
399
|
)
|
|
350
400
|
steps = steps[: policy.max_depth]
|
|
351
401
|
if not steps:
|
|
402
|
+
limited = limited or policy.max_depth_by
|
|
352
403
|
continue
|
|
404
|
+
kept = True
|
|
353
405
|
sources.append(
|
|
354
406
|
_Source(
|
|
355
407
|
section=section,
|
|
356
408
|
view=view,
|
|
357
409
|
steps=steps,
|
|
358
410
|
path_index=index,
|
|
359
|
-
detail=
|
|
360
|
-
inherit=
|
|
411
|
+
detail=detail,
|
|
412
|
+
inherit=walks_up,
|
|
361
413
|
repeat=1,
|
|
362
414
|
)
|
|
363
415
|
)
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
416
|
+
return None if kept else limited
|
|
417
|
+
|
|
418
|
+
bare = request.names_nothing
|
|
419
|
+
named = set(request.views or ())
|
|
420
|
+
default_inherit, _ = policy.clamp_inherit(None)
|
|
421
|
+
for name in sorted(always, key=lambda n: -views[n].priority):
|
|
422
|
+
view = views[name]
|
|
423
|
+
asked = bare or name in named
|
|
424
|
+
limited = add_view(
|
|
425
|
+
view,
|
|
426
|
+
0,
|
|
427
|
+
detail=detail_for(request.detail or view.detail) if asked else detail_for(view.detail),
|
|
428
|
+
walks_up=view.inherit and (inherit if asked else default_inherit),
|
|
429
|
+
)
|
|
430
|
+
if limited is not None:
|
|
431
|
+
dropped.append(
|
|
432
|
+
{
|
|
433
|
+
"view": name,
|
|
434
|
+
"limitedBy": limited,
|
|
435
|
+
"text": f"always-included view {name!r} has no path left by {limited}",
|
|
436
|
+
}
|
|
437
|
+
)
|
|
438
|
+
if bare:
|
|
439
|
+
if not always:
|
|
369
440
|
sources.append(
|
|
370
441
|
_Source(3, None, (Step("*"),), 0, detail_for(request.detail), inherit, depth)
|
|
371
442
|
)
|
|
372
443
|
return sources
|
|
373
444
|
for name in sorted(request.views or (), key=lambda n: -views[n].priority):
|
|
374
|
-
|
|
445
|
+
if name in always:
|
|
446
|
+
continue
|
|
447
|
+
view = views[name]
|
|
448
|
+
add_view(
|
|
449
|
+
view,
|
|
450
|
+
1,
|
|
451
|
+
detail=detail_for(request.detail or view.detail),
|
|
452
|
+
walks_up=view.inherit and inherit,
|
|
453
|
+
)
|
|
375
454
|
for index, text in enumerate(request.paths or ()):
|
|
376
455
|
steps = parse_path(text)
|
|
377
456
|
hidden = next(
|
|
@@ -413,12 +492,20 @@ def _collect(
|
|
|
413
492
|
ancestors: Sequence[str],
|
|
414
493
|
policy: Policy,
|
|
415
494
|
sources: Sequence[_Source],
|
|
495
|
+
*,
|
|
496
|
+
inherit: bool,
|
|
416
497
|
) -> tuple[dict[str, _Entry], list[dict[str, Any]]]:
|
|
498
|
+
"""Every item *sources* reach, at its best rank, and each view's and path's members.
|
|
499
|
+
|
|
500
|
+
The ancestors are never items of a source that inherits, or of any source when the
|
|
501
|
+
call inherits, unless a step names the relationship that reaches them; otherwise
|
|
502
|
+
(inheritance off) they can be.
|
|
503
|
+
"""
|
|
417
504
|
entries: dict[str, _Entry] = {}
|
|
418
|
-
exclude = frozenset({focus, *ancestors})
|
|
419
505
|
group_members: dict[tuple[str | None, str], set[str]] = {}
|
|
420
506
|
for source in sources:
|
|
421
507
|
starts = [focus, *(ancestors if source.inherit else ())]
|
|
508
|
+
exclude = frozenset({focus, *(ancestors if source.inherit or inherit else ())})
|
|
422
509
|
steps = source.steps * source.repeat if source.repeat > 1 else source.steps
|
|
423
510
|
text = path_text(source.steps)
|
|
424
511
|
group = group_members.setdefault((source.view.name if source.view else None, text), set())
|
|
@@ -429,6 +516,7 @@ def _collect(
|
|
|
429
516
|
steps,
|
|
430
517
|
policy,
|
|
431
518
|
exclude=exclude,
|
|
519
|
+
open_to_named=frozenset(ancestors),
|
|
432
520
|
inherited=start != focus,
|
|
433
521
|
parent_rel=schema.parent_rel,
|
|
434
522
|
collect_every_level=source.repeat > 1,
|
|
@@ -456,6 +544,8 @@ def _collect(
|
|
|
456
544
|
)
|
|
457
545
|
group.add(node.key)
|
|
458
546
|
entry = entries.get(node.key)
|
|
547
|
+
# The best-ranked source places the item; the highest detail asked shows it.
|
|
548
|
+
detail = _higher(entry.detail, source.detail) if entry else source.detail
|
|
459
549
|
if entry is None or rank < entry.rank:
|
|
460
550
|
previous = entry.whys if entry is not None else []
|
|
461
551
|
entry = _Entry(
|
|
@@ -463,13 +553,14 @@ def _collect(
|
|
|
463
553
|
rank=rank,
|
|
464
554
|
view=source.view.name if source.view else None,
|
|
465
555
|
path=text,
|
|
466
|
-
detail=
|
|
556
|
+
detail=detail,
|
|
467
557
|
inherited_from=None if start == focus else start,
|
|
468
558
|
depth=len(arrival.hops),
|
|
469
559
|
whys=previous,
|
|
470
560
|
blocking=blocking,
|
|
471
561
|
)
|
|
472
562
|
entries[node.key] = entry
|
|
563
|
+
entry.detail = detail
|
|
473
564
|
if not any(w[1].hops == arrival.hops for w in entry.whys):
|
|
474
565
|
entry.whys.append((rank, arrival, text))
|
|
475
566
|
groups = [
|
|
@@ -479,6 +570,10 @@ def _collect(
|
|
|
479
570
|
return entries, groups
|
|
480
571
|
|
|
481
572
|
|
|
573
|
+
def _blocks(entry: _Entry) -> bool:
|
|
574
|
+
return any(b.get("blocking") for b in entry.blocking)
|
|
575
|
+
|
|
576
|
+
|
|
482
577
|
def build_result(
|
|
483
578
|
graph: Graph,
|
|
484
579
|
schema: Schema,
|
|
@@ -487,9 +582,16 @@ def build_result(
|
|
|
487
582
|
views: Mapping[str, View],
|
|
488
583
|
request: ContextRequest,
|
|
489
584
|
*,
|
|
490
|
-
size_of: SizeOf =
|
|
585
|
+
size_of: SizeOf = content_size,
|
|
586
|
+
include_always: bool = True,
|
|
491
587
|
) -> dict[str, Any]:
|
|
492
|
-
"""The items *request* asks for around *focus*, budgeted, with why each is here.
|
|
588
|
+
"""The items *request* asks for around *focus*, budgeted, with why each is here.
|
|
589
|
+
|
|
590
|
+
The always-included views are in every answer, whatever the request names; policy
|
|
591
|
+
alone can take them out. The budget places blocking items first, then the rest, each
|
|
592
|
+
in rank order (so always-included items first): no blocking item is cut while a
|
|
593
|
+
non-blocking one stays. *include_always* is off only for the map's per-view counts.
|
|
594
|
+
"""
|
|
493
595
|
unknown = [name for name in request.views or () if name not in views]
|
|
494
596
|
if unknown:
|
|
495
597
|
raise ContextError(
|
|
@@ -520,8 +622,20 @@ def build_result(
|
|
|
520
622
|
requested_detail_note.append(note)
|
|
521
623
|
return level
|
|
522
624
|
|
|
523
|
-
|
|
524
|
-
|
|
625
|
+
always = [name for name in policy.always or () if name in views] if include_always else []
|
|
626
|
+
dropped_always: list[dict[str, Any]] = (
|
|
627
|
+
[
|
|
628
|
+
{
|
|
629
|
+
"view": name,
|
|
630
|
+
"limitedBy": by,
|
|
631
|
+
"text": f"{by} takes {name!r} out of the always-included views",
|
|
632
|
+
}
|
|
633
|
+
for name, by in policy.always_dropped
|
|
634
|
+
if name in views
|
|
635
|
+
]
|
|
636
|
+
if include_always
|
|
637
|
+
else []
|
|
638
|
+
)
|
|
525
639
|
sources = _sources(
|
|
526
640
|
request,
|
|
527
641
|
policy,
|
|
@@ -532,39 +646,43 @@ def build_result(
|
|
|
532
646
|
inherit=inherit,
|
|
533
647
|
refused=refused,
|
|
534
648
|
notes=notes,
|
|
649
|
+
dropped=dropped_always,
|
|
535
650
|
)
|
|
536
651
|
clamped.extend(requested_detail_note)
|
|
537
|
-
|
|
652
|
+
walks_up = inherit or any(s.inherit for s in sources)
|
|
653
|
+
ancestors = graph.ancestors(focus, schema.parent_rel) if walks_up else ()
|
|
654
|
+
entries, groups = _collect(graph, schema, focus, ancestors, policy, sources, inherit=inherit)
|
|
538
655
|
ordered = sorted(entries.values(), key=lambda e: e.rank)
|
|
656
|
+
placing = sorted(ordered, key=lambda e: (not _blocks(e), e.rank))
|
|
539
657
|
|
|
540
|
-
|
|
658
|
+
placed: dict[str, dict[str, Any]] = {}
|
|
541
659
|
downgraded: list[dict[str, Any]] = []
|
|
542
660
|
cut: list[_Entry] = []
|
|
543
661
|
used = 0
|
|
544
|
-
for position, entry in enumerate(
|
|
545
|
-
|
|
662
|
+
for position, entry in enumerate(placing):
|
|
663
|
+
fits = False
|
|
546
664
|
for level in _lower(entry.detail):
|
|
547
665
|
payload = _item_payload(entry, level, graph, schema, focus)
|
|
548
666
|
cost = size_of(payload)
|
|
549
667
|
if used + cost <= budget:
|
|
550
|
-
|
|
668
|
+
placed[entry.node.key] = payload
|
|
551
669
|
used += cost
|
|
552
670
|
if level != entry.detail:
|
|
553
671
|
downgraded.append(
|
|
554
672
|
{"qualifiedName": entry.node.key, "from": entry.detail, "to": level}
|
|
555
673
|
)
|
|
556
|
-
|
|
674
|
+
fits = True
|
|
557
675
|
break
|
|
558
|
-
if not
|
|
559
|
-
cut =
|
|
676
|
+
if not fits:
|
|
677
|
+
cut = sorted(placing[position:], key=lambda e: e.rank)
|
|
560
678
|
break
|
|
679
|
+
items = [placed[e.node.key] for e in ordered if e.node.key in placed]
|
|
561
680
|
|
|
562
|
-
returned = {item["qualifiedName"] for item in items}
|
|
563
681
|
for group in groups:
|
|
564
682
|
group["returned"] = sum(
|
|
565
683
|
1
|
|
566
684
|
for e in entries.values()
|
|
567
|
-
if e.node.key in
|
|
685
|
+
if e.node.key in placed and (e.view, e.path) == (group["view"], group["path"])
|
|
568
686
|
)
|
|
569
687
|
truncated: dict[str, Any] | None = None
|
|
570
688
|
if cut or downgraded:
|
|
@@ -586,13 +704,16 @@ def build_result(
|
|
|
586
704
|
for (view, path), names in by_group.items()
|
|
587
705
|
],
|
|
588
706
|
"cutTotal": len(cut),
|
|
707
|
+
"cutBlocking": sum(1 for e in cut if _blocks(e)),
|
|
589
708
|
"hint": {"budget": min(max(whole, budget + 1), policy.budget_max)},
|
|
590
709
|
}
|
|
710
|
+
included = list(dict.fromkeys(s.view.name for s in sources if s.view is not None))
|
|
591
711
|
return {
|
|
592
712
|
"node": _node_payload(graph, schema, focus),
|
|
593
713
|
"request": request.as_payload(),
|
|
594
714
|
"effective": {
|
|
595
|
-
"views":
|
|
715
|
+
"views": included,
|
|
716
|
+
"always": [name for name in always if name in included],
|
|
596
717
|
"paths": [path_text(s.steps) for s in sources if s.view is None],
|
|
597
718
|
"depth": depth,
|
|
598
719
|
"detail": detail_for(request.detail) if request.detail else None,
|
|
@@ -603,7 +724,7 @@ def build_result(
|
|
|
603
724
|
"groups": groups,
|
|
604
725
|
"truncated": truncated,
|
|
605
726
|
"used": used,
|
|
606
|
-
"dropped": {"unresolved": graph.dropped},
|
|
727
|
+
"dropped": {"unresolved": graph.dropped, "always": dropped_always},
|
|
607
728
|
"policy": {
|
|
608
729
|
"layers": list(policy.layers),
|
|
609
730
|
"notes": notes,
|
|
@@ -633,8 +754,6 @@ def build_map(
|
|
|
633
754
|
focus: str,
|
|
634
755
|
policy: Policy,
|
|
635
756
|
views: Mapping[str, View],
|
|
636
|
-
*,
|
|
637
|
-
size_of: SizeOf = json_size,
|
|
638
757
|
) -> dict[str, Any]:
|
|
639
758
|
"""Every relationship around *focus*, what it inherits, its views and the options."""
|
|
640
759
|
ancestors = graph.ancestors(focus, schema.parent_rel) if policy.inheritance else ()
|
|
@@ -686,6 +805,7 @@ def build_map(
|
|
|
686
805
|
|
|
687
806
|
view_rows: list[dict[str, Any]] = []
|
|
688
807
|
always = [name for name in policy.always or () if name in views]
|
|
808
|
+
dropped_by = dict(policy.always_dropped)
|
|
689
809
|
for name, view in sorted(views.items(), key=lambda kv: (-kv[1].priority, kv[0])):
|
|
690
810
|
counted = build_result(
|
|
691
811
|
graph,
|
|
@@ -695,6 +815,7 @@ def build_map(
|
|
|
695
815
|
views,
|
|
696
816
|
ContextRequest(views=(name,), detail=REFS, budget=policy.budget_max),
|
|
697
817
|
size_of=lambda _item: 0,
|
|
818
|
+
include_always=False,
|
|
698
819
|
)
|
|
699
820
|
view_rows.append(
|
|
700
821
|
{
|
|
@@ -705,13 +826,12 @@ def build_map(
|
|
|
705
826
|
"inherit": view.inherit,
|
|
706
827
|
"detail": view.detail,
|
|
707
828
|
"always": name in always,
|
|
829
|
+
"droppedBy": dropped_by.get(name),
|
|
708
830
|
"count": len(counted["items"]),
|
|
709
831
|
}
|
|
710
832
|
)
|
|
711
833
|
always_result = (
|
|
712
|
-
build_result(graph, schema, focus, policy, views, ContextRequest()
|
|
713
|
-
if always
|
|
714
|
-
else None
|
|
834
|
+
build_result(graph, schema, focus, policy, views, ContextRequest()) if always else None
|
|
715
835
|
)
|
|
716
836
|
address = focus
|
|
717
837
|
suggestions = [
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"""Agent-ready markdown for a context result or map, built from the same payload as JSON.
|
|
2
2
|
|
|
3
|
-
The two formats always agree because the markdown is rendered from the result object
|
|
4
|
-
|
|
5
|
-
(:func
|
|
3
|
+
The two formats always agree because the markdown is rendered from the result object,
|
|
4
|
+
and the budget measures an item's content, not either format
|
|
5
|
+
(:func:`~katalyst_plugin_context.domain.context.content_size`).
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
@@ -10,9 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
from collections.abc import Mapping, Sequence
|
|
11
11
|
from typing import Any, cast
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
__all__ = ["item_block", "item_size", "render_map", "render_result"]
|
|
13
|
+
__all__ = ["item_block", "render_map", "render_result"]
|
|
16
14
|
|
|
17
15
|
|
|
18
16
|
def _blocking_marker(item: Mapping[str, Any]) -> str:
|
|
@@ -58,11 +56,6 @@ def item_block(item: Mapping[str, Any]) -> str:
|
|
|
58
56
|
return "\n".join(lines)
|
|
59
57
|
|
|
60
58
|
|
|
61
|
-
def item_size(item: dict[str, Any]) -> int:
|
|
62
|
-
"""The item's markdown block, in estimated tokens."""
|
|
63
|
-
return estimate_tokens(item_block(item))
|
|
64
|
-
|
|
65
|
-
|
|
66
59
|
def _heading(node: Mapping[str, Any]) -> list[str]:
|
|
67
60
|
lines = [f"# Context: {node['kind']} `{node['qualifiedName']}`"]
|
|
68
61
|
if node.get("description"):
|
|
@@ -124,6 +117,8 @@ def render_result(
|
|
|
124
117
|
names = ", ".join(f"`{n}`" for n in cast("list[str]", group["qualifiedNames"]))
|
|
125
118
|
where = f"view {group['view']}, " if group.get("view") else ""
|
|
126
119
|
lines.append(f"- {where}`{group['path']}`: {group['count']} cut — {names}")
|
|
120
|
+
if truncated.get("cutBlocking"):
|
|
121
|
+
lines.append(f"- ⛔ {truncated['cutBlocking']} of them blocking")
|
|
127
122
|
downgraded = cast("list[Any]", truncated.get("downgraded") or [])
|
|
128
123
|
if downgraded:
|
|
129
124
|
lines.append(f"- {len(downgraded)} shown with less detail than asked")
|
|
@@ -131,12 +126,17 @@ def render_result(
|
|
|
131
126
|
if hint.get("budget"):
|
|
132
127
|
lines.append(f"More: ask again with budget {hint['budget']}.")
|
|
133
128
|
policy = cast("Mapping[str, Any]", result.get("policy") or {})
|
|
134
|
-
|
|
129
|
+
dropped = cast("Mapping[str, Any]", result.get("dropped") or {})
|
|
130
|
+
lines.extend(
|
|
131
|
+
_policy_lines(policy, cast("list[Mapping[str, Any]]", dropped.get("always") or []))
|
|
132
|
+
)
|
|
135
133
|
return "\n".join(lines).strip() + "\n"
|
|
136
134
|
|
|
137
135
|
|
|
138
|
-
def _policy_lines(
|
|
139
|
-
|
|
136
|
+
def _policy_lines(
|
|
137
|
+
policy: Mapping[str, Any], dropped_always: Sequence[Mapping[str, Any]] = ()
|
|
138
|
+
) -> list[str]:
|
|
139
|
+
said: list[str] = [f"- {entry['text']}" for entry in dropped_always]
|
|
140
140
|
for key in ("notes", "clamped", "refused"):
|
|
141
141
|
for entry in cast("list[Mapping[str, Any]]", policy.get(key) or []):
|
|
142
142
|
text = entry.get("text") or (
|
|
@@ -173,7 +173,13 @@ def render_map(context_map: Mapping[str, Any]) -> str:
|
|
|
173
173
|
lines.append("")
|
|
174
174
|
lines.append("## Views")
|
|
175
175
|
for view in view_rows:
|
|
176
|
-
flag =
|
|
176
|
+
flag = (
|
|
177
|
+
" (always included)"
|
|
178
|
+
if view.get("always")
|
|
179
|
+
else f" (taken out of always-included by {view['droppedBy']})"
|
|
180
|
+
if view.get("droppedBy")
|
|
181
|
+
else ""
|
|
182
|
+
)
|
|
177
183
|
lines.append(f"- **{view['name']}**{flag}: {view['title']} — {view['count']} item(s)")
|
|
178
184
|
options = cast("Mapping[str, Any]", context_map.get("options") or {})
|
|
179
185
|
lines.append("")
|
|
@@ -14,6 +14,9 @@ Each control narrows in its own way:
|
|
|
14
14
|
``always_include`` the first declaring layer sets it; later layers intersect
|
|
15
15
|
================= ===========================================================
|
|
16
16
|
|
|
17
|
+
A view a later layer takes out of ``always_include`` is recorded (:attr:`Policy.always_dropped`)
|
|
18
|
+
so an answer can say which layer excluded it: the operator's control, not a widening.
|
|
19
|
+
|
|
17
20
|
A layer that tries to widen is not an error. Its declaration has no effect, and the
|
|
18
21
|
effective policy records a note saying which layer limited it. Defaults (``depth``,
|
|
19
22
|
``detail``, ``budget``, ``inherit``) are not limits: a later layer's default replaces an
|
|
@@ -73,6 +76,8 @@ class Policy:
|
|
|
73
76
|
inheritance_by: str = BUILTIN
|
|
74
77
|
always: tuple[str, ...] | None = None
|
|
75
78
|
always_by: str = ""
|
|
79
|
+
always_dropped: list[tuple[str, str]] = field(default_factory=lambda: list[tuple[str, str]]())
|
|
80
|
+
"""Views an earlier layer always included and a later one took out, with that layer."""
|
|
76
81
|
default_depth: int = _BUILTIN_DEPTH
|
|
77
82
|
default_detail: str | None = None
|
|
78
83
|
default_budget: int = _BUILTIN_BUDGET
|
|
@@ -160,6 +165,7 @@ class Policy:
|
|
|
160
165
|
},
|
|
161
166
|
"inheritance": self.inheritance,
|
|
162
167
|
"alwaysInclude": list(self.always or ()),
|
|
168
|
+
"alwaysDropped": [{"view": v, "limitedBy": by} for v, by in self.always_dropped],
|
|
163
169
|
"notes": list(self.notes),
|
|
164
170
|
}
|
|
165
171
|
|
|
@@ -327,6 +333,8 @@ def _fold_limits(policy: Policy, layer: Layer, declared_views: set[str]) -> None
|
|
|
327
333
|
)
|
|
328
334
|
)
|
|
329
335
|
kept = tuple(n for n in names if n in declared_views)
|
|
336
|
+
if policy.always is not None:
|
|
337
|
+
policy.always_dropped.extend((n, layer.label) for n in policy.always if n not in kept)
|
|
330
338
|
policy.always = (
|
|
331
339
|
kept if policy.always is None else tuple(n for n in policy.always if n in kept)
|
|
332
340
|
)
|