Skip to content

Commit a8c929d

Browse files
committed
feat(organization): add semantic organization behaviors
1 parent 3d013f1 commit a8c929d

62 files changed

Lines changed: 4666 additions & 486 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.changes/100.added.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Add extensible semantic Organization behaviors, bounded graph-use queries, and Human-reviewed
2+
acceptance fixtures; also recognize schema-v3 SVC development database provider configuration.
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
from .main import GraphNavigationRetrievalManager
1+
from .main import GraphNavigationQueryContract, GraphNavigationRetrievalManager
22

3-
__all__ = ["GraphNavigationRetrievalManager"]
3+
__all__ = ["GraphNavigationQueryContract", "GraphNavigationRetrievalManager"]

‎app/business/graph_navigation_retrieval/main.py‎

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
11
"""Bounded, presentation-neutral navigation over persisted graph authority."""
22

3+
from collections import deque
4+
from collections.abc import Collection
5+
from dataclasses import dataclass
6+
import inspect
37
import typing
48

9+
import pydantic
510
import sqlmodel
611

712
from app.business.info_base.block import BlockManager
813
from app.business.info_base.relation import RelationManager
914
from app.engine import SessionLocal
1015
from app.schemas.graph_navigation_retrieval import (
1116
BlockNeighborhood,
17+
ConnectedComponentsResult,
18+
ConnectedSeedComponent,
1219
GraphDirection,
1320
GraphModel,
1421
PathFound,
@@ -28,11 +35,78 @@
2835
DEFAULT_MAX_EXPLORED_BLOCKS = 1000
2936
MAX_MAX_EXPLORED_BLOCKS = 10000
3037
FRONTIER_QUERY_SIZE = 200
38+
DEFAULT_MAX_EXPLORED_RELATIONS = 10000
39+
40+
41+
@dataclass(frozen=True)
42+
class GraphNavigationQueryContract:
43+
name: str
44+
description: str
45+
input_model: type[pydantic.BaseModel]
46+
47+
@property
48+
def input_schema(self) -> dict[str, typing.Any]:
49+
return self.input_model.model_json_schema()
3150

3251

3352
class GraphNavigationRetrievalManager:
3453
"""Own graph-navigation semantics while hiding query and closure mechanics."""
3554

55+
@classmethod
56+
def get_query_contracts(cls) -> tuple[GraphNavigationQueryContract, ...]:
57+
"""Describe public typed graph queries without exposing runtime sessions."""
58+
contracts: list[GraphNavigationQueryContract] = []
59+
names = (
60+
name
61+
for name in dir(cls)
62+
if name.startswith(("get_", "find_"))
63+
and name not in {"get_query_contract", "get_query_contracts"}
64+
)
65+
for name in names:
66+
function = getattr(cls, name)
67+
signature = inspect.signature(function, eval_str=True)
68+
fields: dict[str, tuple[typing.Any, typing.Any]] = {}
69+
for parameter in signature.parameters.values():
70+
if parameter.name == "db_session":
71+
continue
72+
if parameter.annotation is inspect.Parameter.empty:
73+
raise TypeError(f"Graph query {name} parameter {parameter.name} must be typed")
74+
default = ... if parameter.default is inspect.Parameter.empty else parameter.default
75+
annotation = parameter.annotation
76+
if typing.get_origin(annotation) is Collection:
77+
item_type = typing.get_args(annotation)[0]
78+
annotation = tuple[item_type, ...]
79+
fields[parameter.name] = (annotation, default)
80+
input_model = typing.cast(typing.Any, pydantic.create_model)(
81+
f"GraphNavigation_{name}_Arguments",
82+
__config__=pydantic.ConfigDict(extra="forbid"),
83+
**fields,
84+
)
85+
input_model.model_json_schema()
86+
contracts.append(
87+
GraphNavigationQueryContract(
88+
name=name,
89+
description=inspect.getdoc(function) or name.replace("_", " "),
90+
input_model=input_model,
91+
)
92+
)
93+
return tuple(contracts)
94+
95+
@classmethod
96+
def get_query_contract(cls, name: str) -> GraphNavigationQueryContract | None:
97+
return next(
98+
(contract for contract in cls.get_query_contracts() if contract.name == name),
99+
None,
100+
)
101+
102+
@classmethod
103+
def invoke_query(cls, name: str, arguments: dict[str, typing.Any]) -> typing.Any:
104+
contract = cls.get_query_contract(name)
105+
if contract is None:
106+
raise ValueError("Graph navigation query is not available")
107+
validated = contract.input_model.model_validate(arguments)
108+
return getattr(cls, name)(**validated.model_dump())
109+
36110
@classmethod
37111
def get_random_block(
38112
cls,
@@ -143,6 +217,122 @@ def get_relation_neighborhood(
143217
graph=GraphModel(blocks=blocks, relations=(relation,)),
144218
)
145219

220+
@classmethod
221+
def get_connected_components(
222+
cls,
223+
seed_block_ids: typing.Collection[BlockID],
224+
*,
225+
contents: typing.Collection[str],
226+
max_explored_blocks: int = DEFAULT_MAX_EXPLORED_BLOCKS,
227+
max_explored_relations: int = DEFAULT_MAX_EXPLORED_RELATIONS,
228+
db_session: sqlmodel.Session | None = None,
229+
) -> ConnectedComponentsResult:
230+
"""Partition existing seeds by bounded undirected exact-content reachability."""
231+
seeds = tuple(dict.fromkeys(seed_block_ids))
232+
relation_contents = tuple(dict.fromkeys(contents))
233+
if not relation_contents:
234+
raise ValueError("contents must not be empty")
235+
if max_explored_blocks < 1 or max_explored_relations < 1:
236+
raise ValueError("exploration bounds must be positive")
237+
if len(seeds) > max_explored_blocks:
238+
raise ValueError("seed blocks exceed max_explored_blocks")
239+
if db_session is None:
240+
with SessionLocal() as owned_session:
241+
return cls.get_connected_components(
242+
seeds,
243+
contents=relation_contents,
244+
max_explored_blocks=max_explored_blocks,
245+
max_explored_relations=max_explored_relations,
246+
db_session=owned_session,
247+
)
248+
249+
existing_blocks = BlockManager.get_many(seeds, db_session)
250+
existing_seed_ids = {block.id for block in existing_blocks if block.id is not None}
251+
missing = tuple(seed for seed in seeds if seed not in existing_seed_ids)
252+
assigned_seeds: set[BlockID] = set()
253+
explored_blocks = set(existing_seed_ids)
254+
seen_relations: set[RelationID] = set()
255+
explored_relation_count = 0
256+
proof_relations: dict[RelationID, RelationModel] = {}
257+
components: list[ConnectedSeedComponent] = []
258+
truncated = False
259+
260+
for seed in seeds:
261+
if seed not in existing_seed_ids or seed in assigned_seeds:
262+
continue
263+
if truncated:
264+
components.append(
265+
ConnectedSeedComponent(seed_blocks=(seed,), member_blocks=(seed,))
266+
)
267+
assigned_seeds.add(seed)
268+
continue
269+
270+
members = {seed}
271+
frontier = deque((seed,))
272+
while frontier and not truncated:
273+
current = frontier.popleft()
274+
for endpoint in ("from", "to"):
275+
cursor: RelationID | None = None
276+
while not truncated:
277+
remaining = max_explored_relations - explored_relation_count
278+
if remaining == 0:
279+
truncated = True
280+
break
281+
requested = min(FRONTIER_QUERY_SIZE, remaining + 1)
282+
page = RelationManager.get_endpoint_page(
283+
(current,),
284+
endpoint=typing.cast(typing.Literal["from", "to"], endpoint),
285+
contents=relation_contents,
286+
cursor=cursor,
287+
limit=requested,
288+
db_session=db_session,
289+
)
290+
if len(page) > remaining:
291+
page = page[:remaining]
292+
truncated = True
293+
explored_relation_count += len(page)
294+
for relation in page:
295+
if relation.id is None or relation.id in seen_relations:
296+
continue
297+
relation_id = relation.id
298+
seen_relations.add(relation_id)
299+
neighbor = relation.to_ if relation.from_ == current else relation.from_
300+
if neighbor in members:
301+
continue
302+
if (
303+
neighbor not in explored_blocks
304+
and len(explored_blocks) >= max_explored_blocks
305+
):
306+
truncated = True
307+
break
308+
members.add(neighbor)
309+
explored_blocks.add(neighbor)
310+
frontier.append(neighbor)
311+
proof_relations[relation_id] = relation
312+
if truncated or len(page) < requested:
313+
break
314+
cursor = typing.cast(RelationID, page[-1].id)
315+
316+
component_seeds = tuple(seed_id for seed_id in seeds if seed_id in members)
317+
assigned_seeds.update(component_seeds)
318+
components.append(
319+
ConnectedSeedComponent(
320+
seed_blocks=component_seeds,
321+
member_blocks=tuple(sorted(members)),
322+
)
323+
)
324+
325+
proof_blocks = BlockManager.get_many(explored_blocks, db_session)
326+
return ConnectedComponentsResult(
327+
components=tuple(components),
328+
proof_graph=GraphModel(
329+
blocks=proof_blocks,
330+
relations=tuple(proof_relations.values()),
331+
),
332+
missing_seed_blocks=missing,
333+
truncated=truncated,
334+
)
335+
146336
@classmethod
147337
def find_path( # noqa: PLR0913
148338
cls,

‎app/business/info_base/resolver/__init__.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@
1010
UnknownResolverError,
1111
UnsupportedResolverCapability,
1212
)
13-
from .main import Resolver, ResolverDraftCapability, ResolverManager
13+
from .main import (
14+
Resolver,
15+
ResolverDraftCapability,
16+
ResolverManager,
17+
ResolverMethodContract,
18+
)
1419

1520
__all__ = [
1621
"ResolverManager",
@@ -26,6 +31,7 @@
2631
"ResolverContentError",
2732
"UnsupportedResolverCapability",
2833
"ResolverDraftCapability",
34+
"ResolverMethodContract",
2935
"AudioResolver",
3036
"EPUBResolver",
3137
"FileResolver",

‎app/business/info_base/resolver/main.py‎

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import abc
2+
from collections.abc import Collection
23
from dataclasses import dataclass
4+
import inspect
35
import typing
46
from typing import Optional as Opt
57

@@ -30,6 +32,19 @@ class ResolverDraftCapability:
3032
resolver_cls: type["Resolver"]
3133

3234

35+
@dataclass(frozen=True)
36+
class ResolverMethodContract:
37+
"""One Agent-projectable public read method on a registered Resolver."""
38+
39+
name: str
40+
description: str
41+
input_model: type[pydantic.BaseModel]
42+
43+
@property
44+
def input_schema(self) -> dict[str, typing.Any]:
45+
return self.input_model.model_json_schema()
46+
47+
3348
class ResolverManager:
3449
RESOLVER_CLS: dict[ResolverType, type["Resolver"]] = {}
3550
"""Global resolver registry.
@@ -86,6 +101,82 @@ def get_draft_capability(
86101
return capability
87102
raise UnknownDraftResolverError(resolver)
88103

104+
@classmethod
105+
def get_method_contracts(
106+
cls,
107+
resolver: ResolverType,
108+
) -> tuple[ResolverMethodContract, ...]:
109+
"""Discover typed public read methods on one registered Resolver."""
110+
resolver_cls = cls.RESOLVER_CLS.get(resolver)
111+
if resolver_cls is None:
112+
return ()
113+
contracts: list[ResolverMethodContract] = []
114+
for name, function in inspect.getmembers(resolver_cls, predicate=inspect.isfunction):
115+
if name.startswith("_") or not name.startswith(("get_", "read_")):
116+
continue
117+
try:
118+
signature = inspect.signature(function, eval_str=True)
119+
fields: dict[str, tuple[typing.Any, typing.Any]] = {}
120+
for parameter in signature.parameters.values():
121+
if parameter.name == "self":
122+
continue
123+
if parameter.kind in (parameter.VAR_POSITIONAL, parameter.VAR_KEYWORD):
124+
raise TypeError("Variadic Resolver methods are not projectable")
125+
if parameter.annotation is inspect.Parameter.empty:
126+
raise TypeError("Resolver method parameters must be typed")
127+
default = (
128+
... if parameter.default is inspect.Parameter.empty else parameter.default
129+
)
130+
annotation = parameter.annotation
131+
if typing.get_origin(annotation) is Collection:
132+
item_type = typing.get_args(annotation)[0]
133+
annotation = tuple[item_type, ...]
134+
fields[parameter.name] = (annotation, default)
135+
input_model = typing.cast(typing.Any, pydantic.create_model)(
136+
f"{resolver_cls.__name__}_{name}_Arguments",
137+
__config__=pydantic.ConfigDict(extra="forbid"),
138+
**fields,
139+
)
140+
input_model.model_json_schema()
141+
except (NameError, TypeError, pydantic.PydanticSchemaGenerationError):
142+
continue
143+
contracts.append(
144+
ResolverMethodContract(
145+
name=name,
146+
description=inspect.getdoc(function) or name.replace("_", " "),
147+
input_model=input_model,
148+
)
149+
)
150+
return tuple(contracts)
151+
152+
@classmethod
153+
def get_method_contract(
154+
cls,
155+
resolver: ResolverType,
156+
name: str,
157+
) -> ResolverMethodContract | None:
158+
return next(
159+
(
160+
contract for contract in cls.get_method_contracts(resolver) if contract.name == name
161+
),
162+
None,
163+
)
164+
165+
@classmethod
166+
async def invoke_method(
167+
cls,
168+
block: BlockModel,
169+
name: str,
170+
arguments: dict[str, typing.Any],
171+
) -> typing.Any:
172+
"""Validate and invoke one projected read method on an exact Block Resolver."""
173+
contract = cls.get_method_contract(block.resolver, name)
174+
if contract is None:
175+
raise ValueError("Resolver method is not available")
176+
validated = contract.input_model.model_validate(arguments)
177+
value = getattr(cls.get(block), name)(**validated.model_dump())
178+
return await value if inspect.isawaitable(value) else value
179+
89180
@classmethod
90181
def match_media_type(cls, media_type: str | None) -> ResolverType | None:
91182
"""Map one specific media type to an installed exact core resolver ID.

0 commit comments

Comments
 (0)