diff --git a/docs/engineering/von_workflow_language_manual.md b/docs/engineering/von_workflow_language_manual.md index b409a1782..111bfd458 100644 --- a/docs/engineering/von_workflow_language_manual.md +++ b/docs/engineering/von_workflow_language_manual.md @@ -482,13 +482,12 @@ not a quality signal. Actor-visible registered tools declared by a matched workflow are exposed as plausible simpler component plans without claiming that one component is equivalent to the whole workflow. -Registered tools and represented workflows participate in semantic capability -retrieval before that frontier is paged for the model. Their physical indexes -remain partitioned by authority surface: registered interface metadata may use -a shared tool-capability index, while workflow retrieval remains actor-filtered. -Only capabilities already delegated to the current actor are unified into the -turn frontier. Retrieval similarity is evidence of possible adequacy, not an -invocation permission or a proof that two plans are equivalent. +Natural-language capability discovery gives the model a complete alphabetical +purpose index of the delegated registered tools alongside bounded represented +workflow matches. The index contains mechanically shortened authored +descriptions, not code-ranked semantic choices; exact-name discovery hydrates +the selected canonical schemas. Lexical detail-page labels are hints and cannot +remove a delegated tool from that model-visible index. Workflow component projection includes actor-visible MCP tools named by represented step actions and by represented LLM-step tool policy @@ -503,19 +502,10 @@ the cheaper path. Persisted workflow-capability manifests carry the routing projection producer schema; a schema change invalidates and rebuilds the namespace instead of silently serving the previous metadata shape. -Within each plan shape, semantic retrieval ranks plausible candidates before -the lower-cost direct and higher-cost workflow alternatives are interleaved. -This ensures that a semantically relevant direct path is present in the -model-visible page rather than being hidden behind weak lexical matches. The -model still decides whether a direct call or small composition can produce the -material work product and evidence. - Schema discovery remains an optional ordinary capability, not a compulsory -stage. When semantic retrieval surfaces it for represented-knowledge work, the -model may use it to retrieve predicates, types, inverse directions, or reified -relationship shapes before a relation-bearing read. A typed unavailable or -degraded retrieval index falls back to the bounded catalogue; it must not be -reported as authoritative evidence that no suitable capability exists. +stage. When the catalogue surfaces it for represented-knowledge work, the model +may use it to retrieve predicates, types, inverse directions, or reified +relationship shapes before a relation-bearing read. Direction-neutral relation capability hints are similarly local to the retrieved capability: when the exact represented predicate and argument direction are unknown, they advise incidence or relation reads across any diff --git a/src/backend/integrations/internal_mcp/catalogue.py b/src/backend/integrations/internal_mcp/catalogue.py index 9f9b2e56c..753db047e 100644 --- a/src/backend/integrations/internal_mcp/catalogue.py +++ b/src/backend/integrations/internal_mcp/catalogue.py @@ -323,6 +323,16 @@ def _annotate_bounded_relation_lookup( result = dict(payload) if not bool(result.get("total_hits_is_lower_bound")): return result + diagnostics = result.get("relation_query_diagnostics") + actor_overlay_truncated = bool( + isinstance(diagnostics, Mapping) + and ( + diagnostics.get("scoped_concept_query_truncated") + or diagnostics.get("actor_effective_text_query_truncated") + ) + ) + if not actor_overlay_truncated: + return result paging = dict(result.get("paging") or {}) paging.update( @@ -504,7 +514,7 @@ def _find_relations_with_argument(**kwargs): relation_kind=kwargs.get("relation_kind"), scope=kwargs.get("scope"), include_text_snippets=bool(kwargs.get("include_text_snippets", False)), - include_concept_preview=bool(kwargs.get("include_concept_preview", True)), + include_concept_preview=bool(kwargs.get("include_concept_preview", False)), limit=kwargs.get("limit"), offset=kwargs.get("offset"), sort_by=kwargs.get("sort_by"), @@ -533,7 +543,7 @@ def _get_predicate_incidence(**kwargs): relation_kind=kwargs.get("relation_kind"), scope=kwargs.get("scope"), include_text_snippets=bool(kwargs.get("include_text_snippets", False)), - include_concept_preview=bool(kwargs.get("include_concept_preview", True)), + include_concept_preview=bool(kwargs.get("include_concept_preview", False)), limit=kwargs.get("limit"), offset=kwargs.get("offset"), sort_by=kwargs.get("sort_by"), @@ -9729,10 +9739,12 @@ def _find_relations_with_argument_input_schema() -> Schema: "predicate_filter (list of predicate IDs or substrings), relation_kind " "('any'|'binary'|'text'), scope (optional), include_text_snippets (bool), " "include_concept_preview (bool), paging (limit/offset), sort_by, " - "uncertainty retrieval controls (include_uncertain, uncertainty_mode, " + "uncertainty retrieval controls (include_uncertain, uncertainty_mode " + "'asserted_only'|'uncertain_only'|'include_uncertain', and " "uncertainty_statuses), and optional namespace passthrough. Do not use " "top-level payload keys named subject or object; the anchor entity always " - "goes in concept_id." + "goes in concept_id. A numeric object argument_index selects one exact " + "stored slot; use 'any' when all subject/object slots are relevant." ), ) @@ -9803,12 +9815,18 @@ def _predicate_incidence_input_schema() -> Schema: "get_predicate_incidence input: exactly one of concept_id (entity mode; " "anchor entity concept ID such as '#V#michael_witbrock') or instance_of " "(type mode). Optional direct_instances_only, argument_index " - "('subject' inspects outgoing subject-side relations), predicate_filter, " + "('subject' inspects outgoing subject-side relations; 'any' inspects " + "every stored subject/object slot; a numeric object index selects one " + "exact stored slot), predicate_filter, " "relation_kind ('any'|'binary'|'text'), include_text_snippets, " "include_concept_preview, paging (limit/offset), sort_by " "('relation_hit_count'|'grounding_count'|'grounded_instance_count'|'predicate'), " - "uncertainty retrieval controls, include_argument_type_counts for direct asserted " - "type counts of non-anchor arguments, type_count_mode='direct_asserted', " + "uncertainty retrieval controls (include_uncertain, uncertainty_mode " + "'asserted_only'|'uncertain_only'|'include_uncertain', and " + "uncertainty_statuses), include_argument_type_counts for aggregated direct " + "asserted types of non-anchor arguments (not related-entity identities; " + "keep false unless a type distribution is requested), " + "type_count_mode='direct_asserted', " "include_untyped_bucket, max_types_per_predicate, max_sample_concepts_per_type, " "and role_expansion_mode ('none'|'explicit'|'auto') with optional explicit " "role_node_type_filter, role_predicate_filter, and role_expansion_depth=1. " @@ -9834,6 +9852,8 @@ def _predicate_incidence_output_schema() -> Schema: "type_ids_considered": (list, type(None)), "instance_count_considered": (int, type(None)), "direct_instances_only": (bool, type(None)), + "coverage_complete": (bool, type(None)), + "counts_are_lower_bounds": (bool, type(None)), "uncertainty_diagnostics": (dict, type(None)), }, allow_unknown=True, @@ -9845,7 +9865,9 @@ def _predicate_incidence_output_schema() -> Schema: "sample_groundings, optional argument_type_counts/untyped/literal/inaccessible " "argument counters, optional role_expansion summaries, and in type mode " "grounded_instance_count/sample_instances), plus role_expansions, paging metadata, " - "and optional typed/uncertainty diagnostics." + "and optional typed/uncertainty diagnostics. coverage_complete=false " + "and counts_are_lower_bounds=true report an unavailable coverage path " + "rather than presenting a partial incidence result as exhaustive." ), ) @@ -33848,7 +33870,18 @@ def _build_default_catalogue_core_definitions() -> List[MethodDefinition]: "argument_index to choose the relation slot instead of inventing " "subject/object payload fields. Supports exact graph matching and " "full-text text-relation matching with optional predicate/kind filters " - "and pagination. Verified actors receive actor_effective results; " + "and pagination. A positive hit proves existence, not enumeration " + "completeness. When predicate, direction, or represented-form coverage " + "is uncertain, inspect predicate incidence first and then read every " + "fitting exact predicate. For list, count, broad-category, or negative " + "claims, pass all semantically fitting predicate IDs together in one " + "predicate_filter read unless the incidence evidence distinguishes " + "their coverage; do not choose only the most obvious label. When a hit " + "reaches a reified, event, claim, " + "or role node, inspect its represented role predicates before treating " + "another filler as the requested related entity; co-participation alone " + "does not establish that semantic role. " + "Verified actors receive actor_effective results; " "payload-only identity is ignored and yields base_publication. " "Truncated actor overlays expose bounded recovery rather than a " "misleading offset continuation." @@ -33961,16 +33994,23 @@ def _build_default_catalogue_core_definitions() -> List[MethodDefinition]: output_schema=_predicate_incidence_output_schema(), category="read", description=( - "Summarise which predicates are actually observed around a concept or across " - "instances of a type. The anchor entity goes in concept_id; " - "argument_index='subject' means inspect outgoing subject-side relations, " - "not that the payload should contain a subject field. Use when you need " - "distinct predicates plus counts before choosing a predicate-specific " - "extent or filtered relation lookup. Start with bounded minimal incidence " - "(a modest limit, without argument type counts, previews, or text snippets), " - "then request those richer fields only when the initial evidence shows they " - "are needed; avoid concurrent rich incidence probes by default. Set " - "role_expansion_mode=" + "Discover predicates actually used around a known anchor or type when " + "relationship coverage is uncertain. The anchor entity goes in concept_id. " + "Use before a " + "filtered relation read when a list, " + "count, broad category, or negative lacks established predicate, " + "direction, or represented-form coverage. Start with one " + "argument_index='any' binary page using limit=20, " + "include_concept_preview=false, include_text_snippets=false, and " + "include_argument_type_counts=false. For list, count, broad-category, or " + "negative claims, pass every semantically fitting returned " + "predicate_concept_id, including inverse forms, together in one exact " + "predicate_filter read; do not choose only the most obvious label. " + "Deduplicate overlaps and inspect role fillers for matching " + "reified nodes. Incidence returns coverage candidates, not answer entities. " + "A filler qualifies only when its represented role establishes the requested " + "relationship; co-participation alone does not. " + "Set role_expansion_mode=" "'explicit' with represented node-type or role-predicate filters when " "the immediate neighbour is a reified/event/claim node whose other role " "fillers are the useful retrieval targets." diff --git a/src/backend/mcp_server/mcp_stdio_server.py b/src/backend/mcp_server/mcp_stdio_server.py index 3434a51cc..6a5b2c67f 100644 --- a/src/backend/mcp_server/mcp_stdio_server.py +++ b/src/backend/mcp_server/mcp_stdio_server.py @@ -2177,7 +2177,7 @@ async def _handle_find_relations_with_argument( arguments.get("include_text_snippets", False) ), include_concept_preview=bool( - arguments.get("include_concept_preview", True) + arguments.get("include_concept_preview", False) ), limit=arguments.get("limit"), offset=arguments.get("offset"), @@ -2236,7 +2236,7 @@ async def _handle_get_predicate_incidence( scope=arguments.get("scope"), include_text_snippets=bool(arguments.get("include_text_snippets", False)), include_concept_preview=bool( - arguments.get("include_concept_preview", True) + arguments.get("include_concept_preview", False) ), limit=arguments.get("limit"), offset=arguments.get("offset"), diff --git a/src/backend/mcp_server/vontology_mcp.json b/src/backend/mcp_server/vontology_mcp.json index e83ea5f2b..b91bd72fe 100644 --- a/src/backend/mcp_server/vontology_mcp.json +++ b/src/backend/mcp_server/vontology_mcp.json @@ -2117,7 +2117,7 @@ }, { "name": "find_relations_with_argument", - "description": "Find relation assertions where a concept appears in one or more argument positions. The anchor entity must be supplied in concept_id; use argument_index to choose the relation slot instead of inventing subject/object payload fields. Supports exact graph matching and full-text text-relation matching with optional predicate/kind filters and pagination. Verified actors receive actor_effective results; payload-only identity is ignored and yields base_publication. Truncated actor overlays expose bounded recovery rather than a misleading offset continuation.", + "description": "Find relation assertions where a concept appears in one or more argument positions. The anchor entity must be supplied in concept_id; use argument_index to choose the relation slot instead of inventing subject/object payload fields. Supports exact graph matching and full-text text-relation matching with optional predicate/kind filters and pagination. A positive hit proves existence, not enumeration completeness. When predicate, direction, or represented-form coverage is uncertain, inspect predicate incidence first and then read every fitting exact predicate. For list, count, broad-category, or negative claims, pass all semantically fitting predicate IDs together in one predicate_filter read unless the incidence evidence distinguishes their coverage; do not choose only the most obvious label. When a hit reaches a reified, event, claim, or role node, inspect its represented role predicates before treating another filler as the requested related entity; co-participation alone does not establish that semantic role. Verified actors receive actor_effective results; payload-only identity is ignored and yields base_publication. Truncated actor overlays expose bounded recovery rather than a misleading offset continuation.", "inputSchema": { "type": "object", "properties": { @@ -2210,7 +2210,7 @@ "concept_id" ], "additionalProperties": true, - "description": "find_relations_with_argument input: concept_id (required anchor entity concept ID, for example '#V#michael_witbrock'), argument_index (int or 'any'; use 'subject' or 1 to inspect subject-side relations), predicate_filter (list of predicate IDs or substrings), relation_kind ('any'|'binary'|'text'), scope (optional), include_text_snippets (bool), include_concept_preview (bool), paging (limit/offset), sort_by, uncertainty retrieval controls (include_uncertain, uncertainty_mode, uncertainty_statuses), and optional namespace passthrough. Do not use top-level payload keys named subject or object; the anchor entity always goes in concept_id." + "description": "find_relations_with_argument input: concept_id (required anchor entity concept ID, for example '#V#michael_witbrock'), argument_index (int or 'any'; use 'subject' or 1 to inspect subject-side relations), predicate_filter (list of predicate IDs or substrings), relation_kind ('any'|'binary'|'text'), scope (optional), include_text_snippets (bool), include_concept_preview (bool), paging (limit/offset), sort_by, uncertainty retrieval controls (include_uncertain, uncertainty_mode 'asserted_only'|'uncertain_only'|'include_uncertain', and uncertainty_statuses), and optional namespace passthrough. Do not use top-level payload keys named subject or object; the anchor entity always goes in concept_id. A numeric object argument_index selects one exact stored slot; use 'any' when all subject/object slots are relevant." } }, { @@ -2320,7 +2320,7 @@ }, { "name": "get_predicate_incidence", - "description": "Summarise which predicates are actually observed around a concept or across instances of a type. The anchor entity goes in concept_id; argument_index='subject' means inspect outgoing subject-side relations, not that the payload should contain a subject field. Use when you need distinct predicates plus counts before choosing a predicate-specific extent or filtered relation lookup. Start with bounded minimal incidence (a modest limit, without argument type counts, previews, or text snippets), then request those richer fields only when the initial evidence shows they are needed; avoid concurrent rich incidence probes by default. Set role_expansion_mode='explicit' with represented node-type or role-predicate filters when the immediate neighbour is a reified/event/claim node whose other role fillers are the useful retrieval targets.", + "description": "Discover predicates actually used around a known anchor or type when relationship coverage is uncertain. The anchor entity goes in concept_id. Use before a filtered relation read when a list, count, broad category, or negative lacks established predicate, direction, or represented-form coverage. Start with one argument_index='any' binary page using limit=20, include_concept_preview=false, include_text_snippets=false, and include_argument_type_counts=false. For list, count, broad-category, or negative claims, pass every semantically fitting returned predicate_concept_id, including inverse forms, together in one exact predicate_filter read; do not choose only the most obvious label. Deduplicate overlaps and inspect role fillers for matching reified nodes. Incidence returns coverage candidates, not answer entities. A filler qualifies only when its represented role establishes the requested relationship; co-participation alone does not. Set role_expansion_mode='explicit' with represented node-type or role-predicate filters when the immediate neighbour is a reified/event/claim node whose other role fillers are the useful retrieval targets.", "inputSchema": { "type": "object", "properties": { @@ -2482,7 +2482,7 @@ }, "required": [], "additionalProperties": true, - "description": "get_predicate_incidence input: exactly one of concept_id (entity mode; anchor entity concept ID such as '#V#michael_witbrock') or instance_of (type mode). Optional direct_instances_only, argument_index ('subject' inspects outgoing subject-side relations), predicate_filter, relation_kind ('any'|'binary'|'text'), include_text_snippets, include_concept_preview, paging (limit/offset), sort_by ('relation_hit_count'|'grounding_count'|'grounded_instance_count'|'predicate'), uncertainty retrieval controls, include_argument_type_counts for direct asserted type counts of non-anchor arguments, type_count_mode='direct_asserted', include_untyped_bucket, max_types_per_predicate, max_sample_concepts_per_type, and role_expansion_mode ('none'|'explicit'|'auto') with optional explicit role_node_type_filter, role_predicate_filter, and role_expansion_depth=1. Auto role expansion is metadata-gated and reports diagnostics when no authoritative reification profile is available. Optional namespace passthrough. Do not use top-level payload keys named subject or object; the anchor entity always goes in concept_id.", + "description": "get_predicate_incidence input: exactly one of concept_id (entity mode; anchor entity concept ID such as '#V#michael_witbrock') or instance_of (type mode). Optional direct_instances_only, argument_index ('subject' inspects outgoing subject-side relations; 'any' inspects every stored subject/object slot; a numeric object index selects one exact stored slot), predicate_filter, relation_kind ('any'|'binary'|'text'), include_text_snippets, include_concept_preview, paging (limit/offset), sort_by ('relation_hit_count'|'grounding_count'|'grounded_instance_count'|'predicate'), uncertainty retrieval controls (include_uncertain, uncertainty_mode 'asserted_only'|'uncertain_only'|'include_uncertain', and uncertainty_statuses), include_argument_type_counts for aggregated direct asserted types of non-anchor arguments (not related-entity identities; keep false unless a type distribution is requested), type_count_mode='direct_asserted', include_untyped_bucket, max_types_per_predicate, max_sample_concepts_per_type, and role_expansion_mode ('none'|'explicit'|'auto') with optional explicit role_node_type_filter, role_predicate_filter, and role_expansion_depth=1. Auto role expansion is metadata-gated and reports diagnostics when no authoritative reification profile is available. Optional namespace passthrough. Do not use top-level payload keys named subject or object; the anchor entity always goes in concept_id.", "x-von-argument-aliases": { "target_type": "instance_of", "target_type_id": "instance_of", @@ -4520,7 +4520,7 @@ }, { "name": "search_concepts", - "description": "Search Vontology concept names and descriptions, including predicates and types. Use as schema discovery when a represented entity or relationship is relevant but its exact concept, predicate, direction, or reified shape is not yet known.", + "description": "Search Vontology names and descriptions for possible entities, predicates, and types. Start with exact or substring search and enrich only when needed. This discovers possible schema, not the predicates actually used around an anchor or enumeration coverage.", "inputSchema": { "type": "object", "properties": { @@ -6371,7 +6371,7 @@ }, { "name": "vontology_concept_search", - "description": "Search Vontology concept names and descriptions, including predicates and types. Use as schema discovery when a represented entity or relationship is relevant but its exact concept, predicate, direction, or reified shape is not yet known.", + "description": "Search Vontology names and descriptions for possible entities, predicates, and types. Start with exact or substring search and enrich only when needed. This discovers possible schema, not the predicates actually used around an anchor or enumeration coverage.", "inputSchema": { "type": "object", "properties": { diff --git a/src/backend/server/utils_flask.py b/src/backend/server/utils_flask.py index 930eed152..63f31397c 100644 --- a/src/backend/server/utils_flask.py +++ b/src/backend/server/utils_flask.py @@ -2458,26 +2458,6 @@ def _initialise_internal_mcp_gateway(app: Flask): app.logger.info( "[mcp_gateway] Enabled with %d registered methods.", method_count ) - if not _is_running_under_pytest(): - try: - from ..services.registered_tool_capability_retrieval_service import ( - prewarm_registered_tool_capability_index, - ) - - prewarm_started = prewarm_registered_tool_capability_index( - gateway_instance - ) - app.logger.info( - "[capability_retrieval] Registered-tool semantic index " - "prewarm started=%s.", - prewarm_started, - ) - except Exception as exc: - app.logger.warning( - "[capability_retrieval] Registered-tool semantic index " - "prewarm unavailable: %s", - exc, - ) else: app.logger.info( "[mcp_gateway] Initialised (disabled). Set VON_INTERNAL_MCP_ENABLE=1 to activate. Methods=%d", diff --git a/src/backend/services/adaptive_turn_service.py b/src/backend/services/adaptive_turn_service.py index 2607a4cc2..f1a73c59a 100644 --- a/src/backend/services/adaptive_turn_service.py +++ b/src/backend/services/adaptive_turn_service.py @@ -65,6 +65,7 @@ _MODEL_EVIDENCE_INDEX_MAX_BYTES = 24_000 _MODEL_TOOL_RESULT_BATCH_MAX_BYTES = 24_000 _MODEL_EVIDENCE_PREVIEW_MAX_CHARS = 240 +_CAPABILITY_PURPOSE_MAX_CHARS = 160 _CAPABILITY_CATALOGUE_SCHEMA_VERSION = "adaptive_turn_capabilities.v1" _CAPABILITY_PLAN_PROFILE_SCHEMA_VERSION = "capability_plan_profile.v1" _CAPABILITY_EFFECT_PROFILE_SCHEMA_VERSION = "capability_effect_profile.v1" @@ -356,10 +357,124 @@ def _compact_capability_selection_profiles( ) if adequacy_sources: compact_selection["adequacy_sources"] = adequacy_sources + existing_adequacy_sources = selection.get("adequacy_sources") + if ( + "adequacy_sources" not in compact_selection + and isinstance(existing_adequacy_sources, Sequence) + and not isinstance(existing_adequacy_sources, (str, bytes, bytearray)) + ): + compact_selection["adequacy_sources"] = [ + str(item) + for item in existing_adequacy_sources + if str(item).strip() + ] compact["selection"] = compact_selection return compact +def _capability_purpose_index( + capabilities: Sequence[Mapping[str, Any]], +) -> dict[str, Any]: + """Expose every candidate's authored purpose without choosing semantics.""" + + entries: list[dict[str, Any]] = [] + for capability in sorted( + capabilities, + key=lambda item: str(item.get("name") or "").lower(), + ): + name = str(capability.get("name") or "").strip() + if not name: + continue + purpose = " ".join(str(capability.get("description") or "").split()) + first_sentence_end = re.search(r"[.!?](?=(?:\s+[A-Z#])|$)", purpose) + if first_sentence_end is not None: + purpose = purpose[: first_sentence_end.end()] + purpose = _truncate_capability_purpose( + purpose, + max_chars=_CAPABILITY_PURPOSE_MAX_CHARS, + ) + entry: dict[str, Any] = {"name": name, "purpose": purpose} + plan_profile = capability.get("plan_profile") + shape = ( + str(plan_profile.get("shape") or "").strip() + if isinstance(plan_profile, Mapping) + else "" + ) + if shape and shape != "single_capability": + entry["shape"] = shape + semantic_effect = capability.get("semantic_effect") + if semantic_effect is not False: + entry["semantic_effect"] = semantic_effect + entries.append(entry) + return { + "schema_version": "adaptive_turn_capability_purpose_index.v1", + "complete": True, + "ordering": "name_ascending_unranked", + "purpose_projection": "first_authored_sentence", + "purpose_max_chars": _CAPABILITY_PURPOSE_MAX_CHARS, + "truncation_marker": "...", + "exact_schema_hydration": { + "tool": _CAPABILITY_TOOL_NAME, + "guidance": "Request all alternatives being compared in one names array.", + "arguments": {"names": [""], "limit": 50}, + }, + "entries": entries, + } + + +def _truncate_capability_purpose(purpose: str, *, max_chars: int) -> str: + if len(purpose) <= max_chars: + return purpose + if max_chars <= 3: + return purpose[:max_chars] + prefix = purpose[: max_chars - 3] + if not prefix.endswith(" ") and not purpose[len(prefix)].isspace(): + word_boundary = prefix.rfind(" ") + if word_boundary > 0: + prefix = prefix[:word_boundary] + return prefix.rstrip() + "..." + + +def _capability_purpose_index_with_limit( + purpose_index: Mapping[str, Any], + *, + max_chars: int, +) -> dict[str, Any]: + """Keep every purpose entry while compacting only its descriptive text.""" + + compact = dict(purpose_index) + entries = purpose_index.get("entries") + compact_entries: list[dict[str, Any]] = [] + if isinstance(entries, Sequence) and not isinstance( + entries, + (str, bytes, bytearray), + ): + for raw_entry in entries: + if not isinstance(raw_entry, Mapping): + continue + entry = dict(raw_entry) + purpose = str(entry.get("purpose") or "") + if purpose and max_chars > 0: + entry["purpose"] = _truncate_capability_purpose( + purpose, + max_chars=max_chars, + ) + else: + entry.pop("purpose", None) + compact_entries.append(entry) + compact.update( + { + "entries": compact_entries, + "purpose_projection": ( + "first_authored_sentence_model_context_compacted" + ), + "purpose_max_chars": max_chars, + "purpose_text_compacted_for_model_context": True, + } + ) + return compact + + def _capability_schema_reference( capability: Mapping[str, Any], *, @@ -415,6 +530,22 @@ def _capability_schema_reference( return compact +def _capability_discovery_reference( + capability: Mapping[str, Any], +) -> dict[str, Any]: + """Expose compact selection facts while deferring exact hydration.""" + + compact = { + key: capability.get(key) + for key in ("name", "query_match") + if key in capability + } + selection = _compact_capability_selection_profiles(capability).get("selection") + if isinstance(selection, Mapping): + compact["selection"] = dict(selection) + return compact + + def _capability_schema_focused_projection( capability: Mapping[str, Any], ) -> dict[str, Any]: @@ -457,8 +588,9 @@ def _bounded_capability_catalogue_output( raw_next_offset = value.get("next_offset") if not isinstance(raw_next_offset, int) or isinstance(raw_next_offset, bool): raw_next_offset = None + exact_name_page = value.get("catalogue_scope") == "requested_exact_names" exact_singleton_page = ( - value.get("catalogue_scope") == "requested_exact_names" + exact_name_page and value.get("total") == 1 and len(capabilities) == 1 and raw_next_offset is None @@ -477,10 +609,10 @@ def _bounded_capability_catalogue_output( "dominated_total", "ranking", "selection_policy", - "capability_retrieval", "dominated_capabilities", "catalogue_scope", "offset", + "purpose_index", ) if key in value } @@ -503,7 +635,11 @@ def assemble( "omitted_page_entry_count": omitted_page_entry_count, } if omitted_page_entry_count or schema_reference_count: - projection["reason"] = "model_context_budget" + projection["reason"] = ( + "exact_name_hydration_required" + if not exact_name_page + else "model_context_budget" + ) return { **base, "next_offset": next_offset, @@ -520,9 +656,78 @@ def assemble( schema_reference_count=schema_reference_count, ) if len(_json_bytes(bounded)) > max_bytes: - return {} + purpose_index = base.get("purpose_index") + if not isinstance(purpose_index, Mapping): + return {} + base = { + key: value.get(key) + for key in ( + "schema_version", + "success", + "delegation", + "total", + "delegated_total", + "catalogue_scope", + "offset", + "purpose_index", + ) + if key in value + } + bounded = assemble( + included, + full_schema_count=full_schema_count, + schema_reference_count=schema_reference_count, + ) + if len(_json_bytes(bounded)) > max_bytes: + try: + maximum_purpose_chars = max( + 0, + int(purpose_index.get("purpose_max_chars") or 0), + ) + except (TypeError, ValueError): + maximum_purpose_chars = 0 + best_fit: tuple[dict[str, Any], dict[str, Any]] | None = None + lower = 0 + upper = maximum_purpose_chars + while lower <= upper: + candidate_limit = (lower + upper) // 2 + candidate_base = { + **base, + "purpose_index": _capability_purpose_index_with_limit( + purpose_index, + max_chars=candidate_limit, + ), + } + base = candidate_base + candidate = assemble( + included, + full_schema_count=full_schema_count, + schema_reference_count=schema_reference_count, + ) + if len(_json_bytes(candidate)) <= max_bytes: + best_fit = candidate_base, candidate + lower = candidate_limit + 1 + else: + upper = candidate_limit - 1 + if best_fit is None: + return {} + base, bounded = best_fit for capability in capabilities: + if not exact_name_page: + reference = _capability_discovery_reference(capability) + reference_candidate = assemble( + [*included, reference], + full_schema_count=full_schema_count, + schema_reference_count=schema_reference_count + 1, + ) + if len(_json_bytes(reference_candidate)) > max_bytes: + break + included.append(reference) + schema_reference_count += 1 + bounded = reference_candidate + continue + full_candidate = assemble( [*included, capability], full_schema_count=full_schema_count + 1, @@ -694,6 +899,17 @@ def _bound_tool_results_for_model( if not results: return [] + complete_batch = [ + { + "call_id": result.call_id, + "tool_name": result.tool_name, + "status": result.status, + "output": result.output, + } + for result in results + ] + if len(_json_bytes(complete_batch)) <= max_bytes: + return list(results) receipt_outputs = [_model_tool_output_receipt(result.output) for result in results] serialisable_shells = [ { @@ -1208,12 +1424,18 @@ def _tool_definitions() -> list[ToolDefinition]: ToolDefinition( name=_CAPABILITY_TOOL_NAME, description=( - "Inspect the capabilities delegated to this turn. Search by " - "ordinary words, request exact names, or page through the complete " - "catalogue. A natural-language query also searches actor-accessible " - "represented workflows and returns executable matches as bound " - "capabilities. Returns canonical argument schemas. This is " - "discovery, not a requirement to use any particular capability." + "Inspect the capabilities delegated to this turn. Non-exact " + "discovery returns a complete unranked compact purpose index, " + "including semantically discovered represented workflows. Direct " + "tools, small compositions, and represented workflows are peers; " + "representedness does not rank a plan. Judge semantic adequacy " + "yourself. Prefer the smallest plan that can produce the requested " + "work product and evidence; use a workflow when its added composition, " + "verification, or recovery is materially needed. Request the exact " + "names of all alternatives being compared together to hydrate their " + "canonical descriptions, planner guidance, plan and effect facts, and " + "argument schemas on equal footing. This is discovery, " + "not a requirement to use any particular capability." ), input_schema={ "type": "object", @@ -1255,7 +1477,9 @@ def _tool_definitions() -> list[ToolDefinition]: name=_EVIDENCE_TOOL_NAME, description=( "Read a selected bounded slice of a prior tool result by its " - "turn-scoped evidence handle. Select with a JSON pointer, a text " + "turn-scoped evidence handle. Evidence IDs are opaque: copy the " + "complete evidence_id exactly from the result or evidence index; " + "never reconstruct or shorten it. Select with a JSON pointer, a text " "query, an offset, or any combination. Repeated calls may inspect " "different portions; the raw result is not discarded. Omit " "json_pointer, use the RFC root pointer (an empty string), or use " @@ -1330,29 +1554,6 @@ def _scope_message( "all other writes are unavailable.\n" "- Remaining effect-capable window before the protected final-answer " f"reserve: {max(0.0, remaining_effect_capable_seconds):.3f} seconds.\n" - f"- {_CAPABILITY_TOOL_NAME} exposes the complete delegated catalogue " - "without interpreting the user's intent; a natural-language query also " - "retrieves actor-accessible executable represented workflows as bound " - "capabilities.\n" - "- Treat a direct call, a small composition, and a represented workflow " - "as capability plans in one decision space. Choose the least costly plan " - "only after judging that it can produce the material work product and " - "evidence; representedness is not a quality score.\n" - "- Use each candidate's plan, effect, cost, and adequacy evidence. A " - "workflow is warranted when it adds needed composition, verification, " - "durability, governance, or recovery; otherwise prefer an adequate " - "observable and recoverable direct path.\n" - "- When bounded read-only plans are each plausibly adequate, start with " - "the least costly observable plan and escalate only if its returned " - "evidence exposes a material gap. Do not launch a durable or multi-step " - "workflow merely to hedge uncertainty that a direct read or small " - "composition can test within the turn budget.\n" - "- Treat one schema-discovery read followed by one content-bearing read " - "as a small composition, not as a reason by itself to select a broader " - "workflow.\n" - "- A matched workflow may expose visible component capabilities as " - "simpler alternatives. Their presence does not prove equivalence: inspect " - "the request and escalate when a direct result leaves a material gap.\n" f"- {_INVOKE_TOOL_NAME} invokes any named delegated capability.\n" f"- {_EVIDENCE_INDEX_TOOL_NAME} pages every evidence handle recorded " "for this turn.\n" @@ -1634,6 +1835,7 @@ def _capability_catalogue( *, workflow_capabilities: Sequence[Any] = (), workflow_discovery: Mapping[str, Any] | None = None, + capability_details_by_name: MutableMapping[str, dict[str, Any]] | None = None, ) -> dict[str, Any]: query = str(payload.get("query") or "").strip().lower() requested_names = payload.get("names") @@ -1659,11 +1861,6 @@ def _capability_catalogue( query_tokens = { token for token in re.findall(r"[a-z0-9_]+", query) if len(token) > 1 } - registered_tool_retrieval: dict[str, Any] = { - "schema_version": "registered_tool_capability_retrieval.v1", - "status": "not_requested", - "result_count": 0, - } registered_candidates: list[dict[str, Any]] = [] registered_by_name: dict[str, dict[str, Any]] = {} registered_delegated_total = 0 @@ -1796,7 +1993,6 @@ def _capability_catalogue( "_ranking_literal_match_count": literal_match_count, "_ranking_literal_phrase_match": literal_phrase_match, "_ranking_component_match_count": 0, - "_ranking_semantic_relevance": 0.0, "_ranking_routing_match_tokens": routing_match_tokens, "_ranking_description_literal_match_count": ( description_literal_match_count @@ -1878,54 +2074,6 @@ def _capability_catalogue( candidate_selection["adequacy_evidence"] = adequacy_evidence candidate["selection"] = candidate_selection - if query and not exact_names: - try: - from src.backend.services.registered_tool_capability_retrieval_service import ( - retrieve_registered_tool_capability_scores, - ) - - semantic_scores, registered_tool_retrieval = ( - retrieve_registered_tool_capability_scores( - query, - registered_candidates, - gateway=gateway, - ) - ) - except Exception as exc: # noqa: BLE001 - semantic_scores = {} - registered_tool_retrieval = { - "schema_version": "registered_tool_capability_retrieval.v1", - "status": "degraded", - "result_count": 0, - "error_type": type(exc).__name__, - "error": str(exc)[:500], - } - for candidate in registered_candidates: - name = str(candidate.get("name") or "").strip() - relevance_score = float(semantic_scores.get(name) or 0.0) - if relevance_score <= 0.0: - continue - candidate["query_match"] = True - candidate["_ranking_semantic_relevance"] = relevance_score - candidate_selection = dict(candidate.get("selection") or {}) - adequacy_evidence = list( - candidate_selection.get("adequacy_evidence") or [] - ) - adequacy_evidence.append( - { - "source": "registered_tool_capability_semantic_relevance", - "relevance_score": round(relevance_score, 4), - } - ) - candidate_selection.update( - { - "frontier_status": "candidate", - "adequacy_evidence": adequacy_evidence, - "semantic_adequacy_owner": "adaptive_model", - } - ) - candidate["selection"] = candidate_selection - workflow_candidates: list[dict[str, Any]] = [] represented_workflow_total = 0 for workflow_capability in workflow_capabilities: @@ -2180,7 +2328,6 @@ def direct_sort_key(item: Mapping[str, Any]) -> tuple[Any, ...]: -int(bool(item.get("_ranking_literal_phrase_match"))), -int(item.get("_ranking_component_match_count") or 0), -float(item.get("_ranking_component_workflow_relevance") or 0.0), - -float(item.get("_ranking_semantic_relevance") or 0.0), -int(item.get("_ranking_literal_match_count") or 0), str(item.get("name") or "").lower(), ) @@ -2260,8 +2407,18 @@ def workflow_sort_key(item: Mapping[str, Any]) -> tuple[Any, ...]: or item["selection"].get("frontier_status") != "declared_dominated" ) ) - page = selected[offset : offset + limit] - next_offset = offset + len(page) + full_page = selected[offset : offset + limit] + if capability_details_by_name is not None: + for capability in full_page: + capability_name = str(capability.get("name") or "").strip().lower() + if capability_name: + capability_details_by_name[capability_name] = dict(capability) + page = ( + full_page + if exact_names + else [_capability_discovery_reference(item) for item in full_page] + ) + next_offset = offset + len(full_page) return { "schema_version": _CAPABILITY_CATALOGUE_SCHEMA_VERSION, "success": True, @@ -2283,28 +2440,11 @@ def workflow_sort_key(item: Mapping[str, Any]) -> tuple[Any, ...]: "evidence_adequacy" ), "frontier_order": ( - "semantically_rank_each_plan_shape_then_interleave_direct_" - "and_workflow_candidates_with_lower_orchestration_cost_first" + "interleave_direct_and_workflow_candidates_with_lower_" + "orchestration_cost_first" ), "representedness_priority": False, }, - "capability_retrieval": { - "schema_version": "turn_capability_retrieval.v1", - "registered_tools": dict(registered_tool_retrieval), - "represented_workflows": { - "status": ( - str(workflow_discovery.get("status") or "available") - if isinstance(workflow_discovery, Mapping) - else "not_requested" - ), - "result_count": len(workflow_candidates), - "source": "represented_workflow_capability_index", - }, - "authority_partitioning": ( - "retrieval_indexes_are_partitioned_by_authority_surface_and_" - "unified_only_after_actor_delegation" - ), - }, "dominated_capabilities": dominated_capabilities, "catalogue_scope": ( "requested_exact_names" @@ -2313,6 +2453,11 @@ def workflow_sort_key(item: Mapping[str, Any]) -> tuple[Any, ...]: ), "offset": offset, "next_offset": next_offset if next_offset < len(selected) else None, + **( + {"purpose_index": _capability_purpose_index(selected)} + if not exact_names + else {} + ), "capabilities": page, **( {"workflow_discovery": dict(workflow_discovery)} @@ -4032,24 +4177,8 @@ def enter_answer_only(reason: str) -> None: workflow_capabilities_by_name.values() ), workflow_discovery=latest_workflow_discovery, + capability_details_by_name=catalogued_capabilities_by_name, ) - raw_catalogued_capabilities = output.get("capabilities") - if isinstance( - raw_catalogued_capabilities, Sequence - ) and not isinstance( - raw_catalogued_capabilities, - (str, bytes, bytearray), - ): - for raw_capability in raw_catalogued_capabilities: - if not isinstance(raw_capability, Mapping): - continue - catalogued_name = str( - raw_capability.get("name") or "" - ).strip() - if catalogued_name: - catalogued_capabilities_by_name[ - catalogued_name.lower() - ] = dict(raw_capability) raw_selection_policy = output.get("selection_policy") latest_capability_selection_policy = ( dict(raw_selection_policy) diff --git a/src/backend/services/concept_relation_service.py b/src/backend/services/concept_relation_service.py index 5c224b63f..47ebbe9b5 100644 --- a/src/backend/services/concept_relation_service.py +++ b/src/backend/services/concept_relation_service.py @@ -310,6 +310,7 @@ def find_relations_with_argument( uncertainty_mode: Optional[str] = None, uncertainty_statuses: Optional[Sequence[str]] = None, context_view: RelationContextView = "base_publication", + _materialise_all_hits: bool = False, ) -> Dict[str, Any]: """Find relation hits where ``concept_id`` appears in any argument position. @@ -345,8 +346,8 @@ def find_relations_with_argument( "predicate_filter": list(predicate_display_terms), "relation_kind": relation_kind, "resolved_relation_kind": relation_filter, - "limit": resolved_limit_value, - "offset": resolved_offset, + "limit": None if _materialise_all_hits else resolved_limit_value, + "offset": 0 if _materialise_all_hits else resolved_offset, "sort_by": sort_by, "include_text_snippets": bool(include_text_snippets), "include_concept_preview": bool(include_concept_preview), @@ -384,6 +385,7 @@ def find_relations_with_argument( preview_cache: Dict[str, Optional[Dict[str, Any]]] = {} hits: List[Dict[str, Any]] = [] + asserted_binary_hits: List[Dict[str, Any]] = [] scoped_concept_query_truncated = False actor_effective_text_query_truncated = False @@ -403,11 +405,6 @@ def find_relations_with_argument( targets = _normalise_relationship_targets(raw_targets) if not targets: continue - source_preview = _resolve_concept_preview( - resolved_concept_id, - include_concept_preview, - preview_cache, - ) for target_index, target_value in enumerate(targets): matched_indexes = [_ARG_INDEX_SUBJECT] arg_target_index = _ARG_INDEX_FIRST_OBJECT + target_index @@ -415,31 +412,19 @@ def find_relations_with_argument( matched_indexes.append(arg_target_index) if not _argument_indexes_match(matched_indexes, argument_filter): continue - target_preview = None - if ( - include_concept_preview - and isinstance(target_value, str) - and target_value.startswith("#V#") - ): - target_preview = _resolve_concept_preview( - target_value, - True, - preview_cache, - ) hit = { "source_concept_id": resolved_concept_id, "predicate_concept_id": predicate_id, "relation_kind": "binary", "argument_indexes": matched_indexes, "target_value": target_value, - "target_concept_preview": target_preview, + "target_concept_preview": None, "relation_metadata": { "relation_id": f"struct::{resolved_concept_id}::{predicate_id}::{target_index}", "updated_at": source_updated_at, "match_type": "exact", }, - "access_granted": source_preview is not None - or not include_concept_preview, + "access_granted": not include_concept_preview, "follow_up_actions": _build_follow_up_actions( [target_value], exclude={resolved_concept_id}, @@ -448,9 +433,8 @@ def find_relations_with_argument( "is_asserted": True, "relation_state": "asserted", } - if include_concept_preview: - hit["source_concept_preview"] = source_preview hits.append(hit) + asserted_binary_hits.append(hit) if include_asserted_rows and include_structural and include_arg2_or_later: incoming_candidates: List[Tuple[str, Any, int, Any]] = [] @@ -460,7 +444,7 @@ def find_relations_with_argument( index_rows: List[Dict[str, Any]] = [] index_total = -1 bounded_index_diagnostics: Dict[str, Any] = {} - if not predicate_terms: + if not predicate_terms and not _materialise_all_hits: bounded_rows, index_used, bounded_index_diagnostics = ( incoming_dynamic_extent_rows_page_for_target( resolved_concept_id, @@ -502,7 +486,11 @@ def find_relations_with_argument( exact_index_filter = ( {"predicate_id": exact_predicate_ids[0]} if len(exact_predicate_ids) == 1 - else {} + else ( + {"predicate_ids": exact_predicate_ids} + if exact_predicate_ids + else {} + ) ) index_rows, index_total = query_relationship_extent_index( **exact_index_filter, @@ -552,11 +540,13 @@ def find_relations_with_argument( ), "bounded": bounded, "complete": complete, + "fallback_reason": bounded_index_diagnostics.get("reason"), **( { "stop_reason": bounded_index_diagnostics.get( "stop_reason" - ), + ) + or bounded_index_diagnostics.get("reason"), "has_more": bool( bounded_index_diagnostics.get("has_more") ), @@ -660,59 +650,14 @@ def find_relations_with_argument( else: incoming_asserted_binary_diagnostics.update( { - "path": "canonical_aggregation", + "path": "relationship_extent_index_unavailable", "fallback_reason": ("relationship_extent_index_unavailable"), + "bounded": False, + "complete": False, + "stop_reason": "relationship_extent_index_unavailable", } ) - incoming_pipeline = [ - {"$match": {"relationships": {"$type": "object"}}}, - { - "$project": { - "concept_id": 1, - "updated_at": 1, - "relationship_items": {"$objectToArray": "$relationships"}, - } - }, - {"$unwind": "$relationship_items"}, - { - "$project": { - "concept_id": 1, - "updated_at": 1, - "predicate": "$relationship_items.k", - "targets": "$relationship_items.v", - } - }, - {"$match": {"targets": resolved_concept_id}}, - ] - for row in ConceptsRepository.aggregate(incoming_pipeline): - incoming_asserted_binary_diagnostics["canonical_rows_returned"] += 1 - source_id = row.get("concept_id") - if not isinstance(source_id, str) or not source_id.strip(): - continue - source_id = source_id.strip() - targets = _normalise_relationship_targets(row.get("targets")) - if not targets: - continue - for target_index, target_value in enumerate(targets): - if target_value != resolved_concept_id: - continue - incoming_candidates.append( - ( - source_id, - row.get("predicate"), - target_index, - row.get("updated_at"), - ) - ) - _prime_concept_preview_cache( - [ - resolved_concept_id, - *(source_id for source_id, _, _, _ in incoming_candidates), - ], - include_preview=include_concept_preview, - preview_cache=preview_cache, - ) for ( source_id, predicate_id, @@ -724,33 +669,19 @@ def find_relations_with_argument( matched_indexes = [_ARG_INDEX_FIRST_OBJECT + target_index] if not _argument_indexes_match(matched_indexes, argument_filter): continue - source_preview = _resolve_concept_preview( - source_id, - include_concept_preview, - preview_cache, - ) hit = { "source_concept_id": source_id, "predicate_concept_id": predicate_id, "relation_kind": "binary", "argument_indexes": matched_indexes, "target_value": resolved_concept_id, - "target_concept_preview": ( - _resolve_concept_preview( - resolved_concept_id, - True, - preview_cache, - ) - if include_concept_preview - else None - ), + "target_concept_preview": None, "relation_metadata": { "relation_id": f"struct::{source_id}::{predicate_id}::incoming::{target_index}", "updated_at": _isoformat(source_updated_at), "match_type": "exact", }, - "access_granted": source_preview is not None - or not include_concept_preview, + "access_granted": not include_concept_preview, "follow_up_actions": _build_follow_up_actions( [source_id], exclude={resolved_concept_id}, @@ -759,9 +690,8 @@ def find_relations_with_argument( "is_asserted": True, "relation_state": "asserted", } - if include_concept_preview: - hit["source_concept_preview"] = source_preview hits.append(hit) + asserted_binary_hits.append(hit) if ( context_view == "actor_effective" @@ -1031,7 +961,51 @@ def find_relations_with_argument( deduped_hits = _dedupe_argument_hits(hits) sorted_hits = _sort_argument_hits(deduped_hits, sort_by) - paged_hits = sorted_hits[resolved_offset : resolved_offset + resolved_limit_value] + paged_hits = ( + sorted_hits + if _materialise_all_hits + else sorted_hits[resolved_offset : resolved_offset + resolved_limit_value] + ) + if include_concept_preview: + paged_hit_ids = {id(hit) for hit in paged_hits} + paged_asserted_binary_hits = [ + hit for hit in asserted_binary_hits if id(hit) in paged_hit_ids + ] + if paged_asserted_binary_hits: + _prime_concept_preview_cache( + [ + *( + related_id + for hit in paged_asserted_binary_hits + for related_id in ( + str(hit.get("source_concept_id") or ""), + str(hit.get("target_value") or ""), + ) + if related_id.startswith("#V#") + ), + ], + include_preview=True, + preview_cache=preview_cache, + ) + for hit in paged_asserted_binary_hits: + source_id = str(hit.get("source_concept_id") or "") + target_value = str(hit.get("target_value") or "") + source_preview = _resolve_concept_preview( + source_id, + True, + preview_cache, + ) + hit["source_concept_preview"] = source_preview + hit["target_concept_preview"] = ( + _resolve_concept_preview( + target_value, + True, + preview_cache, + ) + if target_value.startswith("#V#") + else None + ) + hit["access_granted"] = source_preview is not None counts_are_lower_bounds = bool( scoped_concept_query_truncated or actor_effective_text_query_truncated @@ -1051,8 +1025,8 @@ def find_relations_with_argument( ), "hits": paged_hits, "paging": { - "limit": resolved_limit_value, - "offset": resolved_offset, + "limit": None if _materialise_all_hits else resolved_limit_value, + "offset": 0 if _materialise_all_hits else resolved_offset, "returned": len(paged_hits), "total_available": len(sorted_hits), **( @@ -1101,7 +1075,7 @@ def get_predicate_incidence( relation_kind: Optional[str] = None, scope: Optional[str] = None, # Accepted for caller consistency; currently unused. include_text_snippets: bool = False, - include_concept_preview: bool = True, + include_concept_preview: bool = False, limit: Optional[int] = None, offset: Optional[int] = None, sort_by: Optional[str] = None, @@ -1166,6 +1140,8 @@ def get_predicate_incidence( role_expansion_depth=role_expansion_depth, max_sample_concepts_per_type=type_count_options.max_sample_concepts_per_type, ) + skeleton_type_count_options = PredicateIncidenceTypeCountOptions() + skeleton_role_expansion_options = PredicateIncidenceRoleExpansionOptions() if resolved_concept_id: hits, diagnostics = _collect_all_argument_hits_for_incidence( @@ -1179,28 +1155,41 @@ def get_predicate_incidence( uncertainty_mode=uncertainty_mode, uncertainty_statuses=uncertainty_statuses, ) - if type_count_options.include: - _attach_direct_type_ids_to_fast_incidence_hits({resolved_concept_id: hits}) predicate_rows = _aggregate_predicate_incidence_rows( hits=hits, - include_concept_preview=include_concept_preview, + # Count and sort without doing predicate/grounding preview, type, or + # role reads. Only the selected page is hydrated below. + include_concept_preview=False, preview_cache=preview_cache, anchor_concept_id=resolved_concept_id, mode="entity", type_membership_ids=None, - type_count_options=type_count_options, - role_expansion_options=role_expansion_options, + type_count_options=skeleton_type_count_options, + role_expansion_options=skeleton_role_expansion_options, ) sorted_rows = _sort_predicate_incidence_rows( predicate_rows, sort_by=resolved_sort_by, ) - paged_rows = sorted_rows[ - resolved_offset : resolved_offset + resolved_limit_value - ] + paged_rows = _hydrate_predicate_incidence_page( + sorted_rows[resolved_offset : resolved_offset + resolved_limit_value], + hits_by_anchor={resolved_concept_id: hits}, + mode="entity", + type_membership_ids=None, + include_concept_preview=include_concept_preview, + preview_cache=preview_cache, + type_count_options=type_count_options, + role_expansion_options=role_expansion_options, + ) return { "mode": "entity", "concept_id": resolved_concept_id, + "coverage_complete": bool(diagnostics.get("coverage_complete", True)), + **( + {"counts_are_lower_bounds": True} + if not diagnostics.get("coverage_complete", True) + else {} + ), "total_predicates": len(sorted_rows), "predicates": paged_rows, "role_expansions": _extract_predicate_incidence_role_expansions(paged_rows), @@ -1209,8 +1198,16 @@ def get_predicate_incidence( "offset": resolved_offset, "returned": len(paged_rows), "total_available": len(sorted_rows), + **( + {"total_available_is_lower_bound": True} + if not diagnostics.get("coverage_complete", True) + else {} + ), + }, + "uncertainty_diagnostics": { + key: diagnostics.get(key) + for key in ("mode", "include_uncertain", "statuses") }, - "uncertainty_diagnostics": diagnostics, "typed_predicate_incidence_diagnostics": ( _build_typed_predicate_incidence_diagnostics( type_count_options=type_count_options, @@ -1232,6 +1229,10 @@ def get_predicate_incidence( "include_text_snippets": bool(include_text_snippets), "include_concept_preview": bool(include_concept_preview), "include_argument_type_counts": type_count_options.include, + "coverage_complete": bool( + diagnostics.get("coverage_complete", True) + ), + "coverage_gaps": list(diagnostics.get("coverage_gaps") or []), "uncertainty_mode": diagnostics.get("mode"), "include_uncertain": diagnostics.get("include_uncertain"), "uncertainty_statuses": diagnostics.get("statuses"), @@ -1265,6 +1266,8 @@ def get_predicate_incidence( ) ), "statuses": _normalise_uncertainty_statuses(uncertainty_statuses) or None, + "coverage_complete": True, + "coverage_gaps": [], } grouped_hits = _collect_type_subject_hits_for_incidence_fast( @@ -1282,12 +1285,10 @@ def get_predicate_incidence( if grouped_hits is not None else ("per_instance_relation_lookup") ) - if grouped_hits is not None and type_count_options.include: - _attach_direct_type_ids_to_fast_incidence_hits(grouped_hits) - + hits_by_instance: Dict[str, List[Dict[str, Any]]] = {} for instance_id in instance_ids: if grouped_hits is None: - hits, _ = _collect_all_argument_hits_for_incidence( + hits, instance_diagnostics = _collect_all_argument_hits_for_incidence( concept_id=instance_id, argument_index=argument_index, predicate_filter=predicate_filter, @@ -1298,32 +1299,62 @@ def get_predicate_incidence( uncertainty_mode=uncertainty_mode, uncertainty_statuses=uncertainty_statuses, ) + if not instance_diagnostics.get("coverage_complete", True): + diagnostics["coverage_complete"] = False + diagnostics["coverage_gaps"].extend( + { + **dict(gap), + "anchor_concept_id": instance_id, + } + for gap in instance_diagnostics.get("coverage_gaps", []) + if isinstance(gap, Mapping) + ) else: hits = grouped_hits.get(instance_id, []) + hits_by_instance[instance_id] = hits _accumulate_predicate_incidence_rows( aggregated_by_predicate, hits=hits, - include_concept_preview=include_concept_preview, + # The complete type-wide skeleton must remain read-free apart from + # relation materialisation and access filtering. Enrich only the + # selected predicate page below. + include_concept_preview=False, preview_cache=preview_cache, anchor_concept_id=instance_id, mode="type", contributing_instance_id=instance_id, type_membership_ids=type_ids, - type_count_options=type_count_options, - role_expansion_options=role_expansion_options, + type_count_options=skeleton_type_count_options, + role_expansion_options=skeleton_role_expansion_options, ) + predicate_rows = [ + _finalise_predicate_incidence_row(row, mode="type") + for row in aggregated_by_predicate.values() + ] sorted_rows = _sort_predicate_incidence_rows( - [ - _finalise_predicate_incidence_row(row, mode="type") - for row in aggregated_by_predicate.values() - ], + predicate_rows, sort_by=resolved_sort_by, ) - paged_rows = sorted_rows[resolved_offset : resolved_offset + resolved_limit_value] + paged_rows = _hydrate_predicate_incidence_page( + sorted_rows[resolved_offset : resolved_offset + resolved_limit_value], + hits_by_anchor=hits_by_instance, + mode="type", + type_membership_ids=type_ids, + include_concept_preview=include_concept_preview, + preview_cache=preview_cache, + type_count_options=type_count_options, + role_expansion_options=role_expansion_options, + ) return { "mode": "type", "instance_of": resolved_instance_of, + "coverage_complete": bool(diagnostics.get("coverage_complete", True)), + **( + {"counts_are_lower_bounds": True} + if not diagnostics.get("coverage_complete", True) + else {} + ), "type_ids_considered": type_ids, "instance_count_considered": len(instance_ids), "direct_instances_only": bool(direct_instances_only), @@ -1335,8 +1366,16 @@ def get_predicate_incidence( "offset": resolved_offset, "returned": len(paged_rows), "total_available": len(sorted_rows), + **( + {"total_available_is_lower_bound": True} + if not diagnostics.get("coverage_complete", True) + else {} + ), + }, + "uncertainty_diagnostics": { + key: diagnostics.get(key) + for key in ("mode", "include_uncertain", "statuses") }, - "uncertainty_diagnostics": diagnostics, "typed_predicate_incidence_diagnostics": ( _build_typed_predicate_incidence_diagnostics( type_count_options=type_count_options, @@ -1361,6 +1400,8 @@ def get_predicate_incidence( "include_text_snippets": bool(include_text_snippets), "include_concept_preview": bool(include_concept_preview), "include_argument_type_counts": type_count_options.include, + "coverage_complete": bool(diagnostics.get("coverage_complete", True)), + "coverage_gaps": list(diagnostics.get("coverage_gaps") or []), "uncertainty_mode": diagnostics.get("mode"), "include_uncertain": diagnostics.get("include_uncertain"), "uncertainty_statuses": diagnostics.get("statuses"), @@ -1385,6 +1426,112 @@ def _empty_payload(limit: Optional[int], offset: Optional[int]) -> Dict[str, Any } +def _hydrate_predicate_incidence_page( + skeleton_rows: Sequence[Dict[str, Any]], + *, + hits_by_anchor: Mapping[str, List[Dict[str, Any]]], + mode: str, + type_membership_ids: Optional[Sequence[str]], + include_concept_preview: bool, + preview_cache: Dict[str, Optional[Dict[str, Any]]], + type_count_options: PredicateIncidenceTypeCountOptions, + role_expansion_options: PredicateIncidenceRoleExpansionOptions, +) -> List[Dict[str, Any]]: + enrich_types_or_roles = ( + type_count_options.include + or role_expansion_options.mode == _ROLE_EXPANSION_MODE_EXPLICIT + ) + if not (include_concept_preview or enrich_types_or_roles): + return list(skeleton_rows) + + selected_ids = { + predicate_id + for row in skeleton_rows + if isinstance((predicate_id := row.get("predicate_concept_id")), str) + } + selected_hits_by_anchor = { + anchor_concept_id: [ + hit + for hit in hits + if isinstance(hit.get("predicate_concept_id"), str) + and str(hit["predicate_concept_id"]).strip() in selected_ids + ] + for anchor_concept_id, hits in hits_by_anchor.items() + } + if enrich_types_or_roles: + _attach_direct_type_ids_to_fast_incidence_hits(selected_hits_by_anchor) + + preview_ids: List[str] = [] + if include_concept_preview: + preview_ids.extend(selected_ids) + for row in skeleton_rows: + for sample in [ + *(row.get("sample_groundings") or []), + *(row.get("sample_instances") or []), + ]: + if isinstance(sample, Mapping): + preview_ids.append(sample.get("concept_id")) + if enrich_types_or_roles: + for hits in selected_hits_by_anchor.values(): + for hit in hits: + for field_name in ("source_type_ids", "target_type_ids"): + raw_type_ids = hit.get(field_name) + if isinstance(raw_type_ids, list): + preview_ids.extend(raw_type_ids) + _prime_concept_preview_cache( + preview_ids, + include_preview=True, + preview_cache=preview_cache, + ) + + hydrated_by_predicate: Dict[str, Dict[str, Any]] = {} + for anchor_concept_id, hits in selected_hits_by_anchor.items(): + _accumulate_predicate_incidence_rows( + hydrated_by_predicate, + hits=hits, + include_concept_preview=include_concept_preview, + preview_cache=preview_cache, + anchor_concept_id=anchor_concept_id, + mode=mode, + contributing_instance_id=(anchor_concept_id if mode == "type" else None), + type_membership_ids=type_membership_ids, + type_count_options=type_count_options, + role_expansion_options=role_expansion_options, + ) + hydrated_by_id = { + row["predicate_concept_id"]: _finalise_predicate_incidence_row( + row, + mode=mode, + ) + for row in hydrated_by_predicate.values() + } + if include_concept_preview and type_count_options.include: + sample_concepts = [ + sample + for row in hydrated_by_id.values() + for type_count in row.get("argument_type_counts", []) + for sample in type_count.get("sample_concepts", []) + if isinstance(type_count, Mapping) and isinstance(sample, dict) + ] + _prime_concept_preview_cache( + [sample.get("concept_id") for sample in sample_concepts], + include_preview=True, + preview_cache=preview_cache, + ) + for sample in sample_concepts: + preview = _resolve_concept_preview( + str(sample.get("concept_id") or ""), + True, + preview_cache, + ) + if isinstance(preview, Mapping): + sample.update(_compact_concept_sample(preview)) + return [ + hydrated_by_id.get(row.get("predicate_concept_id"), row) + for row in skeleton_rows + ] + + def _collect_all_argument_hits_for_incidence( *, concept_id: str, @@ -1397,7 +1544,6 @@ def _collect_all_argument_hits_for_incidence( uncertainty_mode: Optional[str], uncertainty_statuses: Optional[Sequence[str]], ) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]: - batch_size = _MAX_LIMIT all_hits: List[Dict[str, Any]] = [] diagnostics: Dict[str, Any] = { "mode": ( @@ -1418,6 +1564,8 @@ def _collect_all_argument_hits_for_incidence( ) ), "statuses": _normalise_uncertainty_statuses(uncertainty_statuses) or None, + "coverage_complete": True, + "coverage_gaps": [], } argument_filter = _coerce_argument_index(argument_index) @@ -1438,45 +1586,54 @@ def _collect_all_argument_hits_for_incidence( relation_queries.append((relation_filter, argument_index)) for query_relation_kind, query_argument_index in relation_queries: - offset = 0 - while True: - payload = find_relations_with_argument( - concept_id=concept_id, - argument_index=query_argument_index, - predicate_filter=predicate_filter, - relation_kind=query_relation_kind, - include_text_snippets=include_text_snippets, - # Incidence aggregation only exposes bounded sample groundings, so - # resolve previews there instead of for every raw relation hit. - include_concept_preview=False, - limit=batch_size, - offset=offset, - include_uncertain=include_uncertain, - uncertainty_mode=uncertainty_mode, - uncertainty_statuses=uncertainty_statuses, + payload = find_relations_with_argument( + concept_id=concept_id, + argument_index=query_argument_index, + predicate_filter=predicate_filter, + relation_kind=query_relation_kind, + include_text_snippets=include_text_snippets, + # Incidence aggregation only exposes bounded sample groundings, so + # resolve previews there instead of for every raw relation hit. + include_concept_preview=False, + include_uncertain=include_uncertain, + uncertainty_mode=uncertainty_mode, + uncertainty_statuses=uncertainty_statuses, + _materialise_all_hits=True, + ) + if isinstance(payload.get("uncertainty_diagnostics"), dict): + uncertainty_diagnostics = payload["uncertainty_diagnostics"] + for key in ("mode", "include_uncertain", "statuses"): + if key in uncertainty_diagnostics: + diagnostics[key] = uncertainty_diagnostics[key] + relation_diagnostics = payload.get("relation_query_diagnostics") + incoming_diagnostics = ( + relation_diagnostics.get("incoming_asserted_binary") + if isinstance(relation_diagnostics, Mapping) + else None + ) + if ( + isinstance(incoming_diagnostics, Mapping) + and incoming_diagnostics.get("requested") + and not incoming_diagnostics.get("complete", True) + ): + diagnostics["coverage_complete"] = False + diagnostics["coverage_gaps"].append( + { + "relation_kind": query_relation_kind, + "argument_index": query_argument_index, + "path": incoming_diagnostics.get("path"), + "reason": ( + incoming_diagnostics.get("stop_reason") + or incoming_diagnostics.get("fallback_reason") + or "incomplete_incoming_relation_coverage" + ), + } ) - if isinstance(payload.get("uncertainty_diagnostics"), dict): - diagnostics = dict(payload["uncertainty_diagnostics"]) - batch_hits = payload.get("hits") - if not isinstance(batch_hits, list) or not batch_hits: - break - materialised_hits = [ + batch_hits = payload.get("hits") + if isinstance(batch_hits, list): + all_hits.extend( dict(hit) for hit in batch_hits if isinstance(hit, Mapping) - ] - all_hits.extend(materialised_hits) - paging = payload.get("paging") - total_available_value = ( - paging.get("total_available") if isinstance(paging, Mapping) else None ) - total_available = ( - int(total_available_value) - if isinstance(total_available_value, (int, float)) - else len(all_hits) - ) - returned = len(materialised_hits) - offset += returned - if returned <= 0 or offset >= total_available: - break return all_hits, diagnostics @@ -1493,6 +1650,10 @@ def _collect_type_subject_hits_for_incidence_fast( uncertainty_statuses: Optional[Sequence[str]], ) -> Optional[Dict[str, List[Dict[str, Any]]]]: argument_filter = _coerce_argument_index(argument_index) + explicitly_all_arguments = bool( + isinstance(argument_index, str) + and argument_index.strip().lower() == "any" + ) relation_filter = _normalise_relation_kind_filter(relation_kind) mode_value, include_asserted_rows, include_uncertain_rows = ( _resolve_uncertainty_mode( @@ -1504,6 +1665,7 @@ def _collect_type_subject_hits_for_incidence_fast( not include_asserted_rows or include_uncertain_rows or _normalise_uncertainty_statuses(uncertainty_statuses) + or explicitly_all_arguments or argument_filter not in (None, _ARG_INDEX_SUBJECT) or relation_filter not in {"any", "binary", "text"} ): @@ -1662,12 +1824,14 @@ def _attach_direct_type_ids_to_fast_incidence_hits( grouped_hits: Mapping[str, List[Dict[str, Any]]], ) -> None: target_ids: List[str] = [] - for source_id, hits in grouped_hits.items(): + for anchor_id, hits in grouped_hits.items(): for hit in hits: + source_id = _normalise_concept_id(hit.get("source_concept_id")) target_id = _normalise_concept_id(hit.get("target_value")) - if not target_id or target_id == source_id: - continue - target_ids.append(target_id) + if source_id and source_id != anchor_id: + target_ids.append(source_id) + if target_id and target_id != anchor_id: + target_ids.append(target_id) target_ids = list(dict.fromkeys(target_ids)) if not target_ids: return @@ -1721,11 +1885,12 @@ def _attach_direct_type_ids_to_fast_incidence_hits( type_id for type_id in type_ids if type_id in accessible_type_ids ] - for source_id, hits in grouped_hits.items(): + for hits in grouped_hits.values(): for hit in hits: + source_id = _normalise_concept_id(hit.get("source_concept_id")) target_id = _normalise_concept_id(hit.get("target_value")) - if not target_id or target_id == source_id: - continue + if source_id in type_ids_by_target: + hit["source_type_ids"] = list(type_ids_by_target[source_id]) if target_id in type_ids_by_target: hit["target_type_ids"] = list(type_ids_by_target[target_id]) @@ -1876,19 +2041,19 @@ def _accumulate_predicate_incidence_rows( role_expansion_options=role_expansion_options, ) - if len(row["sample_groundings"]) < 4: - for grounding_key, grounding in _extract_predicate_incidence_groundings( - hit, - anchor_concept_id=anchor_concept_id, - include_concept_preview=include_concept_preview, - preview_cache=preview_cache, - ): - if grounding_key in row["_grounding_keys"]: - continue - row["_grounding_keys"].add(grounding_key) + for grounding_key, grounding in _extract_predicate_incidence_groundings( + hit, + anchor_concept_id=anchor_concept_id, + include_concept_preview=( + include_concept_preview and len(row["sample_groundings"]) < 4 + ), + preview_cache=preview_cache, + ): + if grounding_key in row["_grounding_keys"]: + continue + row["_grounding_keys"].add(grounding_key) + if len(row["sample_groundings"]) < 4: row["sample_groundings"].append(grounding) - if len(row["sample_groundings"]) >= 4: - break if mode == "type" and contributing_instance_id: row["_instance_ids"].add(contributing_instance_id) @@ -1952,16 +2117,32 @@ def _extract_predicate_incidence_argument_entries( source_concept_id = _normalise_concept_id(hit.get("source_concept_id")) if source_concept_id and source_concept_id != anchor_concept_id: + raw_source_type_ids = hit.get("source_type_ids") + source_type_ids: Optional[List[str]] = ( + [ + str(type_id).strip() + for type_id in raw_source_type_ids + if isinstance(type_id, str) and str(type_id).strip() + ] + if isinstance(raw_source_type_ids, list) + else None + ) entries.append( { "argument_index": _ARG_INDEX_SUBJECT, "argument_role": "subject", "concept_id": source_concept_id, - "concept_preview": _resolve_concept_preview( - source_concept_id, - True, - preview_cache, + "concept_preview": preview_cache.get(source_concept_id) + or ( + {"concept_id": source_concept_id, "type_ids": source_type_ids} + if source_type_ids is not None + else _resolve_concept_preview( + source_concept_id, + True, + preview_cache, + ) ), + "type_ids": source_type_ids, } ) @@ -1983,7 +2164,7 @@ def _extract_predicate_incidence_argument_entries( ) if not isinstance(resolved_preview, Mapping): if target_type_ids is not None: - resolved_preview = { + resolved_preview = preview_cache.get(target_concept_id) or { "concept_id": target_concept_id, "type_ids": list(target_type_ids), } @@ -2139,7 +2320,16 @@ def _accumulate_predicate_incidence_role_expansion( and frame_id not in frames ): continue - frame_type_ids = _resolve_accessible_concept_type_ids(frame_id) + raw_frame_type_ids = entry.get("type_ids") + frame_type_ids = ( + [ + str(type_id).strip() + for type_id in raw_frame_type_ids + if isinstance(type_id, str) and str(type_id).strip() + ] + if isinstance(raw_frame_type_ids, list) + else _resolve_accessible_concept_type_ids(frame_id) + ) if frame_type_ids is None: continue if allowed_node_types and not ( diff --git a/src/backend/services/registered_tool_capability_retrieval_service.py b/src/backend/services/registered_tool_capability_retrieval_service.py deleted file mode 100644 index b0d64e410..000000000 --- a/src/backend/services/registered_tool_capability_retrieval_service.py +++ /dev/null @@ -1,470 +0,0 @@ -"""Semantic retrieval for registered ordinary-turn tool capabilities. - -The ordinary-turn selector already retrieves represented workflows through the -workflow capability index. Registered tools need an equivalent semantic -surface so both plan shapes can be ranked on the same model-visible frontier. - -This index contains only tool interface metadata (name, description, planner -hint, and declared evidence surface). Delegation remains authoritative in the -adaptive-turn gateway: retrieval results are always intersected with the -already delegated candidate set and can neither expose nor invoke an -undelegated capability. -""" - -from __future__ import annotations - -import hashlib -import json -import logging -import os -import tempfile -import threading -from collections.abc import Mapping, Sequence -from pathlib import Path -from typing import Any - -from src.backend.services.rag_service import build_rag_retrieval_state - -logger = logging.getLogger(__name__) - -REGISTERED_TOOL_CAPABILITY_NAMESPACE = "registered_tool_capabilities_v1" -REGISTERED_TOOL_CAPABILITY_RETRIEVAL_SCHEMA_VERSION = ( - "registered_tool_capability_retrieval.v1" -) -REGISTERED_TOOL_CAPABILITY_INDEX_SCHEMA_VERSION = "registered_tool_capability_index.v1" -_DOCUMENT_TYPE = "registered_tool_capability" -_MANIFEST_FILENAME = "registered_tool_capability_manifest.json" -_RETRIEVAL_LIMIT = 30 - -_index_lock = threading.Lock() -_indexed_digest: str | None = None -_runtime_state: dict[str, Any] = { - "schema_version": REGISTERED_TOOL_CAPABILITY_INDEX_SCHEMA_VERSION, - "status": "not_initialised", - "ready": False, -} -_prewarm_thread: threading.Thread | None = None - - -def _get_capability_rag_service() -> Any: - from src.backend.services.rag_service import get_rag_service - - service = get_rag_service("llamaindex") - if type(service).__name__ != "LlamaIndexRAGService": - raise RuntimeError( - "registered tool capability retrieval requires the llamaindex backend" - ) - return service - - -def _clean_text(value: Any) -> str: - return str(value or "").strip() - - -def _capability_document(candidate: Mapping[str, Any]) -> dict[str, Any] | None: - name = _clean_text(candidate.get("name")) - if not name: - return None - description = _clean_text(candidate.get("description")) - planner_hint = _clean_text(candidate.get("planner_hint")) - surface_family = _clean_text( - candidate.get("evidence_surface_family") or candidate.get("surface_family") - ) - # Put the short, planner-authored semantic description first and avoid - # repeated boilerplate shared by every document; repeated "direct - # capability" prose dilutes rather than improves embedding discrimination. - text_parts = [ - f"Capability: {name.replace('_', ' ')}.", - planner_hint, - description, - f"Evidence surface: {surface_family}." if surface_family else "", - ] - text = "\n".join(part for part in text_parts if part) - return { - "id": f"registered_tool_capability:{name}", - "text": text, - "metadata": { - "type": _DOCUMENT_TYPE, - "capability_name": name, - "kind": "registered_tool", - }, - } - - -def _normalised_documents( - candidates: Sequence[Mapping[str, Any]], -) -> list[dict[str, Any]]: - documents: dict[str, dict[str, Any]] = {} - for candidate in candidates: - document = _capability_document(candidate) - if document is None: - continue - documents[str(document["id"])] = document - return [documents[key] for key in sorted(documents)] - - -def _document_digest(documents: Sequence[Mapping[str, Any]]) -> str: - canonical = json.dumps( - list(documents), - ensure_ascii=True, - sort_keys=True, - separators=(",", ":"), - default=str, - ) - return hashlib.sha256(canonical.encode("utf-8")).hexdigest() - - -def _manifest_path(rag_service: Any) -> Path | None: - namespace_dir_builder = getattr(rag_service, "_namespace_persist_dir", None) - if not callable(namespace_dir_builder): - return None - try: - namespace_dir = Path( - str(namespace_dir_builder(REGISTERED_TOOL_CAPABILITY_NAMESPACE)) - ).resolve() - except Exception: # noqa: BLE001 - return None - return namespace_dir / _MANIFEST_FILENAME - - -def _read_manifest(rag_service: Any) -> dict[str, Any] | None: - path = _manifest_path(rag_service) - if path is None or not path.is_file(): - return None - try: - payload = json.loads(path.read_text(encoding="utf-8")) - except Exception: # noqa: BLE001 - return None - return dict(payload) if isinstance(payload, Mapping) else None - - -def _write_manifest( - rag_service: Any, - *, - digest: str, - document_count: int, -) -> None: - path = _manifest_path(rag_service) - if path is None: - return - path.parent.mkdir(parents=True, exist_ok=True) - payload = { - "schema_version": REGISTERED_TOOL_CAPABILITY_INDEX_SCHEMA_VERSION, - "namespace": REGISTERED_TOOL_CAPABILITY_NAMESPACE, - "digest": digest, - "document_count": int(document_count), - } - handle, temporary_path = tempfile.mkstemp( - prefix=f".{_MANIFEST_FILENAME}.", - suffix=".tmp", - dir=str(path.parent), - text=True, - ) - try: - with os.fdopen(handle, "w", encoding="utf-8") as stream: - json.dump(payload, stream, ensure_ascii=True, sort_keys=True) - stream.write("\n") - os.replace(temporary_path, path) - finally: - if os.path.exists(temporary_path): - os.unlink(temporary_path) - - -def _namespace_is_compatible(rag_service: Any) -> bool: - get_state = getattr(rag_service, "get_namespace_runtime_state", None) - if not callable(get_state): - return False - try: - state = get_state(REGISTERED_TOOL_CAPABILITY_NAMESPACE) - except Exception: # noqa: BLE001 - return False - return bool(isinstance(state, Mapping) and state.get("compatible") is True) - - -def _set_runtime_state(**values: Any) -> None: - global _runtime_state - _runtime_state = { - "schema_version": REGISTERED_TOOL_CAPABILITY_INDEX_SCHEMA_VERSION, - **values, - } - - -def ensure_registered_tool_capability_index( - candidates: Sequence[Mapping[str, Any]], -) -> dict[str, Any]: - """Ensure the persisted semantic index matches current tool metadata.""" - - global _indexed_digest - - documents = _normalised_documents(candidates) - digest = _document_digest(documents) - if _indexed_digest == digest: - return get_registered_tool_capability_index_state() - current_state = get_registered_tool_capability_index_state() - if ( - current_state.get("status") == "building" - and current_state.get("digest") == digest - ): - # Startup prewarming must not turn a first-turn retrieval into a wait - # for the embedding/index build. The caller receives a typed, - # non-authoritative unavailable state and can fall back to the bounded - # catalogue until the same build becomes ready. - return current_state - - with _index_lock: - if _indexed_digest == digest: - return get_registered_tool_capability_index_state() - _set_runtime_state( - status="building", - ready=False, - digest=digest, - document_count=len(documents), - ) - try: - rag_service = _get_capability_rag_service() - manifest = _read_manifest(rag_service) - if ( - isinstance(manifest, Mapping) - and manifest.get("digest") == digest - and _namespace_is_compatible(rag_service) - ): - _indexed_digest = digest - _set_runtime_state( - status="ready", - ready=True, - source="compatible_persisted_index", - digest=digest, - document_count=len(documents), - ) - return get_registered_tool_capability_index_state() - - rag_service.reset_namespace(REGISTERED_TOOL_CAPABILITY_NAMESPACE) - success_count, failure_count = rag_service.upsert_documents( - documents, - namespace=REGISTERED_TOOL_CAPABILITY_NAMESPACE, - allow_partial_failures=False, - ) - if failure_count or success_count != len(documents): - raise RuntimeError( - "registered tool capability retrieval sync failed " - f"(success={success_count}, failed={failure_count}, " - f"expected={len(documents)})" - ) - _write_manifest( - rag_service, - digest=digest, - document_count=len(documents), - ) - _indexed_digest = digest - _set_runtime_state( - status="ready", - ready=True, - source="rebuilt", - digest=digest, - document_count=len(documents), - ) - except Exception as exc: # noqa: BLE001 - logger.warning( - "registered_tool_capability_index_build_failed: %s", - exc, - ) - _set_runtime_state( - status="unavailable", - ready=False, - digest=digest, - document_count=len(documents), - error_type=type(exc).__name__, - error=str(exc)[:500], - ) - return get_registered_tool_capability_index_state() - - -def retrieve_registered_tool_capability_scores( - query: str, - candidates: Sequence[Mapping[str, Any]], - *, - max_results: int = _RETRIEVAL_LIMIT, - gateway: Any | None = None, -) -> tuple[dict[str, float], dict[str, Any]]: - """Return semantic scores only for the supplied delegated candidates.""" - - clean_query = _clean_text(query) - allowed_names = { - name - for candidate in candidates - for name in [_clean_text(candidate.get("name"))] - if name - } - if not clean_query or not allowed_names: - return {}, { - "schema_version": REGISTERED_TOOL_CAPABILITY_RETRIEVAL_SCHEMA_VERSION, - "status": "not_requested", - "result_count": 0, - } - - index_candidates = ( - _gateway_capability_candidates(gateway) if gateway is not None else candidates - ) - index_state = ensure_registered_tool_capability_index(index_candidates) - if index_state.get("ready") is not True: - return {}, { - "schema_version": REGISTERED_TOOL_CAPABILITY_RETRIEVAL_SCHEMA_VERSION, - "status": "unavailable", - "result_count": 0, - "index": index_state, - "retrieval_state": build_rag_retrieval_state( - "unavailable", - result_count=0, - cause="registered_tool_capability_index_unavailable", - ), - } - - requested_results = max(1, min(50, int(max_results))) - try: - rag_service = _get_capability_rag_service() - rag_results = rag_service.query( - query_text=clean_query, - top_k=requested_results, - namespace=REGISTERED_TOOL_CAPABILITY_NAMESPACE, - hybrid=True, - permissions_context={ - "type": _DOCUMENT_TYPE, - "retrieval_candidate_limit": max( - requested_results, - min(200, len(allowed_names)), - ), - }, - ) - retrieval_state = getattr(rag_results, "retrieval_state", None) - scores: dict[str, float] = {} - for result in rag_results: - if not isinstance(result, Mapping): - continue - metadata = result.get("metadata") - if not isinstance(metadata, Mapping): - continue - name = _clean_text(metadata.get("capability_name")) - if name not in allowed_names: - continue - try: - score = max(0.0, min(1.0, float(result.get("score") or 0.0))) - except (TypeError, ValueError): - continue - scores[name] = max(score, scores.get(name, 0.0)) - return scores, { - "schema_version": REGISTERED_TOOL_CAPABILITY_RETRIEVAL_SCHEMA_VERSION, - "status": "results_available" if scores else "valid_empty", - "result_count": len(scores), - "requested_result_count": requested_results, - "index": index_state, - **( - {"retrieval_state": dict(retrieval_state)} - if isinstance(retrieval_state, Mapping) - else {} - ), - } - except Exception as exc: # noqa: BLE001 - logger.warning("registered_tool_capability_retrieval_failed: %s", exc) - return {}, { - "schema_version": REGISTERED_TOOL_CAPABILITY_RETRIEVAL_SCHEMA_VERSION, - "status": "degraded", - "result_count": 0, - "index": index_state, - "error_type": type(exc).__name__, - "error": str(exc)[:500], - "retrieval_state": build_rag_retrieval_state( - "degraded", - result_count=0, - cause=f"query_failed:{type(exc).__name__}", - ), - } - - -def get_registered_tool_capability_index_state() -> dict[str, Any]: - return dict(_runtime_state) - - -def reset_registered_tool_capability_index_state() -> None: - """Reset process-local state; intended for tests and explicit rebuilds.""" - - global _indexed_digest, _prewarm_thread - with _index_lock: - _indexed_digest = None - _prewarm_thread = None - _set_runtime_state(status="not_initialised", ready=False) - - -def _gateway_capability_candidates(gateway: Any) -> list[dict[str, Any]]: - from src.backend.services.tool_metadata_service import ( - get_tool_description, - get_tool_dispatch_surface_metadata, - get_tool_planner_hint, - ) - - candidates: list[dict[str, Any]] = [] - describe_methods = getattr(gateway, "describe_methods", None) - described_methods = describe_methods() if callable(describe_methods) else {} - names = ( - sorted(str(name) for name in described_methods) - if isinstance(described_methods, Mapping) - else [] - ) - for name in names: - definition = gateway.get_method_definition(name) - if definition is None: - continue - fallback_description = ( - _clean_text(definition.description) - or _clean_text(getattr(definition.input_schema, "description", None)) - or f"Use {name}." - ) - description = ( - get_tool_description( - name, - fallback_description=fallback_description, - ) - or fallback_description - ) - surface = get_tool_dispatch_surface_metadata(name) - surface_family = _clean_text(getattr(surface, "surface_family", None)) - evidence_surface_family = _clean_text( - getattr(surface, "evidence_surface_family", None) - ) - candidates.append( - { - "name": name, - "description": description, - "planner_hint": get_tool_planner_hint(name), - "surface_family": surface_family, - "evidence_surface_family": evidence_surface_family, - } - ) - return candidates - - -def prewarm_registered_tool_capability_index(gateway: Any) -> bool: - """Start one best-effort background build after the gateway is available.""" - - global _prewarm_thread - with _index_lock: - if _prewarm_thread is not None and _prewarm_thread.is_alive(): - return False - - def _run() -> None: - try: - ensure_registered_tool_capability_index( - _gateway_capability_candidates(gateway) - ) - except Exception as exc: # noqa: BLE001 - logger.warning( - "registered_tool_capability_index_prewarm_failed: %s", - exc, - ) - - _prewarm_thread = threading.Thread( - target=_run, - name="registered_tool_capability_index_prewarm", - daemon=True, - ) - _prewarm_thread.start() - return True diff --git a/src/backend/services/relationship_extent_index_service.py b/src/backend/services/relationship_extent_index_service.py index 9856a3cf6..337da716b 100644 --- a/src/backend/services/relationship_extent_index_service.py +++ b/src/backend/services/relationship_extent_index_service.py @@ -674,6 +674,7 @@ def _flush_pending_docs(pending_docs: list[dict[str, Any]]) -> None: def _build_relationship_extent_index_query( *, predicate_id: str | None = None, + predicate_ids: Iterable[str] | None = None, target_value: str | None = None, target_values: Iterable[str] | None = None, source_concept_id: str | None = None, @@ -682,8 +683,41 @@ def _build_relationship_extent_index_query( query: dict[str, Any] = { "schema_version": RELATIONSHIP_EXTENT_INDEX_SCHEMA_VERSION, } - if predicate_id: - query["predicate_id"] = predicate_id + predicate_filter_supplied = bool(predicate_id) or predicate_ids is not None + raw_predicate_ids = ( + [predicate_ids] + if isinstance(predicate_ids, str) + else list(predicate_ids or []) + ) + resolved_predicate_ids = list( + dict.fromkeys( + [ + *( + [predicate_id.strip()] + if isinstance(predicate_id, str) and predicate_id.strip() + else [] + ), + *( + item.strip() + for item in raw_predicate_ids + if isinstance(item, str) and item.strip() + ), + ] + ) + ) + if exclude_structural_predicates and resolved_predicate_ids: + structural_predicates = get_relationship_kinds_set() + resolved_predicate_ids = [ + item for item in resolved_predicate_ids if item not in structural_predicates + ] + if predicate_filter_supplied and not resolved_predicate_ids: + return {}, False + if resolved_predicate_ids: + query["predicate_id"] = ( + resolved_predicate_ids[0] + if len(resolved_predicate_ids) == 1 + else {"$in": resolved_predicate_ids} + ) if target_value: query["target_value"] = target_value elif target_values is not None: @@ -695,19 +729,15 @@ def _build_relationship_extent_index_query( query["target_value"] = {"$in": values} if source_concept_id: query["source_concept_id"] = source_concept_id - if exclude_structural_predicates: - if predicate_id and predicate_id in get_relationship_kinds_set(): - return {}, False - predicate_filter: dict[str, Any] = {"$nin": list(get_relationship_kinds_set())} - if predicate_id: - predicate_filter["$eq"] = predicate_id - query["predicate_id"] = predicate_filter + if exclude_structural_predicates and not resolved_predicate_ids: + query["predicate_id"] = {"$nin": list(get_relationship_kinds_set())} return query, True def query_relationship_extent_index( *, predicate_id: str | None = None, + predicate_ids: Iterable[str] | None = None, target_value: str | None = None, target_values: Iterable[str] | None = None, source_concept_id: str | None = None, @@ -733,6 +763,7 @@ def query_relationship_extent_index( query, should_query = _build_relationship_extent_index_query( predicate_id=predicate_id, + predicate_ids=predicate_ids, target_value=target_value, target_values=target_values, source_concept_id=source_concept_id, diff --git a/src/backend/services/tool_metadata_service.py b/src/backend/services/tool_metadata_service.py index cbcf063f7..8f2f75398 100644 --- a/src/backend/services/tool_metadata_service.py +++ b/src/backend/services/tool_metadata_service.py @@ -217,18 +217,16 @@ class ToolRequiredObligationMetadata: "category": "vontology", "display_template": "Found {count} concepts for {query}", "description": ( - "Search Vontology concept names and descriptions, including " - "predicates and types. Use as schema discovery when a represented " - "entity or relationship is relevant but its exact concept, " - "predicate, direction, or reified shape is not yet known." + "Search Vontology names and descriptions for possible entities, " + "predicates, and types. Start with exact or substring search and " + "enrich only when needed. This discovers possible schema, not the " + "predicates actually used around an anchor or enumeration coverage." ), "planner_hint": ( - "Schema discovery for represented knowledge. Use before a " - "relation-specific read when the relevant entity, predicate, type, " - "inverse direction, or reified relationship shape is uncertain; " - "then use the discovered schema with a relation-bearing lookup. " - "Skip when the exact schema is already known or the task is not " - "about represented knowledge." + "Use when the entity, predicate, type, inverse, or reified shape is " + "unknown, then make a relation-bearing read. For a broad category " + "around a known anchor, use predicate incidence instead: schema search " + "shows what is possible, not what is actually used there." ), "dispatch_surface_family": "knowledge_base", "evidence_surface_family": "knowledge_base", @@ -683,7 +681,10 @@ class ToolRequiredObligationMetadata: "target_concept_max_count": 5, "planner_hint": ( "Use when you already know the concept ID and need grounded represented " - "facts, predicates, or relationships for that specific concept." + "facts for that specific concept. For relationship exploration, prefer " + "a small predicate-filtered relation read; if relation enrichment is " + "needed here, start with a small page without inline previews and hydrate " + "only selected related concepts." ), }, "find_relations_with_argument": { @@ -699,18 +700,20 @@ class ToolRequiredObligationMetadata: "target_concept_argument_name": "concept_id", "target_concept_source": "focal_concept", "target_concept_max_count": 2, - "default_payload": {"limit": 20}, + "default_payload": {"include_concept_preview": False, "limit": 20}, "planner_hint": ( - "Use for entity-relative relationship lookup after resolving the anchor " - "concept. This is relation-bearing evidence, not mere inventory. Choose " - "the cheapest adequate query shape: use relation_kind='binary' for " - "entity-to-entity relations and request text relations or snippets only " - "when the user asks about text content. When the represented predicate " - "is unknown, retrieve schema candidates with vontology_concept_search or " - "inspect predicate incidence, then pass the returned exact predicate IDs " - "in predicate_filter; do not start with a broad unfiltered relation page. " - "Keep argument_index='any' when direction is unknown. A one-direction or " - "lexical-predicate probe cannot support a complete negative result." + "Use for content-bearing relationships after resolving the anchor. " + "Start predicate-filtered around limit 20 with previews off; hydrate " + "selected concepts only when needed. A hit proves existence, not list " + "or count completeness. If predicate, direction, or represented-form " + "coverage is not established, inspect small any-direction incidence " + "first, then pass every fitting exact predicate together in one " + "predicate_filter read; do not select only the most obvious label. " + "When a hit reaches a " + "reified, event, claim, or role node, inspect represented role predicates " + "before treating another filler as the requested entity; co-participation " + "alone does not establish that role. A numeric object index selects one " + "stored slot, not the whole object side." ), }, "find_concepts_by_name": { @@ -778,16 +781,25 @@ class ToolRequiredObligationMetadata: "default_payload": { "argument_index": "subject", "relation_kind": "binary", - "include_argument_type_counts": True, + "include_argument_type_counts": False, "include_concept_preview": False, "limit": 12, }, "planner_hint": ( - "Use before a filtered entity-relative lookup when the represented " - "predicate, inverse form, or argument direction is not established. " - "Set argument_index='any' to inspect both directions, then use the " - "returned exact predicate IDs and type counts for the relation read. " - "Do not infer a complete negative from a one-direction incidence probe." + "Use before a filtered relation read when coverage is not established. " + "For a list, count, broad category, or negative, start one small binary " + "argument_index='any' page with limit=20, " + "include_concept_preview=false, include_text_snippets=false, and " + "include_argument_type_counts=false. " + "Treat every semantically fitting row, including inverse forms, as a " + "coverage candidate; pass all returned predicate IDs together in one " + "exact predicate_filter read, do not select only the most obvious label, " + "deduplicate overlaps, " + "and inspect role fillers when a matching row grounds a reified node. " + "Incidence identifies coverage candidates, not the answer entities. A " + "filler qualifies only when its represented role establishes the requested " + "relationship; co-participation alone does not. A numeric object index " + "selects only one stored slot." ), }, "get_concept_usage_profile": { diff --git a/src/backend/workflows/repo_seed_bundles/entity_information_retrieval_workflow_seed_bundle.json b/src/backend/workflows/repo_seed_bundles/entity_information_retrieval_workflow_seed_bundle.json index 47928937d..cd3338f1d 100644 --- a/src/backend/workflows/repo_seed_bundles/entity_information_retrieval_workflow_seed_bundle.json +++ b/src/backend/workflows/repo_seed_bundles/entity_information_retrieval_workflow_seed_bundle.json @@ -2,7 +2,7 @@ "family_id": "entity_information_retrieval_workflow_seed_bundle", "managed_by": "entity_information_retrieval_workflow_vontology_service", "schema_version": "repo_seed_workflow_bundle.v1", - "seed_version": "12", + "seed_version": "13", "source_tag": "JVNAUTOSCI-2075", "supported_action_ids": [ "llm.action" @@ -11,8 +11,8 @@ { "workflow_id": "#V#entity_information_retrieval_workflow", "display_name": "Entity Information Retrieval Workflow", - "description": "Canonical reusable workflow for grounded retrieval of information of a requested kind about a resolved entity, especially authenticated self-relative questions about papers, affiliations, roles, and predicate-filtered extents.", - "content": "Resolve the target entity from authenticated or explicit context, identify the requested information kind, prefer predicate-incidence and relation retrieval before broad inventory or lexical search, filter retrieved results by the requested type or extent, and answer directly from grounded represented evidence.", + "description": "Canonical reusable workflow for a broad, multi-source entity profile or an explicit comparison of asserted facts with uncertain or proposed relationships. It reconciles focal-concept facts, text, predicate incidence, grounded relations, and uncertainty-bearing assertions.", + "content": "Resolve the target entity from authenticated or explicit context and use this workflow when the requested work product needs a multi-faceted profile, cross-checking across distinct evidence surfaces, or an explicit separation of asserted facts from uncertain or proposed relationships. For a single bounded fact or relation extent, prefer an adequate direct read or small composition. Retrieve and reconcile the workflow's required evidence, then answer directly from grounded represented evidence and preserve any material gap.", "type_ids": [ "#V#ai_workflow", "#V#durable_workflow" @@ -40,7 +40,7 @@ }, { "predicate": "#V#hasWorkflowDiscoveryExemplarsJson", - "text": "{\"schema_version\":\"workflow_discovery_exemplars.v1\",\"keywords\":[\"entity information retrieval workflow\",\"information about a resolved entity\",\"authenticated self relation lookup\",\"predicate extent lookup\",\"represented relationship lookup\",\"people related to an entity\",\"entities connected by a relation\",\"students supervised by an entity\",\"PhD students supervised by me\",\"represented affiliation role membership and supervision lookup\",\"who am i and list my papers\",\"papers of mine\"],\"examples\":[\"Tell me who I am and list my papers.\",\"What papers of mine do you know about?\",\"Can you list PhD students supervised by me?\",\"Which people do I supervise in the represented knowledge?\",\"Which roles are associated with this person?\",\"Which organisation is Michael Witbrock affiliated with in the represented knowledge?\",\"Which projects are linked to this researcher?\",\"Who is related to this person by the requested predicate?\",\"¿Qué organizaciones están relacionadas con esta persona en el conocimiento representado?\"]}", + "text": "{\"schema_version\":\"workflow_discovery_exemplars.v1\",\"keywords\":[\"entity information retrieval workflow\",\"broad multi-source entity profile\",\"bounded entity dossier\",\"multi-faceted represented self profile\",\"reconcile concept text and relationship evidence\",\"cross-check asserted and uncertain relationships\",\"separate represented facts from likely inferences\"],\"examples\":[\"Build a bounded multilingual concept dossier for #V#person from its names, descriptions and incoming/outgoing relationships. Keep it read-only and summarise the evidence.\",\"Give me a multi-faceted profile of this researcher, including descriptions, affiliations, works, and uncertainty-bearing assertions.\",\"Separate established represented facts about this person from uncertain or proposed relationships and cite the evidence.\",\"Tell me who I am, list my papers and affiliations, and distinguish asserted facts from uncertain relationships.\"]}", "lang": "en-NZ" }, { diff --git a/tests/backend/test_adaptive_turn_service.py b/tests/backend/test_adaptive_turn_service.py index 612373568..b62674ae4 100644 --- a/tests/backend/test_adaptive_turn_service.py +++ b/tests/backend/test_adaptive_turn_service.py @@ -69,28 +69,6 @@ def _acknowledge_effect_observation_journal( ) -@pytest.fixture(autouse=True) -def _disable_live_registered_tool_capability_rag( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Keep unit tests independent of the configured embedding backend.""" - - from src.backend.services import registered_tool_capability_retrieval_service - - monkeypatch.setattr( - registered_tool_capability_retrieval_service, - "retrieve_registered_tool_capability_scores", - lambda _query, _candidates, **_kwargs: ( - {}, - { - "schema_version": "registered_tool_capability_retrieval.v1", - "status": "valid_empty", - "result_count": 0, - }, - ), - ) - - class _SequenceClient: def __init__(self, *responses: Any) -> None: self.responses = list(responses) @@ -173,7 +151,7 @@ def _gateway(handler: Any) -> InternalMCPGateway: ) -def test_scope_message_prefers_progressive_low_cost_read_plans() -> None: +def test_scope_message_contains_boundaries_not_selection_policy() -> None: message = _scope_message( TrustedTurnScope( user_concept_id="#V#person", @@ -184,10 +162,10 @@ def test_scope_message_prefers_progressive_low_cost_read_plans() -> None: final_synthesis=False, ) - assert "start with the least costly observable plan" in message - assert "escalate only if its returned evidence exposes a material gap" in message - assert "one schema-discovery read followed by one content-bearing read" in message - assert "merely to hedge uncertainty" in message + assert "Authenticated actor: #V#person" in message + assert "Effect boundary:" in message + assert "least costly" not in message + assert "representedness" not in message def _gmail_gateway(handler: Any) -> InternalMCPGateway: @@ -720,6 +698,11 @@ def test_plain_answer_gets_trusted_scope_and_generic_read_doorway() -> None: for tool in client.calls[0]["available_tools"] if tool.name == "turn_capabilities" ) + assert "complete unranked compact purpose index" in catalogue_tool.description + assert "represented workflows are peers" in catalogue_tool.description + assert "representedness does not rank a plan" in catalogue_tool.description + assert "smallest plan that can produce" in catalogue_tool.description + assert "all alternatives being compared together" in catalogue_tool.description assert set(catalogue_tool.input_schema["properties"]) == { "query", "names", @@ -3068,7 +3051,18 @@ def test_capability_query_ranks_without_eliminating_the_delegated_set( "public_web_search", "internal_record_search", ] - public_web, internal = web_query["capabilities"] + assert all( + set(item) == {"name", "query_match", "selection"} + for item in web_query["capabilities"] + ) + exact = _capability_catalogue( + gateway, + ("internal_record_search", "public_web_search"), + {"names": ["internal_record_search", "public_web_search"]}, + ) + exact_by_name = {item["name"]: item for item in exact["capabilities"]} + public_web = exact_by_name["public_web_search"] + internal = exact_by_name["internal_record_search"] assert public_web["surface_family"] == "web" assert public_web["external_surface"] is True assert internal["surface_family"] == "represented_records" @@ -3077,6 +3071,186 @@ def test_capability_query_ranks_without_eliminating_the_delegated_set( assert "actor_id" not in internal["input_schema"]["properties"] +def test_complete_purpose_index_keeps_zero_overlap_direct_tool_visible( + monkeypatch, +) -> None: + from src.backend.services import tool_metadata_service + from src.backend.services.workflow_turn_capability_service import ( + WorkflowTurnCapability, + ) + + target_name = "find_relations_with_argument" + target_description = ( + "Return represented relations containing a supplied entity at a selected " + "argument position, with bounded predicate, certainty, direction, preview, " + "and pagination controls for direct evidence reads. A second sentence adds " + "detail that the compact purpose index must not repeat." + ) + families = ( + "knowledge_lookup", + "mail_search", + "project_read", + "record_fetch", + "task_query", + "workflow_status", + "web_research", + "zotero_search", + ) + filler_names = [ + f"{family}_{index:02d}" + for family in families + for index in range(12) + ][:95] + capability_names = (target_name, *filler_names) + catalogue = MethodCatalogue() + for name in capability_names: + catalogue.register( + MethodDefinition( + name=name, + handler=lambda **_kwargs: {"success": True}, + input_schema=Schema( + required={"query": str}, + optional={ + f"bounded_field_{index}": str for index in range(8) + }, + allow_unknown=False, + ), + category="read", + description=( + target_description + if name == target_name + else ( + "Inspect a bounded source and return provenance-bearing " + "records for the requested research operation." + ) + ), + ) + ) + gateway = InternalMCPGateway( + catalogue=catalogue, + transport=InternalMCPTransport(read_timeout_sec=1.0), + enabled=True, + ) + monkeypatch.setattr( + tool_metadata_service, + "get_tool_description", + lambda name, *, fallback_description=None: ( + target_description if name == target_name else fallback_description + ), + ) + monkeypatch.setattr( + tool_metadata_service, + "get_tool_planner_hint", + lambda _name: None, + ) + monkeypatch.setattr( + tool_metadata_service, + "get_tool_dispatch_surface_metadata", + lambda _name: None, + ) + workflows = tuple( + WorkflowTurnCapability( + name=f"represented_workflow_academic_profile_{index:02d}", + workflow_id=f"#V#academic_profile_workflow_{index:02d}", + display_name=f"Academic profile workflow {index:02d}", + description=( + "Compile and verify a multi-source academic mentorship dossier." + if index == 0 + else "Compile and verify a bounded multi-source research dossier." + ), + relevance_score=0.97 - (index * 0.01), + input_schema={"type": "object", "properties": {}}, + declared_step_count=5, + semantic_effect=False, + semantic_effect_source="declared_registered_read_components", + ) + for index in range(10) + ) + + raw = _capability_catalogue( + gateway, + capability_names, + {"query": "Which scholar mentors Michael?", "limit": 50}, + workflow_capabilities=workflows, + ) + assert len(_json_bytes(raw)) <= 24_000 + assert len(raw["capabilities"]) == 50 + assert all( + "description" not in item and "input_schema" not in item + for item in raw["capabilities"] + ) + + target_detail = next( + item for item in raw["capabilities"] if item["name"] == target_name + ) + assert target_detail["query_match"] is False + purpose_index = raw["purpose_index"] + entries = purpose_index["entries"] + assert purpose_index["complete"] is True + assert purpose_index["ordering"] == "name_ascending_unranked" + assert purpose_index["purpose_projection"] == "first_authored_sentence" + assert len(entries) == len(capability_names) + len(workflows) + assert [entry["name"] for entry in entries] == sorted( + (*capability_names, *(workflow.name for workflow in workflows)), + key=str.lower, + ) + target_purpose = next( + entry for entry in entries if entry["name"] == target_name + ) + assert target_purpose["purpose"].endswith("...") + assert len(target_purpose["purpose"]) <= 160 + assert "second sentence" not in target_purpose["purpose"].lower() + assert set(target_purpose) == {"name", "purpose"} + workflow_purpose = next( + entry for entry in entries if entry["name"] == workflows[0].name + ) + assert workflow_purpose["shape"] == "represented_workflow" + assert "query_match" not in workflow_purpose + assert "selection" not in workflow_purpose + + non_english = _capability_catalogue( + gateway, + capability_names, + {"query": "Qui Michel encadre-t-il ?", "limit": 50}, + workflow_capabilities=workflows, + ) + non_english_target = next( + item for item in non_english["capabilities"] if item["name"] == target_name + ) + assert non_english_target["query_match"] is False + assert non_english["purpose_index"] == purpose_index + + bounded_results = _bound_tool_results_for_model( + [ + ToolResult( + call_id="realistic-purpose-index", + tool_name="turn_capabilities", + output=raw, + status="ok", + ) + ] + ) + assert bounded_results is not None + bounded = bounded_results[0].output + bounded_size = len(_json_bytes(bounded)) + assert len(_json_bytes(purpose_index)) < 24_000 + assert bounded_size <= 24_000 + assert bounded["purpose_index"] == purpose_index + + exact = _capability_catalogue( + gateway, + capability_names, + {"names": [target_name], "limit": 1}, + workflow_capabilities=workflows, + ) + assert "purpose_index" not in exact + assert exact["capabilities"][0]["name"] == target_name + assert set(exact["capabilities"][0]["input_schema"]["properties"]) == { + "query", + *(f"bounded_field_{index}" for index in range(8)), + } + + def test_schema_discovery_metadata_is_retrievable_without_list_word_trigger( monkeypatch, ) -> None: @@ -3117,7 +3291,15 @@ def test_schema_discovery_metadata_is_retrievable_without_list_word_trigger( capability = positive["capabilities"][0] assert capability["query_match"] is True - assert "schema discovery" in capability["planner_hint"].lower() + exact = _capability_catalogue( + gateway, + ("vontology_concept_search",), + {"names": ["vontology_concept_search"]}, + ) + planner_hint = exact["capabilities"][0]["planner_hint"].lower() + assert "unknown" in planner_hint + assert "relation-bearing read" in planner_hint + assert "what is possible, not what is actually used" in planner_hint assert negative["capabilities"][0]["query_match"] is False finally: tool_metadata_service.invalidate_cache() @@ -3153,7 +3335,15 @@ def fail_metadata(*_args, **_kwargs): assert result["total"] == 1 assert result["matched_total"] == 0 assert result["capabilities"][0]["name"] == "general_read" - assert result["capabilities"][0]["description"] == ( + assert result["purpose_index"]["entries"][0]["purpose"] == ( + "Read arbitrary general evidence." + ) + exact = _capability_catalogue( + gateway, + ("general_read",), + {"names": ["general_read"]}, + ) + assert exact["capabilities"][0]["description"] == ( "Read arbitrary general evidence." ) @@ -3236,27 +3426,36 @@ def test_capability_frontier_promotes_direct_components_without_hiding_workflow( "find_relations_with_argument", "students_index", ] - direct = result["capabilities"][0] - represented = result["capabilities"][1] + direct_reference = result["capabilities"][0] + exact = _capability_catalogue( + gateway, + ("fetch_concept", "find_relations_with_argument", "students_index"), + { + "names": [ + "fetch_concept", + "represented_workflow_entity_lookup", + ] + }, + workflow_capabilities=(workflow,), + ) + exact_by_name = {item["name"]: item for item in exact["capabilities"]} + direct = exact_by_name["fetch_concept"] + represented = exact_by_name["represented_workflow_entity_lookup"] assert direct["plan_profile"]["shape"] == "single_capability" assert represented["plan_profile"]["shape"] == "represented_workflow" assert represented["semantic_effect"] is False assert represented["effect_profile"]["operational_state_effect"] is True - assert any( - evidence["source"] == "declared_component_of_matched_workflow" - for evidence in direct["selection"]["adequacy_evidence"] + assert "declared_component_of_matched_workflow" in ( + direct_reference["selection"]["adequacy_sources"] ) assert result["frontier_total"] == 4 assert result["dominated_total"] == 0 -def test_workflow_components_remain_visible_ahead_of_unrelated_tool_rag_hits( +def test_workflow_components_remain_visible_ahead_of_unrelated_tool_matches( monkeypatch, ) -> None: - from src.backend.services import ( - registered_tool_capability_retrieval_service, - tool_metadata_service, - ) + from src.backend.services import tool_metadata_service from src.backend.services.workflow_turn_capability_service import ( WorkflowTurnCapability, ) @@ -3297,18 +3496,6 @@ def test_workflow_components_remain_visible_ahead_of_unrelated_tool_rag_hits( "get_tool_dispatch_surface_metadata", lambda _name: None, ) - monkeypatch.setattr( - registered_tool_capability_retrieval_service, - "retrieve_registered_tool_capability_scores", - lambda _query, _candidates, **_kwargs: ( - {"workflow_list_instances": 0.9}, - { - "schema_version": "registered_tool_capability_retrieval.v1", - "status": "results_available", - "result_count": 1, - }, - ), - ) workflow = WorkflowTurnCapability( name="represented_workflow_entity_lookup", workflow_id="#V#entity_lookup_workflow", @@ -3337,10 +3524,8 @@ def test_workflow_components_remain_visible_ahead_of_unrelated_tool_rag_hits( "get_predicate_incidence", ] assert all( - any( - evidence["source"] == "declared_component_of_matched_workflow" - for evidence in item["selection"]["adequacy_evidence"] - ) + "declared_component_of_matched_workflow" + in item["selection"]["adequacy_sources"] for item in ( result["capabilities"][0], result["capabilities"][2], @@ -3349,30 +3534,34 @@ def test_workflow_components_remain_visible_ahead_of_unrelated_tool_rag_hits( ) -def test_semantic_tool_retrieval_puts_cheaper_schema_plan_in_visible_frontier( +def test_descriptions_put_relevant_direct_plan_in_visible_frontier( monkeypatch, ) -> None: - from src.backend.services import ( - registered_tool_capability_retrieval_service, - tool_metadata_service, - ) + from src.backend.services import tool_metadata_service from src.backend.services.workflow_turn_capability_service import ( WorkflowTurnCapability, ) catalogue = MethodCatalogue() - for name in ( - "generic_read", - "search_concepts", - "find_relations_with_argument", - ): + descriptions = { + "generic_read": "Inspect an unrelated operational record.", + "search_concepts": ( + "Search represented concept names, predicates, and types for " + "relationship schema discovery." + ), + "find_relations_with_argument": ( + "Find represented relationships, including people supervised by " + "a concept, where it appears as subject or target." + ), + } + for name, description in descriptions.items(): catalogue.register( MethodDefinition( name=name, handler=lambda **_kwargs: {"success": True}, input_schema=Schema(optional={"query": str}, allow_unknown=True), category="read", - description=f"Use {name}.", + description=description, ) ) gateway = InternalMCPGateway( @@ -3395,22 +3584,6 @@ def test_semantic_tool_retrieval_puts_cheaper_schema_plan_in_visible_frontier( "get_tool_dispatch_surface_metadata", lambda _name: None, ) - monkeypatch.setattr( - registered_tool_capability_retrieval_service, - "retrieve_registered_tool_capability_scores", - lambda _query, _candidates, **_kwargs: ( - { - "search_concepts": 0.97, - "find_relations_with_argument": 0.94, - "generic_read": 0.2, - }, - { - "schema_version": "registered_tool_capability_retrieval.v1", - "status": "results_available", - "result_count": 3, - }, - ), - ) workflow = WorkflowTurnCapability( name="represented_workflow_entity_lookup", workflow_id="#V#entity_lookup_workflow", @@ -3431,21 +3604,21 @@ def test_semantic_tool_retrieval_puts_cheaper_schema_plan_in_visible_frontier( workflow_capabilities=(workflow,), ) - assert [item["name"] for item in result["capabilities"]] == [ - "search_concepts", - "represented_workflow_entity_lookup", + capability_names = [item["name"] for item in result["capabilities"]] + assert capability_names[:2] == [ "find_relations_with_argument", - "generic_read", + "represented_workflow_entity_lookup", ] - assert result["capability_retrieval"]["registered_tools"]["status"] == ( - "results_available" - ) - search_candidate = result["capabilities"][0] - assert search_candidate["plan_profile"]["cost_profile"]["durable_runtime"] is False - assert any( - evidence["source"] == "registered_tool_capability_semantic_relevance" - for evidence in search_candidate["selection"]["adequacy_evidence"] + assert set(capability_names[2:]) == {"search_concepts", "generic_read"} + exact = _capability_catalogue( + gateway, + ("generic_read", "search_concepts", "find_relations_with_argument"), + {"names": ["find_relations_with_argument"]}, + workflow_capabilities=(workflow,), ) + direct_candidate = exact["capabilities"][0] + assert direct_candidate["plan_profile"]["cost_profile"]["durable_runtime"] is False + assert result["selection_policy"]["semantic_adequacy_owner"] == "adaptive_model" def test_capability_frontier_does_not_prefer_unrelated_direct_tool( @@ -3567,9 +3740,9 @@ def test_capability_frontier_uses_catalogue_rarity_not_generic_routing_words( by_name[name]["selection"]["frontier_status"] == "outside_query_frontier" for name in generic_names ) - generic_evidence = by_name[generic_names[0]]["selection"]["adequacy_evidence"] - assert generic_evidence[0]["common_routing_term_count"] == 1 - assert generic_evidence[0]["informative_routing_term_count"] == 0 + assert by_name[generic_names[0]]["selection"]["adequacy_sources"] == [ + "literal_query_terms" + ] def test_declared_read_only_direct_equivalence_prunes_only_frontier( @@ -3654,6 +3827,76 @@ def test_declared_read_only_direct_equivalence_prunes_only_frontier( ) +def test_declared_direct_equivalence_does_not_prune_effectful_workflow( + monkeypatch, +) -> None: + from src.backend.services import tool_metadata_service + from src.backend.services.workflow_turn_capability_service import ( + WorkflowTurnCapability, + ) + + catalogue = MethodCatalogue() + catalogue.register( + MethodDefinition( + name="direct_lookup", + handler=lambda **_kwargs: {"success": True}, + input_schema=Schema(optional={"query": str}, allow_unknown=False), + category="read", + description="Look up the requested record.", + ) + ) + gateway = InternalMCPGateway( + catalogue=catalogue, + transport=InternalMCPTransport(read_timeout_sec=1.0), + enabled=True, + ) + monkeypatch.setattr( + tool_metadata_service, + "get_tool_description", + lambda _name, *, fallback_description=None: fallback_description, + ) + monkeypatch.setattr(tool_metadata_service, "get_tool_planner_hint", lambda _: None) + monkeypatch.setattr( + tool_metadata_service, + "get_tool_dispatch_surface_metadata", + lambda _: None, + ) + workflow = WorkflowTurnCapability( + name="represented_workflow_effectful_lookup", + workflow_id="#V#effectful_lookup_workflow", + display_name="Effectful lookup workflow", + description="Look up the record and publish a represented result.", + relevance_score=0.9, + input_schema={"type": "object", "properties": {}}, + component_capability_names=("direct_lookup",), + declared_component_count=1, + declared_step_count=2, + direct_equivalent_capability_names=("direct_lookup",), + semantic_effect=True, + semantic_effect_source="represented_workflow_declaration", + ) + + result = _capability_catalogue( + gateway, + ("direct_lookup",), + {"query": "look up and publish the requested record", "limit": 10}, + workflow_capabilities=(workflow,), + ) + + assert result["dominated_total"] == 0 + assert result["dominated_capabilities"] == [] + assert {item["name"] for item in result["capabilities"]} == { + "direct_lookup", + "represented_workflow_effectful_lookup", + } + purpose = next( + item + for item in result["purpose_index"]["entries"] + if item["name"] == "represented_workflow_effectful_lookup" + ) + assert purpose["semantic_effect"] is True + + def test_capability_page_budget_preserves_every_alternative_and_cursor( monkeypatch, ) -> None: @@ -3713,7 +3956,8 @@ def test_capability_page_budget_preserves_every_alternative_and_cursor( capability_names, {"query": "research", "offset": 0, "limit": 20}, ) - assert len(_json_bytes(first_raw_page)) > 24_000 + assert len(_json_bytes(first_raw_page)) <= 24_000 + assert len(first_raw_page["capabilities"]) == 20 paired = _bound_tool_results_for_model( [ @@ -3728,10 +3972,8 @@ def test_capability_page_budget_preserves_every_alternative_and_cursor( ) assert paired is not None assert all(item.output.get("capabilities") for item in paired) - assert all( - item.output["next_offset"] == len(item.output["capabilities"]) - for item in paired - ) + assert all(item.output["next_offset"] is None for item in paired) + assert all(len(item.output["capabilities"]) == 20 for item in paired) assert ( len( _json_bytes( @@ -3751,7 +3993,6 @@ def test_capability_page_budget_preserves_every_alternative_and_cursor( seen_names: list[str] = [] seen_entries: list[dict[str, Any]] = [] - schema_references: list[dict[str, Any]] = [] offset: int | None = 0 while offset is not None: raw_page = _capability_catalogue( @@ -3779,34 +4020,30 @@ def test_capability_page_budget_preserves_every_alternative_and_cursor( ] seen_names.extend(item["name"] for item in entries) seen_entries.extend(entries) - schema_references.extend( - item - for item in entries - if item.get("input_schema_omitted_for_model_context") is True - ) next_offset = bounded["next_offset"] if next_offset is not None: assert next_offset == offset + len(entries) offset = next_offset assert seen_names == capability_names - reference = schema_references[0] if schema_references else seen_entries[0] - assert reference["description"].startswith("Search research material.") - assert reference["surface_family"] == "research" - assert reference["evidence_surface_family"] == "research" - assert reference["external_surface"] is False - assert reference["server_bound_arguments"] == ["actor_id"] - if schema_references: - assert reference["input_schema_hydration"] == { - "tool": "turn_capabilities", - "arguments": { - "names": [reference["name"]], - "limit": 1, - }, - "purpose": "dedicated_exact_name_schema_page", - } - else: - assert "input_schema" in reference + reference = seen_entries[0] + assert reference["query_match"] is True + assert reference["selection"]["frontier_status"] == "candidate" + assert "description" not in reference + assert "input_schema" not in reference + purpose_entry = next( + item + for item in bounded["purpose_index"]["entries"] + if item["name"] == reference["name"] + ) + assert purpose_entry["purpose"].startswith("Search research material.") + assert purpose_entry["purpose"].endswith("...") + assert len(purpose_entry["purpose"]) <= 160 + assert bounded["purpose_index"]["exact_schema_hydration"] == { + "tool": "turn_capabilities", + "guidance": "Request all alternatives being compared in one names array.", + "arguments": {"names": [""], "limit": 50}, + } exact_page = _capability_catalogue( gateway, @@ -3827,6 +4064,71 @@ def test_capability_page_budget_preserves_every_alternative_and_cursor( assert "input_schema" in exact_bounded[0].output["capabilities"][0] +def test_bounded_catalogue_compacts_purposes_without_erasing_candidates() -> None: + from src.backend.services.adaptive_turn_service import ( + _bounded_capability_catalogue_output, + _json_bytes, + ) + + entries = [ + { + "name": f"general_capability_{index:03d}", + "purpose": ( + "Perform an ordinary bounded task using represented evidence and " + "return the useful work product while preserving provenance, " + "recoverability, and a concise account of the observed result." + ), + } + for index in range(200) + ] + raw = { + "schema_version": "adaptive_turn_capability_catalogue.v1", + "success": True, + "delegation": "bounded_capabilities", + "total": len(entries), + "delegated_total": len(entries), + "catalogue_scope": "query_frontier", + "offset": 0, + "next_offset": None, + "purpose_index": { + "schema_version": "adaptive_turn_capability_purpose_index.v1", + "complete": True, + "ordering": "name_ascending_unranked", + "purpose_projection": "first_authored_sentence", + "purpose_max_chars": 160, + "exact_schema_hydration": { + "tool": "turn_capabilities", + "guidance": ( + "Request all alternatives being compared in one names array." + ), + "arguments": {"names": [""], "limit": 50}, + }, + "entries": entries, + }, + "capabilities": [ + { + "name": entry["name"], + "query_match": True, + "selection": {"frontier_status": "candidate"}, + } + for entry in entries[:50] + ], + } + + bounded = _bounded_capability_catalogue_output(raw, max_bytes=24_000) + + assert bounded + assert len(_json_bytes(bounded)) <= 24_000 + purpose_index = bounded["purpose_index"] + assert purpose_index["complete"] is True + assert len(purpose_index["entries"]) == len(entries) + assert purpose_index["purpose_text_compacted_for_model_context"] is True + assert purpose_index["purpose_max_chars"] < 160 + assert [item["name"] for item in purpose_index["entries"]] == [ + item["name"] for item in entries + ] + + def test_single_oversized_capability_remains_visible_as_schema_reference( monkeypatch, ) -> None: @@ -3991,23 +4293,18 @@ def test_bulky_capability_metadata_falls_back_without_hiding_the_schema( bounded = bounded_results[0].output assert len(_json_bytes(bounded)) <= 24_000 assert bounded["next_offset"] is None - assert bounded["capability_page_projection"]["returned"] == 1 - assert bounded["capability_page_projection"]["schema_reference_count"] == 1 + assert "capability_page_projection" not in bounded + assert len(bounded["capabilities"]) == 1 compact = bounded["capabilities"][0] assert compact["name"] == name - assert compact["capability_metadata_omitted_for_model_context"] is True - assert compact["input_schema_omitted_for_model_context"] is True - assert compact["input_schema_hydration"] == { - "tool": "turn_capabilities", - "arguments": { - "names": [name], - "limit": 1, - }, - "purpose": "dedicated_exact_name_schema_page", - } - assert compact["semantic_effect"] is False - assert compact["plan_profile"]["shape"] == "single_capability" assert compact["selection"]["frontier_status"] == "candidate" + assert "description" not in compact + assert "input_schema" not in compact + purpose = bounded["purpose_index"]["entries"][0] + assert purpose["name"] == name + assert purpose["purpose"].startswith("Read represented records.") + assert purpose["purpose"].endswith("...") + assert len(purpose["purpose"]) <= 160 exact = _capability_catalogue( gateway, @@ -4330,6 +4627,42 @@ def test_tool_result_correlation_shell_overflow_has_no_oversized_fallback() -> N assert _bound_tool_results_for_model(results) is None +def test_tool_result_batch_preserves_full_outputs_when_aggregate_fits() -> None: + results = [ + ToolResult( + call_id=f"capability-{index}", + tool_name="turn_capabilities", + status="ok", + output={ + "name": f"capability-{index}", + "description": character * size, + "planner_hint": f"planner-{index}", + }, + ) + for index, (character, size) in enumerate( + (("a", 6_700), ("b", 5_100), ("c", 5_400), ("d", 3_300)) + ) + ] + complete_batch = [ + { + "call_id": result.call_id, + "tool_name": result.tool_name, + "status": result.status, + "output": result.output, + } + for result in results + ] + assert len(_json_bytes(complete_batch)) < 24_000 + + bounded = _bound_tool_results_for_model(results) + + assert bounded is not None + assert [result.output for result in bounded] == [ + result.output for result in results + ] + assert bounded[0].output["planner_hint"] == "planner-0" + + def test_bounded_effect_result_preserves_exact_partial_receipt() -> None: result = ToolResult( call_id="partial-effect", @@ -5825,7 +6158,12 @@ def _execute_workflow(**kwargs: Any) -> dict[str, Any]: catalogue_result = client.calls[1]["tool_results"][0].output assert catalogue_result["represented_workflow_total"] == 1 - assert catalogue_result["capabilities"][0]["kind"] == ("represented_workflow") + workflow_purpose = next( + item + for item in catalogue_result["purpose_index"]["entries"] + if item["name"] == workflow_capability.name + ) + assert workflow_purpose["shape"] == "represented_workflow" assert ( catalogue_result["selection_policy"]["semantic_adequacy_owner"] == "adaptive_model" diff --git a/tests/backend/test_concept_relation_service_extent_index.py b/tests/backend/test_concept_relation_service_extent_index.py index 6e16c2959..50832aa0d 100644 --- a/tests/backend/test_concept_relation_service_extent_index.py +++ b/tests/backend/test_concept_relation_service_extent_index.py @@ -49,6 +49,80 @@ def test_relationship_target_visibility_is_batched_without_changing_shape( } +def test_relation_preview_default_is_false_only_on_model_facing_tools( + monkeypatch, +) -> None: + import asyncio + import inspect + + from src.backend.integrations.internal_mcp import catalogue + from src.backend.mcp_server import mcp_stdio_server + from src.backend.services import concept_relation_service as service + from src.backend.services import tool_metadata_service + + assert ( + inspect.signature(service.find_relations_with_argument) + .parameters["include_concept_preview"] + .default + is True + ) + observed: list[bool] = [] + + def fake_find_relations_with_argument(concept_id: str, **kwargs: Any): + observed.append(kwargs["include_concept_preview"]) + return { + "concept_id": concept_id, + "total_hits": 0, + "hits": [], + "paging": { + "limit": 20, + "offset": 0, + "returned": 0, + "total_available": 0, + }, + "relation_query_diagnostics": {}, + } + + monkeypatch.setattr( + service, + "find_relations_with_argument", + fake_find_relations_with_argument, + ) + catalogue._find_relations_with_argument(concept_id="#V#anchor") + catalogue._find_relations_with_argument( + concept_id="#V#anchor", + include_concept_preview=True, + ) + monkeypatch.setattr( + mcp_stdio_server, + "find_relations_with_argument", + fake_find_relations_with_argument, + ) + asyncio.run( + mcp_stdio_server._handle_find_relations_with_argument( + {"concept_id": "#V#anchor"} + ) + ) + asyncio.run( + mcp_stdio_server._handle_find_relations_with_argument( + {"concept_id": "#V#anchor", "include_concept_preview": True} + ) + ) + + monkeypatch.setattr(tool_metadata_service, "_load_from_vontology", dict) + tool_metadata_service.invalidate_cache() + try: + metadata = tool_metadata_service.get_tool_metadata( + "find_relations_with_argument" + ) + assert metadata.default_payload is not None + assert metadata.default_payload["include_concept_preview"] is False + finally: + tool_metadata_service.invalidate_cache() + + assert observed == [False, True, False, True] + + def test_incoming_asserted_binary_uses_extent_index_without_changing_results( monkeypatch, ) -> None: @@ -211,6 +285,7 @@ def test_relation_hits_include_source_previews_for_both_directions( from src.backend.services import concept_relation_service as service focal_id = "#V#focal" + index_queries: list[dict[str, Any]] = [] monkeypatch.setattr( service, "_load_accessible_relation_subject_document", @@ -219,10 +294,9 @@ def test_relation_hits_include_source_previews_for_both_directions( "relationships": {"#V#direct_relation": ["#V#outgoing_related"]}, }, ) - monkeypatch.setattr( - service, - "query_relationship_extent_index", - lambda **_kwargs: ( + def query_index(**kwargs: Any): + index_queries.append(kwargs) + return ( [ { "source_concept_id": "#V#incoming_related", @@ -233,8 +307,9 @@ def test_relation_hits_include_source_previews_for_both_directions( } ], 1, - ), - ) + ) + + monkeypatch.setattr(service, "query_relationship_extent_index", query_index) monkeypatch.setattr( service, "filter_accessible_concept_ids", @@ -253,6 +328,18 @@ def test_relation_hits_include_source_previews_for_both_directions( "relationships": {"is_an_instance_of": ["#V#requested_type"]}, }, } + monkeypatch.setattr(service, "should_enforce_access_control", lambda: False) + monkeypatch.setattr( + service.ConceptsRepository, + "find", + staticmethod( + lambda query, _projection: [ + preview_docs[concept_id] + for concept_id in query["concept_id"]["$in"] + if concept_id in preview_docs + ] + ), + ) monkeypatch.setattr( service, "_load_accessible_preview_document", @@ -277,6 +364,74 @@ def test_relation_hits_include_source_previews_for_both_directions( assert hits_by_source["#V#incoming_related"]["source_concept_preview"][ "type_ids" ] == ["#V#requested_type"] + assert index_queries[0]["predicate_ids"] == [ + "#V#direct_relation", + "#V#inverse_relation", + ] + + +def test_outgoing_relation_previews_batch_uncached_targets(monkeypatch) -> None: + from src.backend.services import concept_relation_service as service + + source_id = "#V#source" + target_ids = ("#V#target_a", "#V#target_b", "#V#target_c") + preview_queries: list[tuple[dict[str, Any], dict[str, int]]] = [] + monkeypatch.setattr( + service, + "_load_accessible_relation_subject_document", + lambda *_args, **_kwargs: { + "concept_id": source_id, + "relationships": { + "#V#supervises": list(target_ids), + "#V#unrelated": ["#V#not_requested"], + }, + }, + ) + monkeypatch.setattr(service, "should_enforce_access_control", lambda: False) + + def fake_find(query, projection): + preview_queries.append((query, projection)) + return [ + { + "concept_id": concept_id, + "name": concept_id.removeprefix("#V#"), + "relationships": {}, + } + for concept_id in (source_id, *target_ids) + ] + + monkeypatch.setattr( + service.ConceptsRepository, + "find", + staticmethod(fake_find), + ) + monkeypatch.setattr( + service, + "_load_accessible_preview_document", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("batched target previews must avoid point reads") + ), + ) + + payload = service.find_relations_with_argument( + source_id, + argument_index="subject", + predicate_filter=["#V#supervises"], + relation_kind="binary", + include_concept_preview=True, + offset=1, + limit=1, + ) + + assert len(preview_queries) == 1 + assert preview_queries[0][0] == { + "concept_id": {"$in": [source_id, "#V#target_b"]} + } + assert payload["total_hits"] == 3 + assert payload["paging"]["returned"] == 1 + assert payload["hits"][0]["target_concept_preview"]["concept_id"] == ( + "#V#target_b" + ) def test_incoming_relation_previews_batch_uncached_sources(monkeypatch) -> None: @@ -356,7 +511,7 @@ def fake_find(query, projection): assert len(preview_queries) == 1 assert preview_queries[0][0] == { "concept_id": { - "$in": [target_id, "#V#source_a", "#V#source_b"] + "$in": ["#V#source_a", target_id, "#V#source_b"] } } assert { @@ -365,6 +520,94 @@ def fake_find(query, projection): } == {"#V#source_a", "#V#source_b"} +def test_incoming_relation_previews_only_hydrate_the_returned_page(monkeypatch) -> None: + from src.backend.services import concept_relation_service as service + + target_id = "#V#target" + source_ids = ("#V#source_a", "#V#source_b", "#V#source_c") + preview_queries: list[tuple[dict[str, Any], dict[str, int]]] = [] + monkeypatch.setattr( + service, + "_load_accessible_relation_subject_document", + lambda *_args, **_kwargs: None, + ) + monkeypatch.setattr( + service, + "incoming_dynamic_extent_rows_page_for_target", + lambda *_args, **_kwargs: ( + [ + { + "source_concept_id": source_id, + "predicate_id": "#V#relation", + "target_value": target_id, + "arg2_index": 2, + "updated_at": None, + } + for source_id in source_ids + ], + True, + { + "used_extent_index": True, + "complete": True, + "bounded": False, + "has_more": False, + "index_rows_scanned": 3, + "rows_filtered_by_access": 0, + }, + ), + ) + monkeypatch.setattr(service, "should_enforce_access_control", lambda: False) + + def fake_find(query, projection): + preview_queries.append((query, projection)) + return [ + { + "concept_id": concept_id, + "name": concept_id.removeprefix("#V#"), + "relationships": {}, + } + for concept_id in query["concept_id"]["$in"] + ] + + monkeypatch.setattr( + service.ConceptsRepository, "find", staticmethod(fake_find) + ) + monkeypatch.setattr( + service, + "_load_accessible_preview_document", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("paged previews must avoid point reads") + ), + ) + + payload = service.find_relations_with_argument( + target_id, + relation_kind="binary", + include_concept_preview=True, + sort_by="predicate", + offset=1, + limit=1, + ) + + assert payload["total_hits"] == 3 + assert payload["paging"] == { + "limit": 1, + "offset": 1, + "returned": 1, + "total_available": 3, + } + assert len(preview_queries) == 1 + assert preview_queries[0][0] == { + "concept_id": {"$in": ["#V#source_b", target_id]} + } + assert payload["hits"][0]["source_concept_id"] == "#V#source_b" + assert payload["hits"][0]["source_concept_preview"]["concept_id"] == ( + "#V#source_b" + ) + assert payload["hits"][0]["target_concept_preview"]["concept_id"] == target_id + assert payload["hits"][0]["access_granted"] is True + + def test_incoming_asserted_binary_falls_back_only_when_index_is_unavailable( monkeypatch, ) -> None: @@ -458,7 +701,7 @@ def fake_find(query, projection, *, max_time_ms=None): } -def test_unresolved_predicate_label_preserves_general_canonical_fallback( +def test_unresolved_predicate_label_returns_typed_incomplete_without_full_scan( monkeypatch, ) -> None: from src.backend.services import concept_relation_service as service @@ -495,14 +738,20 @@ def test_unresolved_predicate_label_preserves_general_canonical_fallback( include_concept_preview=False, ) - assert len(aggregate_pipelines) == 1 + assert aggregate_pipelines == [] + assert payload["total_hits"] == 0 + assert payload["total_hits_is_lower_bound"] is True + assert payload["paging"]["total_available_is_lower_bound"] is True diagnostics = payload["relation_query_diagnostics"][ "incoming_asserted_binary" ] - assert diagnostics["path"] == "canonical_aggregation" + assert diagnostics["path"] == "relationship_extent_index_unavailable" assert diagnostics["fallback_reason"] == ( "relationship_extent_index_unavailable" ) + assert diagnostics["stop_reason"] == "relationship_extent_index_unavailable" + assert diagnostics["bounded"] is False + assert diagnostics["complete"] is False def test_empty_available_extent_index_does_not_trigger_canonical_fallback( @@ -678,3 +927,157 @@ def test_unfiltered_extent_timeout_does_not_start_canonical_full_scan( assert diagnostics["used_relationship_extent_index"] is False assert diagnostics["bounded"] is True assert diagnostics["complete"] is False + + +def test_predicate_incidence_materialises_large_ready_index_once( + monkeypatch, +) -> None: + from src.backend.services import concept_relation_service as service + + target_id = "#V#target" + index_calls: list[dict[str, Any]] = [] + indexed_rows = [ + { + "source_concept_id": f"#V#source_{index}", + "predicate_id": "#V#related_to", + "target_value": target_id, + "target_index": 0, + "source_updated_at": None, + } + for index in range(12_005) + ] + + monkeypatch.setattr( + service, + "_load_accessible_relation_subject_document", + lambda *_args, **_kwargs: None, + ) + + def query_index(**kwargs: Any): + index_calls.append(kwargs) + return indexed_rows, len(indexed_rows) + + monkeypatch.setattr(service, "query_relationship_extent_index", query_index) + monkeypatch.setattr( + service, + "filter_accessible_concept_ids", + set, + ) + monkeypatch.setattr( + service.ConceptsRepository, + "aggregate", + staticmethod(_fail_if_aggregated), + ) + monkeypatch.setattr( + service, + "_load_accessible_preview_document", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("unselected incidence rows must not read previews") + ), + ) + monkeypatch.setattr( + service, + "_attach_direct_type_ids_to_fast_incidence_hits", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("default incidence must not hydrate argument types") + ), + ) + + payload = service.get_predicate_incidence( + concept_id=target_id, + argument_index="object", + relation_kind="binary", + include_concept_preview=False, + limit=20, + ) + + assert len(index_calls) == 1 + assert payload["coverage_complete"] is True + assert "counts_are_lower_bounds" not in payload + assert payload["total_predicates"] == 1 + assert payload["predicates"][0]["predicate_concept_id"] == "#V#related_to" + assert payload["predicates"][0]["relation_hit_count"] == 12_005 + assert payload["predicates"][0]["grounding_count"] == 12_005 + assert payload["predicates"][0]["object_argument_hit_count"] == 12_005 + + +def test_predicate_incidence_reports_incomplete_without_unindexed_full_scan( + monkeypatch, +) -> None: + from src.backend.services import concept_relation_service as service + + target_id = "#V#target" + aggregate_calls: list[list[dict[str, Any]]] = [] + incoming_rows = [ + { + "concept_id": f"#V#source_{index}", + "predicate": "#V#related_to", + "targets": [target_id], + "updated_at": None, + } + for index in range(12_005) + ] + + monkeypatch.setattr( + service, + "_load_accessible_relation_subject_document", + lambda *_args, **_kwargs: None, + ) + monkeypatch.setattr( + service, + "incoming_dynamic_extent_rows_page_for_target", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("complete incidence must not use a bounded relation page") + ), + ) + monkeypatch.setattr( + service, + "query_relationship_extent_index", + lambda **_kwargs: ([], -1), + ) + monkeypatch.setattr( + service.ConceptsRepository, + "aggregate", + staticmethod( + lambda pipeline: aggregate_calls.append(pipeline) or incoming_rows + ), + ) + monkeypatch.setattr( + service, + "_load_accessible_preview_document", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("unselected incidence rows must not read previews") + ), + ) + monkeypatch.setattr( + service, + "_attach_direct_type_ids_to_fast_incidence_hits", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("default incidence must not hydrate argument types") + ), + ) + + payload = service.get_predicate_incidence( + concept_id=target_id, + argument_index="object", + relation_kind="binary", + include_concept_preview=False, + limit=20, + ) + + assert aggregate_calls == [] + assert payload["coverage_complete"] is False + assert payload["counts_are_lower_bounds"] is True + assert payload["total_predicates"] == 0 + assert payload["predicates"] == [] + assert payload["paging"]["total_available_is_lower_bound"] is True + diagnostics = payload["predicate_incidence_query_diagnostics"] + assert diagnostics["coverage_complete"] is False + assert diagnostics["coverage_gaps"] == [ + { + "relation_kind": "binary", + "argument_index": "object", + "path": "relationship_extent_index_unavailable", + "reason": "relationship_extent_index_unavailable", + } + ] diff --git a/tests/backend/test_concept_relation_service_uncertainty.py b/tests/backend/test_concept_relation_service_uncertainty.py index 20da4ab4b..979ed126c 100644 --- a/tests/backend/test_concept_relation_service_uncertainty.py +++ b/tests/backend/test_concept_relation_service_uncertainty.py @@ -298,6 +298,7 @@ def test_relation_previews_and_predicate_incidence_groundings_include_type_ids() concept_id="#V#michael_witbrock", argument_index="subject", relation_kind="binary", + include_concept_preview=True, ) rows = incidence_payload.get("predicates") or [] author_row = next( @@ -520,9 +521,11 @@ def fail_per_hit_type_resolution(*_args, **_kwargs): assert target_type_count["concept_count"] == 2 -def test_get_predicate_incidence_role_expands_reified_neighbour_roles() -> None: +def test_get_predicate_incidence_role_expands_reified_neighbour_roles( + monkeypatch, +) -> None: from src.backend.db.repositories.concepts_repository import ConceptsRepository - from src.backend.services.concept_relation_service import get_predicate_incidence + from src.backend.services import concept_relation_service as service ConceptsRepository.insert_one( {"concept_id": "#V#michael_witbrock", "relationships": {}} @@ -551,8 +554,24 @@ def test_get_predicate_incidence_role_expands_reified_neighbour_roles() -> None: "relationships": {"is_an_instance_of": ["#V#file_copy"]}, } ) + monkeypatch.setattr( + service, + "query_relationship_extent_index", + lambda **_kwargs: ( + [ + { + "source_concept_id": "#V#authorship_event_one", + "predicate_id": "#V#has_author", + "target_value": "#V#michael_witbrock", + "target_index": 0, + "source_updated_at": None, + } + ], + 1, + ), + ) - payload = get_predicate_incidence( + payload = service.get_predicate_incidence( concept_id="#V#michael_witbrock", argument_index="object", relation_kind="binary", @@ -595,7 +614,7 @@ def test_get_predicate_incidence_role_expands_reified_neighbour_roles() -> None: payload["role_expansions"][0]["anchor_predicate_concept_id"] == "#V#has_author" ) - auto_payload = get_predicate_incidence( + auto_payload = service.get_predicate_incidence( concept_id="#V#michael_witbrock", argument_index="object", relation_kind="binary", @@ -710,6 +729,66 @@ def fail_per_instance_lookup(*_args, **_kwargs): ) +def test_get_predicate_incidence_type_mode_explicit_any_includes_incoming( + monkeypatch, +) -> None: + from src.backend.db.repositories.concepts_repository import ConceptsRepository + from src.backend.services import concept_relation_service as service + + ConceptsRepository.insert_one( + { + "concept_id": "#V#alice_student", + "relationships": {"is_an_instance_of": ["#V#sail_student"]}, + } + ) + ConceptsRepository.insert_one( + { + "concept_id": "#V#bob_student", + "relationships": {"is_an_instance_of": ["#V#sail_student"]}, + } + ) + + def query_index(**kwargs): + target_id = kwargs.get("target_value") + if target_id != "#V#alice_student": + return [], 0 + return ( + [ + { + "source_concept_id": "#V#supervisor", + "predicate_id": "#V#supervises", + "target_value": target_id, + "target_index": 0, + "source_updated_at": None, + } + ], + 1, + ) + + monkeypatch.setattr(service, "query_relationship_extent_index", query_index) + monkeypatch.setattr( + service, + "filter_accessible_concept_ids", + set, + ) + + payload = service.get_predicate_incidence( + instance_of="#V#sail_student", + direct_instances_only=True, + argument_index="any", + relation_kind="binary", + include_concept_preview=False, + ) + + rows = {row["predicate_concept_id"]: row for row in payload["predicates"]} + assert rows["#V#supervises"]["relation_hit_count"] == 1 + assert rows["#V#supervises"]["grounded_instance_count"] == 1 + assert ( + payload["predicate_incidence_query_diagnostics"]["retrieval_strategy"] + == "per_instance_relation_lookup" + ) + + def test_get_predicate_incidence_type_mode_batches_argument_type_counts( monkeypatch, ) -> None: diff --git a/tests/backend/test_entity_information_retrieval_workflow_vontology_service.py b/tests/backend/test_entity_information_retrieval_workflow_vontology_service.py index c18dd879d..171edc02f 100644 --- a/tests/backend/test_entity_information_retrieval_workflow_vontology_service.py +++ b/tests/backend/test_entity_information_retrieval_workflow_vontology_service.py @@ -59,6 +59,18 @@ def test_bootstrap_materialises_entity_information_retrieval_workflow( ENTITY_INFORMATION_RETRIEVAL_WORKFLOW_ID ) assert definition is not None + assert "multi-faceted profile" in str(definition.purpose) + assert "For a single bounded fact or relation extent" in str(definition.purpose) + assert "smallest evidence path" not in str(definition.purpose) + description_rows = get_texts_for_concept( + ENTITY_INFORMATION_RETRIEVAL_WORKFLOW_ID, + predicate="hasDescription", + limit=5, + ) + assert any( + "broad, multi-source entity profile" in str((row or {}).get("text") or "") + for row in description_rows + ) routing_profile, routing_source = resolve_workflow_routing_profile( ENTITY_INFORMATION_RETRIEVAL_WORKFLOW_ID @@ -74,14 +86,13 @@ def test_bootstrap_materialises_entity_information_retrieval_workflow( assert discovery_source.startswith("text_relation:") discovery_keywords = discovery_exemplars.get("keywords") or [] discovery_examples = discovery_exemplars.get("examples") or [] - assert "who am i and list my papers" in discovery_keywords - assert "people related to an entity" in discovery_keywords - assert ( - "Which people do I supervise in the represented knowledge?" - in discovery_examples - ) - assert "Can you list PhD students supervised by me?" in discovery_examples - assert "Which projects are linked to this researcher?" in discovery_examples + assert "broad multi-source entity profile" in discovery_keywords + assert "bounded entity dossier" in discovery_keywords + assert "cross-check asserted and uncertain relationships" in discovery_keywords + assert any("multilingual concept dossier" in item for item in discovery_examples) + assert any("multi-faceted profile" in item for item in discovery_examples) + assert all("PhD students" not in item for item in discovery_examples) + assert all("Which people do I supervise" not in item for item in discovery_examples) launch_contract, launch_source = resolve_workflow_launch_input_contract( ENTITY_INFORMATION_RETRIEVAL_WORKFLOW_ID diff --git a/tests/backend/test_internal_mcp_catalogue_builds.py b/tests/backend/test_internal_mcp_catalogue_builds.py index 0527f22a3..def77af3c 100644 --- a/tests/backend/test_internal_mcp_catalogue_builds.py +++ b/tests/backend/test_internal_mcp_catalogue_builds.py @@ -719,6 +719,10 @@ def test_predicate_incidence_input_schema_accepts_target_type_alias(): catalogue = build_default_catalogue() method = catalogue.get("get_predicate_incidence") + assert method.description.startswith( + "Discover predicates actually used around a known anchor or type when " + "relationship coverage is uncertain." + ) payload = { "target_type": "#V#scientific_publication", "relation_kind": "binary", diff --git a/tests/backend/test_internal_mcp_scoped_assertion_authority.py b/tests/backend/test_internal_mcp_scoped_assertion_authority.py index 44ca45746..506ff59fa 100644 --- a/tests/backend/test_internal_mcp_scoped_assertion_authority.py +++ b/tests/backend/test_internal_mcp_scoped_assertion_authority.py @@ -577,6 +577,45 @@ def truncated_lookup(**kwargs): } +def test_index_coverage_lower_bound_is_not_labelled_as_actor_overlay() -> None: + from src.backend.integrations.internal_mcp.catalogue import ( + _annotate_bounded_relation_lookup, + ) + + payload = { + "concept_id": "#V#subject", + "context_view": "base_publication", + "total_hits": 0, + "total_hits_is_lower_bound": True, + "hits": [], + "paging": { + "limit": 50, + "offset": 0, + "returned": 0, + "total_available": 0, + "total_available_is_lower_bound": True, + }, + "relation_query_diagnostics": { + "scoped_concept_query_truncated": False, + "actor_effective_text_query_truncated": False, + "incoming_asserted_binary": { + "path": "relationship_extent_index_unavailable", + "complete": False, + }, + }, + } + + result = _annotate_bounded_relation_lookup( + payload, + concept_id="#V#subject", + predicate_filter=None, + relation_kind="binary", + ) + + assert result == payload + assert "continuation" not in result + + def test_text_assertion_receipt_includes_derived_maintenance_schedule( monkeypatch: pytest.MonkeyPatch, ): diff --git a/tests/backend/test_predicate_incidence_page_first.py b/tests/backend/test_predicate_incidence_page_first.py new file mode 100644 index 000000000..df504a703 --- /dev/null +++ b/tests/backend/test_predicate_incidence_page_first.py @@ -0,0 +1,481 @@ +from __future__ import annotations + +import asyncio +import inspect +from typing import Any + +import pytest + + +def _hit( + predicate_id: str, + target_id: str, + *, + source_id: str = "#V#anchor", +) -> dict[str, Any]: + return { + "source_concept_id": source_id, + "predicate_concept_id": predicate_id, + "relation_kind": "binary", + "argument_indexes": [1], + "target_value": target_id, + "target_concept_preview": None, + "relation_metadata": { + "relation_id": f"struct::{source_id}::{predicate_id}::{target_id}" + }, + "access_granted": True, + "follow_up_actions": [], + "score": 1.0, + "is_asserted": True, + "relation_state": "asserted", + } + + +def _diagnostics() -> dict[str, Any]: + return { + "mode": "asserted_only", + "include_uncertain": False, + "statuses": None, + } + + +def test_selected_incidence_page_is_hydrated_only_after_skeleton_sort( + monkeypatch, +) -> None: + from src.backend.services import concept_relation_service as service + + hits = [ + _hit("#V#high", "#V#high_target_one"), + _hit("#V#high", "#V#high_target_two"), + _hit("#V#high", "#V#high_target_three"), + _hit("#V#high", "#V#high_target_four"), + _hit("#V#high", "#V#high_target_five"), + _hit("#V#low", "#V#low_target"), + ] + monkeypatch.setattr( + service, + "_collect_all_argument_hits_for_incidence", + lambda **_kwargs: (list(hits), _diagnostics()), + ) + + aggregate_calls: list[dict[str, Any]] = [] + original_aggregate = service._aggregate_predicate_incidence_rows + + def recording_aggregate(**kwargs): + aggregate_calls.append( + { + "predicates": { + hit.get("predicate_concept_id") for hit in kwargs["hits"] + }, + "include_concept_preview": kwargs["include_concept_preview"], + "include_type_counts": kwargs["type_count_options"].include, + } + ) + return original_aggregate(**kwargs) + + monkeypatch.setattr( + service, + "_aggregate_predicate_incidence_rows", + recording_aggregate, + ) + hydrated_predicates: list[set[str]] = [] + + def attach_types(grouped_hits): + selected = { + hit["predicate_concept_id"] + for grouped in grouped_hits.values() + for hit in grouped + } + hydrated_predicates.append(selected) + for grouped in grouped_hits.values(): + for hit in grouped: + hit["target_type_ids"] = [f"{hit['target_value']}_type"] + + monkeypatch.setattr( + service, + "_attach_direct_type_ids_to_fast_incidence_hits", + attach_types, + ) + + def prime_previews(concept_ids, *, include_preview, preview_cache): + assert include_preview is True + pending = list( + dict.fromkeys( + concept_id + for concept_id in concept_ids + if concept_id not in preview_cache + ) + ) + if len(pending) < 2: + return + for concept_id in pending: + preview_cache[concept_id] = { + "concept_id": concept_id, + "name": concept_id, + "kind": "individual", + } + + monkeypatch.setattr(service, "_prime_concept_preview_cache", prime_previews) + monkeypatch.setattr( + service, + "_load_accessible_preview_document", + lambda concept_id: { + "concept_id": concept_id, + "name": concept_id, + "kind": "individual", + }, + ) + + payload = service.get_predicate_incidence( + concept_id="#V#anchor", + relation_kind="binary", + include_concept_preview=True, + include_argument_type_counts=True, + limit=1, + ) + + assert [row["predicate_concept_id"] for row in payload["predicates"]] == ["#V#high"] + assert aggregate_calls == [ + { + "predicates": {"#V#high", "#V#low"}, + "include_concept_preview": False, + "include_type_counts": False, + } + ] + assert hydrated_predicates == [{"#V#high"}] + assert payload["predicates"][0]["predicate_preview"]["concept_id"] == "#V#high" + type_counts = { + row["type_concept_id"]: row + for row in payload["predicates"][0]["argument_type_counts"] + } + fifth_sample = type_counts["#V#high_target_five_type"]["sample_concepts"][0] + assert fifth_sample["name"] == "#V#high_target_five" + + +def test_selected_page_hydration_uses_normalised_predicate_ids(monkeypatch) -> None: + from src.backend.services import concept_relation_service as service + + monkeypatch.setattr( + service, + "_collect_all_argument_hits_for_incidence", + lambda **_kwargs: ( + [_hit(" #V#related_to ", "#V#target")], + _diagnostics(), + ), + ) + + def prime_previews(concept_ids, *, include_preview, preview_cache): + assert include_preview is True + for concept_id in concept_ids: + preview_cache[concept_id] = { + "concept_id": concept_id, + "name": concept_id, + "kind": "predicate", + } + + monkeypatch.setattr(service, "_prime_concept_preview_cache", prime_previews) + + payload = service.get_predicate_incidence( + concept_id="#V#anchor", + relation_kind="binary", + include_concept_preview=True, + limit=1, + ) + + row = payload["predicates"][0] + assert row["predicate_concept_id"] == "#V#related_to" + assert row["predicate_preview"]["concept_id"] == "#V#related_to" + + +@pytest.mark.parametrize( + "sort_by", + [ + "relation_hit_count", + "grounding_count", + "grounded_instance_count", + "predicate", + ], +) +def test_incidence_sort_and_offset_use_complete_skeleton( + monkeypatch, + sort_by: str, +) -> None: + from src.backend.services import concept_relation_service as service + + hits = [ + *[_hit("#V#alpha", f"#V#alpha_{index}") for index in range(5)], + *[_hit("#V#beta", f"#V#beta_{index}") for index in range(3)], + _hit("#V#gamma", "#V#gamma_0"), + ] + monkeypatch.setattr( + service, + "_collect_all_argument_hits_for_incidence", + lambda **_kwargs: (list(hits), _diagnostics()), + ) + + payload = service.get_predicate_incidence( + concept_id="#V#anchor", + relation_kind="binary", + sort_by=sort_by, + offset=1, + limit=1, + ) + + assert payload["total_predicates"] == 3 + assert payload["paging"] == { + "limit": 1, + "offset": 1, + "returned": 1, + "total_available": 3, + } + assert payload["predicates"][0]["predicate_concept_id"] == "#V#beta" + assert payload["predicates"][0]["relation_hit_count"] == 3 + + +def test_type_incidence_preserves_counts_while_hydrating_selected_page( + monkeypatch, +) -> None: + from src.backend.services import concept_relation_service as service + + grouped_hits = { + "#V#instance_one": [ + _hit("#V#high", "#V#shared", source_id="#V#instance_one"), + _hit("#V#low", "#V#other", source_id="#V#instance_one"), + ], + "#V#instance_two": [_hit("#V#high", "#V#shared", source_id="#V#instance_two")], + } + monkeypatch.setattr( + service, + "_resolve_type_incidence_type_ids", + lambda *_args, **_kwargs: ["#V#test_type"], + ) + monkeypatch.setattr( + service, + "_resolve_type_incidence_instance_ids", + lambda _type_ids: list(grouped_hits), + ) + monkeypatch.setattr( + service, + "_collect_type_subject_hits_for_incidence_fast", + lambda **_kwargs: {key: list(value) for key, value in grouped_hits.items()}, + ) + selected_for_types: list[set[str]] = [] + + def attach_types(selected): + selected_for_types.append( + {hit["predicate_concept_id"] for hits in selected.values() for hit in hits} + ) + for hits in selected.values(): + for hit in hits: + hit["target_type_ids"] = ["#V#shared_type"] + + monkeypatch.setattr( + service, + "_attach_direct_type_ids_to_fast_incidence_hits", + attach_types, + ) + monkeypatch.setattr( + service, + "_prime_concept_preview_cache", + lambda concept_ids, *, include_preview, preview_cache: preview_cache.update( + {concept_id: {"concept_id": concept_id} for concept_id in concept_ids} + ), + ) + + payload = service.get_predicate_incidence( + instance_of="#V#test_type", + direct_instances_only=True, + relation_kind="binary", + include_argument_type_counts=True, + limit=1, + ) + + row = payload["predicates"][0] + assert row["predicate_concept_id"] == "#V#high" + assert row["relation_hit_count"] == 2 + assert row["grounding_count"] == 1 + assert row["grounded_instance_count"] == 2 + assert selected_for_types == [{"#V#high"}] + + +def test_role_expansion_reads_only_selected_predicate_frames(monkeypatch) -> None: + from src.backend.services import concept_relation_service as service + + hits = [ + _hit("#V#high", "#V#high_frame_one"), + _hit("#V#high", "#V#high_frame_two"), + _hit("#V#low", "#V#low_frame"), + ] + monkeypatch.setattr( + service, + "_collect_all_argument_hits_for_incidence", + lambda **_kwargs: (list(hits), _diagnostics()), + ) + selected_for_types: list[set[str]] = [] + + def attach_types(selected): + selected_for_types.append( + { + hit["predicate_concept_id"] + for grouped in selected.values() + for hit in grouped + } + ) + for grouped in selected.values(): + for hit in grouped: + hit["target_type_ids"] = ["#V#frame_type"] + + monkeypatch.setattr( + service, + "_attach_direct_type_ids_to_fast_incidence_hits", + attach_types, + ) + monkeypatch.setattr( + service, + "_prime_concept_preview_cache", + lambda *_args, **_kwargs: None, + ) + loaded_frames: list[str] = [] + + def load_frame(concept_id, **_kwargs): + loaded_frames.append(concept_id) + return { + "concept_id": concept_id, + "relationships": {"#V#has_literal_role": ["represented value"]}, + } + + monkeypatch.setattr( + service, + "_load_accessible_relation_subject_document", + load_frame, + ) + + payload = service.get_predicate_incidence( + concept_id="#V#anchor", + relation_kind="binary", + role_expansion_mode="explicit", + role_node_type_filter=["#V#frame_type"], + limit=1, + ) + + assert payload["predicates"][0]["predicate_concept_id"] == "#V#high" + assert selected_for_types == [{"#V#high"}] + assert set(loaded_frames) == {"#V#high_frame_one", "#V#high_frame_two"} + assert "#V#low_frame" not in loaded_frames + + +def test_incoming_incidence_excludes_inaccessible_sources(monkeypatch) -> None: + from src.backend.services import concept_relation_service as service + + monkeypatch.setattr( + service, + "_load_accessible_relation_subject_document", + lambda *_args, **_kwargs: None, + ) + monkeypatch.setattr(service, "should_enforce_access_control", lambda: True) + monkeypatch.setattr( + service, + "filter_accessible_concept_ids", + lambda concept_ids: { + concept_id + for concept_id in concept_ids + if concept_id == "#V#visible_source" + }, + ) + monkeypatch.setattr( + service, + "query_relationship_extent_index", + lambda **_kwargs: ( + [ + { + "source_concept_id": "#V#visible_source", + "predicate_id": "#V#related_to", + "target_value": "#V#anchor", + "target_index": 0, + "source_updated_at": None, + }, + { + "source_concept_id": "#V#hidden_source", + "predicate_id": "#V#related_to", + "target_value": "#V#anchor", + "target_index": 0, + "source_updated_at": None, + }, + ], + 2, + ), + ) + + payload = service.get_predicate_incidence( + concept_id="#V#anchor", + argument_index="object", + predicate_filter=["#V#related_to"], + relation_kind="binary", + ) + + assert payload["total_predicates"] == 1 + assert payload["predicates"][0]["relation_hit_count"] == 1 + assert payload["predicates"][0]["grounding_count"] == 1 + assert payload["predicates"][0]["sample_groundings"] == [ + {"grounding_kind": "concept", "concept_id": "#V#visible_source"} + ] + + +def test_incidence_preview_default_matches_service_internal_mcp_stdio_and_metadata( + monkeypatch, +) -> None: + from src.backend.integrations.internal_mcp import catalogue + from src.backend.mcp_server import mcp_stdio_server + from src.backend.services import concept_relation_service as service + from src.backend.services import tool_metadata_service + + assert ( + inspect.signature(service.get_predicate_incidence) + .parameters["include_concept_preview"] + .default + is False + ) + + observed: list[bool] = [] + + def fake_get_predicate_incidence(**kwargs): + observed.append(kwargs["include_concept_preview"]) + return { + "mode": "entity", + "total_predicates": 0, + "predicates": [], + "paging": {}, + } + + monkeypatch.setattr( + service, "get_predicate_incidence", fake_get_predicate_incidence + ) + catalogue._get_predicate_incidence(concept_id="#V#anchor") + catalogue._get_predicate_incidence( + concept_id="#V#anchor", + include_concept_preview=True, + ) + monkeypatch.setattr( + mcp_stdio_server, + "get_predicate_incidence", + fake_get_predicate_incidence, + ) + asyncio.run( + mcp_stdio_server._handle_get_predicate_incidence({"concept_id": "#V#anchor"}) + ) + asyncio.run( + mcp_stdio_server._handle_get_predicate_incidence( + {"concept_id": "#V#anchor", "include_concept_preview": True} + ) + ) + + monkeypatch.setattr(tool_metadata_service, "_load_from_vontology", dict) + tool_metadata_service.invalidate_cache() + try: + metadata = tool_metadata_service.get_tool_metadata("get_predicate_incidence") + assert metadata.default_payload is not None + assert metadata.default_payload["include_concept_preview"] is False + finally: + tool_metadata_service.invalidate_cache() + + assert observed == [False, True, False, True] diff --git a/tests/backend/test_registered_tool_capability_retrieval_service.py b/tests/backend/test_registered_tool_capability_retrieval_service.py deleted file mode 100644 index 72875f51c..000000000 --- a/tests/backend/test_registered_tool_capability_retrieval_service.py +++ /dev/null @@ -1,209 +0,0 @@ -from __future__ import annotations - -import re -from pathlib import Path -from typing import Any - -import pytest - -from src.backend.services import registered_tool_capability_retrieval_service as service - - -class _FakeCapabilityRAG: - def __init__(self, root: Path) -> None: - self.root = root - self.documents: dict[str, dict[str, Any]] = {} - self.reset_count = 0 - self.query_calls: list[dict[str, Any]] = [] - - def _namespace_persist_dir(self, namespace: str) -> str: - return str(self.root / namespace) - - def get_namespace_runtime_state(self, namespace: str) -> dict[str, Any]: - return { - "namespace": namespace, - "compatible": bool(self.documents), - "status": "compatible" if self.documents else "missing_index", - } - - def reset_namespace(self, namespace: str) -> None: - self.reset_count += 1 - self.documents = {} - - def upsert_documents( - self, - docs, - *, - namespace: str, - allow_partial_failures: bool, - ) -> tuple[int, int]: - rows = list(docs) - self.documents = {str(row["id"]): dict(row) for row in rows} - return len(rows), 0 - - def query( - self, - query_text: str, - *, - top_k: int, - namespace: str, - hybrid: bool, - permissions_context: dict[str, Any], - ) -> list[dict[str, Any]]: - self.query_calls.append( - { - "query_text": query_text, - "top_k": top_k, - "namespace": namespace, - "hybrid": hybrid, - "permissions_context": dict(permissions_context), - } - ) - query_tokens = set(re.findall(r"[a-z0-9]+", query_text.lower())) - results: list[dict[str, Any]] = [] - for row in self.documents.values(): - text_tokens = set( - re.findall(r"[a-z0-9]+", str(row.get("text") or "").lower()) - ) - overlap = len(query_tokens & text_tokens) - if overlap <= 0: - continue - results.append( - { - "id": row["id"], - "metadata": dict(row.get("metadata") or {}), - "score": overlap / max(1, len(query_tokens)), - } - ) - results.sort( - key=lambda row: ( - -float(row["score"]), - str(row["metadata"].get("capability_name") or ""), - ) - ) - return results[:top_k] - - -@pytest.fixture(autouse=True) -def _reset_process_state() -> None: - service.reset_registered_tool_capability_index_state() - yield - service.reset_registered_tool_capability_index_state() - - -def _candidates() -> list[dict[str, Any]]: - return [ - { - "name": "search_concepts", - "description": "Discover represented predicates and types.", - "planner_hint": "Use for schema discovery before a relation lookup.", - "evidence_surface_family": "knowledge_base", - }, - { - "name": "find_relations_with_argument", - "description": "Find relationships for an anchor entity.", - "planner_hint": "Use for entity-relative relation-bearing evidence.", - "evidence_surface_family": "knowledge_base", - }, - { - "name": "unrelated_admin_read", - "description": "Inspect server configuration.", - "evidence_surface_family": "operations", - }, - ] - - -def test_retrieval_builds_once_and_returns_only_supplied_candidate_names( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - rag = _FakeCapabilityRAG(tmp_path) - monkeypatch.setattr(service, "_get_capability_rag_service", lambda: rag) - - scores, diagnostics = service.retrieve_registered_tool_capability_scores( - "discover schema predicates for a relationship", - _candidates()[:2], - ) - second_scores, second_diagnostics = ( - service.retrieve_registered_tool_capability_scores( - "discover schema predicates for a relationship", - _candidates()[:2], - ) - ) - - assert rag.reset_count == 1 - assert set(scores).issubset({"search_concepts", "find_relations_with_argument"}) - assert scores["search_concepts"] > scores.get( - "find_relations_with_argument", - 0.0, - ) - assert second_scores == scores - assert diagnostics["status"] == "results_available" - assert second_diagnostics["index"]["ready"] is True - assert rag.query_calls[0]["permissions_context"]["type"] == ( - "registered_tool_capability" - ) - - -def test_metadata_digest_change_rebuilds_the_index( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - rag = _FakeCapabilityRAG(tmp_path) - monkeypatch.setattr(service, "_get_capability_rag_service", lambda: rag) - - first = _candidates() - service.ensure_registered_tool_capability_index(first) - changed = [dict(candidate) for candidate in first] - changed[0]["planner_hint"] = "Changed represented schema guidance." - service.ensure_registered_tool_capability_index(changed) - - assert rag.reset_count == 2 - state = service.get_registered_tool_capability_index_state() - assert state["ready"] is True - assert state["source"] == "rebuilt" - - -def test_retrieval_unavailability_is_typed_and_non_authoritative( - monkeypatch: pytest.MonkeyPatch, -) -> None: - def fail_backend() -> Any: - raise RuntimeError("embedding backend unavailable") - - monkeypatch.setattr(service, "_get_capability_rag_service", fail_backend) - - scores, diagnostics = service.retrieve_registered_tool_capability_scores( - "represented relationship lookup", - _candidates(), - ) - - assert scores == {} - assert diagnostics["status"] == "unavailable" - assert diagnostics["retrieval_state"]["authoritative_empty"] is False - assert diagnostics["retrieval_state"]["cause"] == ( - "registered_tool_capability_index_unavailable" - ) - - -def test_matching_background_build_returns_without_starting_a_second_build( - monkeypatch: pytest.MonkeyPatch, -) -> None: - documents = service._normalised_documents(_candidates()) - digest = service._document_digest(documents) - service._set_runtime_state( - status="building", - ready=False, - digest=digest, - document_count=len(documents), - ) - monkeypatch.setattr( - service, - "_get_capability_rag_service", - lambda: pytest.fail("request path must not wait for or duplicate prewarm"), - ) - - state = service.ensure_registered_tool_capability_index(_candidates()) - - assert state["status"] == "building" - assert state["ready"] is False - assert state["digest"] == digest diff --git a/tests/backend/test_relationship_extent_index_service.py b/tests/backend/test_relationship_extent_index_service.py index d7523ce21..c4a1d6f01 100644 --- a/tests/backend/test_relationship_extent_index_service.py +++ b/tests/backend/test_relationship_extent_index_service.py @@ -185,6 +185,22 @@ def test_relationship_extent_readiness_requires_explicit_complete_state( assert service.relationship_extent_index_ready() is True +def test_relationship_extent_query_batches_exact_predicates() -> None: + from src.backend.services import relationship_extent_index_service as service + + query, should_query = service._build_relationship_extent_index_query( + predicate_ids=["#V#supervises", "#V#co_supervises", "#V#supervises"], + target_value="#V#person", + ) + + assert should_query is True + assert query == { + "schema_version": service.RELATIONSHIP_EXTENT_INDEX_SCHEMA_VERSION, + "predicate_id": {"$in": ["#V#supervises", "#V#co_supervises"]}, + "target_value": "#V#person", + } + + def test_relationship_extent_rebuild_enforces_batch_size_for_dense_source( monkeypatch, ): diff --git a/tests/backend/test_tool_metadata_service.py b/tests/backend/test_tool_metadata_service.py index dc3d84204..481e8d96a 100644 --- a/tests/backend/test_tool_metadata_service.py +++ b/tests/backend/test_tool_metadata_service.py @@ -230,15 +230,20 @@ def test_vontology_concept_search_alias_inherits_schema_discovery_metadata( assert alias.planner_hint == canonical.planner_hint assert alias.operation_category == "read" assert alias.evidence_role == "search" - assert "schema discovery" in (alias.planner_hint or "").lower() - assert "relation-bearing lookup" in (alias.planner_hint or "") + assert "possible schema" in (alias.description or "").lower() + assert "then make a relation-bearing read" in (alias.planner_hint or "") + assert "use predicate incidence instead" in (alias.planner_hint or "") + assert "possible, not what is actually used" in (alias.planner_hint or "") + assert "requested work product is a list" not in ( + alias.planner_hint or "" + ) assert "PhD" not in (alias.description or "") assert "student" not in (alias.planner_hint or "").lower() finally: service.invalidate_cache() -def test_relation_read_hints_preserve_direction_neutral_completeness( +def test_relation_read_hints_start_bounded_without_losing_negative_completeness( monkeypatch, ): from src.backend.services import tool_metadata_service as service @@ -248,17 +253,52 @@ def test_relation_read_hints_preserve_direction_neutral_completeness( try: incidence = service.get_tool_metadata("get_predicate_incidence") relation_read = service.get_tool_metadata("find_relations_with_argument") - + concept_fetch = service.get_tool_metadata("fetch_concept") + + assert incidence.default_payload == { + "argument_index": "subject", + "relation_kind": "binary", + "include_argument_type_counts": False, + "include_concept_preview": False, + "limit": 12, + } + assert "limit=20" in (incidence.planner_hint or "") + assert "include_concept_preview=false" in (incidence.planner_hint or "") + assert "include_text_snippets=false" in (incidence.planner_hint or "") + assert "include_argument_type_counts=false" in ( + incidence.planner_hint or "" + ) assert "argument_index='any'" in (incidence.planner_hint or "") - assert "exact predicate IDs" in (incidence.planner_hint or "") - assert "one-direction" in (incidence.planner_hint or "") - assert "argument_index='any'" in (relation_read.planner_hint or "") - assert "complete negative" in (relation_read.planner_hint or "") - assert "relation_kind='binary'" in (relation_read.planner_hint or "") - assert "vontology_concept_search" in (relation_read.planner_hint or "") - assert "broad unfiltered relation page" in ( + assert "every semantically fitting row" in (incidence.planner_hint or "") + assert "including inverse forms" in (incidence.planner_hint or "") + assert "inspect role fillers" in (incidence.planner_hint or "") + assert "coverage candidates, not the answer entities" in ( + incidence.planner_hint or "" + ) + assert "co-participation alone does not" in (incidence.planner_hint or "") + assert "selects only one stored slot" in (incidence.planner_hint or "") + assert relation_read.default_payload == { + "include_concept_preview": False, + "limit": 20, + } + assert "predicate-filtered around limit 20" in ( + relation_read.planner_hint or "" + ) + assert "A hit proves existence, not list or count completeness" in ( + relation_read.planner_hint or "" + ) + assert "small any-direction incidence" in (relation_read.planner_hint or "") + assert "every fitting exact predicate" in (relation_read.planner_hint or "") + assert "co-participation alone does not" in ( relation_read.planner_hint or "" ) + assert "not the whole object side" in (relation_read.planner_hint or "") + assert "small predicate-filtered relation read" in ( + concept_fetch.planner_hint or "" + ) + assert "hydrate only selected related concepts" in ( + concept_fetch.planner_hint or "" + ) assert "PhD" not in (incidence.planner_hint or "") assert "student" not in (relation_read.planner_hint or "").lower() finally: