Coverage for integrations / ui_actions / navigate_tool.py: 100.0%
29 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""navigate_tool — LangChain facade for the page registry.
3Exposed to the chat agent as `Navigate_App`. When the user says
4'take me to social' / 'open model management' / 'connect whatsapp
5on the admin page', this tool resolves the best-matching page from
6PAGE_REGISTRY and returns a structured sentinel the Flask /chat
7handler intercepts and surfaces to the frontend LiquidActionBar.
9The tool ALSO writes the resolved ui_action into thread_local_data
10so chatbot_routes.py can attach it to the final response without
11having to parse the tool string. The string return value is just
12what the LLM sees (human-readable so it weaves a natural reply
13like 'opening Model Management for you').
15Single source of truth: this tool, page_registry.PAGE_REGISTRY, and
16the frontend `pageRegistry.js` all share the same `id` keys. Adding
17a new page means editing one entry in page_registry.py and mirroring
18the id + route in the frontend file.
19"""
20from __future__ import annotations
22import json
23import logging
24from typing import Optional
26from .page_registry import (
27 PAGE_REGISTRY,
28 page_to_ui_action,
29 resolve_page,
30 list_pages,
31)
33logger = logging.getLogger(__name__)
36def handle_navigate_app(
37 input_text: str,
38 user_role: str = 'flat',
39 thread_local=None,
40) -> str:
41 """Resolve a natural-language destination and attach a ui_action.
43 Args:
44 input_text: the user's destination phrase (e.g. "model management")
45 user_role: viewer role for filtering admin-only pages
46 thread_local: optional thread-local object with set_ui_actions().
47 When present the resolved ui_action list is stored on it so
48 the /chat handler can lift it into the response without
49 parsing the tool's string return value.
51 Returns:
52 A human-readable string the LLM can weave into its final reply.
53 On no-match, returns a hint listing a few valid destinations so
54 the LLM asks for clarification.
55 """
56 query = (input_text or '').strip()
57 if not query:
58 visible = list_pages(user_role=user_role)
59 names = ', '.join(p.label for p in visible[:6])
60 return (
61 f"Navigate_App needs a destination. Available pages include: {names}. "
62 f"Try calling me again with the page name."
63 )
65 matches = resolve_page(query, user_role=user_role, top_k=3)
66 if not matches:
67 visible = list_pages(user_role=user_role)
68 names = ', '.join(p.label for p in visible[:6])
69 return (
70 f"I couldn't match '{query}' to any app page. "
71 f"Known destinations include: {names}."
72 )
74 actions = [page_to_ui_action(page) for page, _score in matches]
76 # Best match drives the reply text; secondary matches still surface
77 # as chips so the user can pick another option if we guessed wrong.
78 best_page, best_score = matches[0]
79 logger.info(
80 f"Navigate_App resolved '{query}' → {best_page.id} "
81 f"(score={best_score}, +{len(matches)-1} runner-ups)"
82 )
84 if thread_local is not None:
85 try:
86 thread_local.set_ui_actions(actions)
87 except Exception as e:
88 logger.debug(f"thread_local.set_ui_actions failed: {e}")
90 runner_up = ''
91 if len(matches) > 1:
92 runner_up = ' Other options: ' + ', '.join(
93 f"{p.label}" for p, _ in matches[1:]
94 ) + '.'
95 return (
96 f"Opening {best_page.label} ({best_page.description}).{runner_up}"
97 )
100def navigate_tool_json_payload(user_role: str = 'flat') -> str:
101 """JSON blob the Flask /chat handler can return so the frontend
102 renders the full action bar on first load without waiting for the
103 user to invoke Navigate_App. Used by the initial LiquidActionBar
104 hydration call (GET /ui-actions/pages)."""
105 return json.dumps({
106 'pages': [page_to_ui_action(p) for p in list_pages(user_role=user_role)],
107 })