1170 lines
50 KiB
Python
1170 lines
50 KiB
Python
import argparse
|
||
import json
|
||
import re
|
||
from collections import deque
|
||
from dataclasses import dataclass
|
||
from difflib import SequenceMatcher
|
||
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
|
||
|
||
|
||
def _normalize(text: str) -> str:
|
||
text = text.lower().strip()
|
||
text = re.sub(r"[^\w\u4e00-\u9fff]+", " ", text)
|
||
return re.sub(r"\s+", " ", text).strip()
|
||
|
||
|
||
def _tokenize(text: str) -> List[str]:
|
||
return [t for t in _normalize(text).split(" ") if t]
|
||
|
||
|
||
def _score(query: str, candidate: str) -> float:
|
||
if not query or not candidate:
|
||
return 0.0
|
||
q = _normalize(query)
|
||
c = _normalize(candidate)
|
||
if not q or not c:
|
||
return 0.0
|
||
if q == c:
|
||
return 1.0
|
||
if q in c:
|
||
return 0.9
|
||
q_tokens = _tokenize(q)
|
||
c_tokens = set(_tokenize(c))
|
||
if not q_tokens:
|
||
return 0.0
|
||
overlap = len([t for t in q_tokens if t in c_tokens]) / len(q_tokens)
|
||
ratio = SequenceMatcher(None, q, c).ratio()
|
||
return max(overlap * 0.75 + ratio * 0.25, ratio * 0.6)
|
||
|
||
|
||
def _gesture_to_verb(gesture: Optional[str]) -> str:
|
||
if not gesture:
|
||
return "点击"
|
||
mapping = {
|
||
"tap": "点击",
|
||
"click": "点击",
|
||
"doubleTap": "双击",
|
||
"doubleClick": "双击",
|
||
"longPress": "长按",
|
||
"press": "按下",
|
||
"swipe": "滑动",
|
||
"drag": "拖拽",
|
||
}
|
||
if gesture in mapping:
|
||
return mapping[gesture]
|
||
g = str(gesture)
|
||
if g in mapping:
|
||
return mapping[g]
|
||
return g
|
||
|
||
|
||
def _short_label(label: str) -> str:
|
||
label = (label or "").strip()
|
||
if not label:
|
||
return ""
|
||
# Node labels are usually: "<description> <component> <route> ...".
|
||
# Using the first token keeps output readable.
|
||
return label.split(" ")[0]
|
||
|
||
|
||
def _strip_id_prefix(label: str, entity_id: str) -> str:
|
||
label = (label or "").strip()
|
||
entity_id = (entity_id or "").strip()
|
||
if not label or not entity_id:
|
||
return label
|
||
if label == entity_id:
|
||
return ""
|
||
prefix = entity_id + " "
|
||
if label.startswith(prefix):
|
||
return label[len(prefix) :].strip()
|
||
return label
|
||
|
||
|
||
def _fmt_kv_pairs(obj: Any) -> str:
|
||
if not isinstance(obj, dict) or not obj:
|
||
return ""
|
||
parts: List[str] = []
|
||
for k, v in obj.items():
|
||
if isinstance(v, (dict, list)):
|
||
v_str = json.dumps(v, ensure_ascii=False)
|
||
else:
|
||
v_str = str(v)
|
||
parts.append(f"{k}={v_str}")
|
||
return ", ".join(parts)
|
||
|
||
|
||
def _edge_params_hint(edge: Dict[str, Any]) -> str:
|
||
# Prefer showing concrete search values (e.g. tab=total), then route params schema (e.g. bookId=string).
|
||
hints: List[str] = []
|
||
search = edge.get("search")
|
||
if isinstance(search, dict) and search:
|
||
kv = _fmt_kv_pairs(search)
|
||
if kv:
|
||
hints.append(kv)
|
||
params = edge.get("params")
|
||
if isinstance(params, dict) and params:
|
||
kv = _fmt_kv_pairs(params)
|
||
if kv:
|
||
hints.append(f"参数:{kv}")
|
||
if not hints:
|
||
return ""
|
||
return f"({';'.join(hints)})"
|
||
|
||
|
||
def _fmt_node_step(step: Dict[str, Any]) -> str:
|
||
node_id = str(step.get("id") or "")
|
||
label = str(step.get("label") or "")
|
||
name = _short_label(label) or node_id
|
||
return f"{name}({node_id})" if node_id else name
|
||
|
||
|
||
def _fmt_action_step(step: Dict[str, Any]) -> str:
|
||
action_id = str(step.get("id") or "")
|
||
label = str(step.get("label") or action_id)
|
||
human = _strip_id_prefix(label, action_id) or label or action_id
|
||
return f"{human}({action_id})" if action_id else human
|
||
|
||
|
||
def _fmt_state_step(step: Dict[str, Any]) -> str:
|
||
state_id = str(step.get("id") or "")
|
||
label = str(step.get("label") or state_id)
|
||
human = _strip_id_prefix(label, state_id) or label or state_id
|
||
return f"{human}({state_id})" if state_id else human
|
||
|
||
|
||
def _fmt_transition_step(step: Dict[str, Any]) -> str:
|
||
trans_id = str(step.get("id") or "")
|
||
label = str(step.get("label") or trans_id)
|
||
human = _strip_id_prefix(label, trans_id) or label or trans_id
|
||
return f"{human}({trans_id})" if trans_id else human
|
||
|
||
|
||
def _steps_to_narrative(steps: List[Dict[str, Any]]) -> str:
|
||
if not steps:
|
||
return ""
|
||
|
||
nodes = [s for s in steps if s.get("type") == "node"]
|
||
start_node = nodes[0] if nodes else None
|
||
end_node = nodes[-1] if nodes else None
|
||
end_node_id = str(end_node.get("id") or "") if end_node else ""
|
||
last_node_index = -1
|
||
for i in range(len(steps) - 1, -1, -1):
|
||
if steps[i].get("type") == "node":
|
||
last_node_index = i
|
||
break
|
||
|
||
segments: List[str] = []
|
||
if start_node:
|
||
segments.append(f"在初始状态 {_fmt_node_step(start_node)}")
|
||
else:
|
||
segments.append("在初始状态")
|
||
|
||
# Walk through steps and generate a readable narrative.
|
||
for idx, step in enumerate(steps):
|
||
stype = step.get("type")
|
||
|
||
if stype == "transition":
|
||
edge = step.get("edge") if isinstance(step.get("edge"), dict) else {}
|
||
edge_type = edge.get("type") or step.get("edge", {}).get("type")
|
||
ui_meta = edge.get("uiMeta") if isinstance(edge.get("uiMeta"), dict) else {}
|
||
verb = _gesture_to_verb(ui_meta.get("gesture"))
|
||
|
||
# Find the next node to describe arrival.
|
||
next_node = None
|
||
next_node_index = -1
|
||
for j in range(idx + 1, len(steps)):
|
||
if steps[j].get("type") == "node":
|
||
next_node = steps[j]
|
||
next_node_index = j
|
||
break
|
||
is_to_final_node = (
|
||
bool(end_node_id)
|
||
and next_node is not None
|
||
and str(next_node.get("id") or "") == end_node_id
|
||
and next_node_index == last_node_index
|
||
)
|
||
|
||
note_parts: List[str] = []
|
||
if edge.get("availability") is not None:
|
||
note_parts.append("受可用性限制")
|
||
if bool(edge.get("needsPrompt")) or edge.get("uiCondition") is not None or edge.get("conditionStatus") is not None:
|
||
note_parts.append("可能需要确认条件")
|
||
note = f"({','.join(note_parts)})" if note_parts else ""
|
||
|
||
if edge_type == "back" or step.get("id") == "back":
|
||
if next_node:
|
||
# Avoid redundant "回到/到达 <final>" — the final node will be stated once at the end.
|
||
if is_to_final_node:
|
||
segments.append(f"{verb}返回{note}")
|
||
else:
|
||
segments.append(f"{verb}返回,回到 {_fmt_node_step(next_node)}{note}")
|
||
else:
|
||
segments.append(f"{verb}返回{note}")
|
||
continue
|
||
|
||
trans_text = _fmt_transition_step(step)
|
||
trans_text = f"{trans_text}{_edge_params_hint(edge)}"
|
||
# Avoid duplicated gesture words, e.g. "长按长按打开..." when label already contains "长按".
|
||
verb_prefix = "" if trans_text.startswith(verb) else verb
|
||
if next_node:
|
||
if is_to_final_node:
|
||
segments.append(f"{verb_prefix}{trans_text}{note}")
|
||
else:
|
||
segments.append(
|
||
f"{verb_prefix}{trans_text},到达 {_fmt_node_step(next_node)}{note}"
|
||
)
|
||
else:
|
||
segments.append(f"{verb_prefix}{trans_text}{note}")
|
||
continue
|
||
|
||
if stype == "action":
|
||
# Prefer attaching the action to the nearest node (usually the previous node).
|
||
loc_node = None
|
||
for j in range(idx - 1, -1, -1):
|
||
if steps[j].get("type") == "node":
|
||
loc_node = steps[j]
|
||
break
|
||
action_text = _fmt_action_step(step)
|
||
if loc_node:
|
||
segments.append(f"在 {_fmt_node_step(loc_node)} 执行 {action_text}")
|
||
else:
|
||
segments.append(f"执行 {action_text}")
|
||
continue
|
||
|
||
if stype == "state":
|
||
loc_node = None
|
||
for j in range(idx - 1, -1, -1):
|
||
if steps[j].get("type") == "node":
|
||
loc_node = steps[j]
|
||
break
|
||
state_text = _fmt_state_step(step)
|
||
if loc_node:
|
||
segments.append(f"在 {_fmt_node_step(loc_node)} 切换到状态 {state_text}")
|
||
else:
|
||
segments.append(f"切换到状态 {state_text}")
|
||
continue
|
||
|
||
if end_node:
|
||
segments.append(f"最终到达 {_fmt_node_step(end_node)}")
|
||
else:
|
||
last = steps[-1]
|
||
if last.get("type") == "action":
|
||
segments.append(f"最终执行 {_fmt_action_step(last)}")
|
||
elif last.get("type") == "state":
|
||
segments.append(f"最终处于状态 {_fmt_state_step(last)}")
|
||
elif last.get("type") == "transition":
|
||
segments.append(f"最终触发 {_fmt_transition_step(last)}")
|
||
|
||
return ",".join([s for s in segments if s]).strip(",") + "。"
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Entity:
|
||
id: str
|
||
type: str
|
||
label: str
|
||
parent_node: Optional[str] = None
|
||
edge: Optional[Dict[str, Any]] = None
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Match:
|
||
entity: Entity
|
||
score: float
|
||
|
||
|
||
class NavGraphPathFinder:
|
||
def __init__(
|
||
self,
|
||
graph: Dict[str, Any],
|
||
include_back_edges: bool = True,
|
||
assume_push_when_mode_missing: bool = False,
|
||
history_depth: int = 0,
|
||
max_history_seeds: int = 50,
|
||
) -> None:
|
||
self.graph = graph
|
||
self.nodes = {node["id"]: node for node in graph.get("nodes", [])}
|
||
self.edges = graph.get("edges", [])
|
||
self.adj: Dict[str, List[Tuple[str, Dict[str, Any]]]] = {}
|
||
self.push_incoming: Dict[str, List[Tuple[str, Dict[str, Any]]]] = {}
|
||
self.entities: List[Entity] = []
|
||
self.include_back_edges = include_back_edges
|
||
self.assume_push_when_mode_missing = assume_push_when_mode_missing
|
||
self.history_depth = history_depth
|
||
self.max_history_seeds = max_history_seeds
|
||
self._build_graph()
|
||
self._build_entities()
|
||
|
||
def _edge_endpoints(self, edge: Dict[str, Any]) -> Tuple[Optional[str], Optional[str]]:
|
||
source = edge.get("sourceNodeId") or edge.get("source")
|
||
target = edge.get("targetNodeId") or edge.get("target")
|
||
return source, target
|
||
|
||
def _is_push_edge(self, edge: Dict[str, Any]) -> bool:
|
||
# Treat any edge with `mode: push` as contributing to back-stack history.
|
||
#
|
||
# In our nav graph schema, both "navigation" and "state" edges can be "push"
|
||
# (e.g. opening a query-param UI state like `/?menu=plus`). If we ignore
|
||
# non-navigation push edges, implicit "back" traversal becomes impossible for
|
||
# these states and shortest paths will be missing.
|
||
mode = edge.get("mode")
|
||
if mode == "replace":
|
||
return False
|
||
if mode is None:
|
||
return bool(self.assume_push_when_mode_missing)
|
||
return True
|
||
|
||
def _has_availability(self, edge: Dict[str, Any]) -> bool:
|
||
# If present, this edge is not always available.
|
||
return "availability" in edge and edge.get("availability") is not None
|
||
|
||
def _has_ui_condition(self, edge: Dict[str, Any]) -> bool:
|
||
# transition.ui.condition propagated into nav graph edge
|
||
return edge.get("uiCondition") is not None
|
||
|
||
def _has_condition_status(self, edge: Dict[str, Any]) -> bool:
|
||
# data-mode condition evaluation status propagated into nav graph edge
|
||
return edge.get("conditionStatus") is not None
|
||
|
||
def _needs_prompt(self, edge: Dict[str, Any]) -> bool:
|
||
# "Need prompt" edges: UI condition exists, or condition status indicates uncertainty.
|
||
# In schema graphs, uiCondition exists but conditionStatus is usually absent.
|
||
return self._has_ui_condition(edge) or self._has_condition_status(edge)
|
||
|
||
def _node_needs_prompt(self, node: Dict[str, Any]) -> bool:
|
||
# "Need prompt" nodes: existence depends on stateCondition, or data-mode has conditionStatus.
|
||
return node.get("stateCondition") is not None or node.get("conditionStatus") is not None
|
||
|
||
def _node_step(self, node_id: str) -> Dict[str, Any]:
|
||
node = self.nodes.get(node_id, {"id": node_id})
|
||
return {
|
||
"type": "node",
|
||
"id": node_id,
|
||
"label": self._node_label(node),
|
||
"node": {
|
||
"stateCondition": node.get("stateCondition"),
|
||
"conditionStatus": node.get("conditionStatus"),
|
||
"needsPrompt": self._node_needs_prompt(node),
|
||
},
|
||
}
|
||
|
||
def _build_graph(self) -> None:
|
||
for edge in self.edges:
|
||
source, target = self._edge_endpoints(edge)
|
||
if not source or not target:
|
||
continue
|
||
self.adj.setdefault(source, []).append((target, edge))
|
||
if self._is_push_edge(edge):
|
||
self.push_incoming.setdefault(target, []).append((source, edge))
|
||
|
||
def _node_label(self, node: Dict[str, Any]) -> str:
|
||
parts = [
|
||
node.get("description", ""),
|
||
node.get("component", ""),
|
||
node.get("routePath", ""),
|
||
node.get("uiStateId", ""),
|
||
node.get("id", ""),
|
||
]
|
||
return " ".join([p for p in parts if p])
|
||
|
||
def _build_entities(self) -> None:
|
||
for node_id, node in self.nodes.items():
|
||
self.entities.append(
|
||
Entity(
|
||
id=node_id,
|
||
type="node",
|
||
label=self._node_label(node),
|
||
parent_node=node_id,
|
||
)
|
||
)
|
||
for state in node.get("states", []):
|
||
self.entities.append(
|
||
Entity(
|
||
id=state,
|
||
type="state",
|
||
label=f"{state} {node.get('description', '')}",
|
||
parent_node=node_id,
|
||
)
|
||
)
|
||
for action in node.get("actions", []):
|
||
action_id = action.get("id", "")
|
||
action_label = action.get("label", "")
|
||
if not action_id and not action_label:
|
||
continue
|
||
self.entities.append(
|
||
Entity(
|
||
id=action_id or action_label,
|
||
type="action",
|
||
label=f"{action_id} {action_label}".strip(),
|
||
parent_node=node_id,
|
||
)
|
||
)
|
||
for edge in self.edges:
|
||
edge_id = edge.get("id") or edge.get("label")
|
||
edge_label = edge.get("label") or edge.get("id") or ""
|
||
if not edge_id and not edge_label:
|
||
continue
|
||
source, target = self._edge_endpoints(edge)
|
||
if not source or not target:
|
||
continue
|
||
self.entities.append(
|
||
Entity(
|
||
id=edge_id,
|
||
type="transition",
|
||
label=f"{edge_id} {edge_label}".strip(),
|
||
parent_node=None,
|
||
edge=edge,
|
||
)
|
||
)
|
||
|
||
def search_entities(
|
||
self,
|
||
query: str,
|
||
types: Optional[Iterable[str]] = None,
|
||
limit: int = 5,
|
||
min_score: float = 0.55,
|
||
) -> List[Match]:
|
||
q_raw = (query or "").strip()
|
||
# If the user provides a concrete route-like string (e.g. "/my-reading?tab=total"),
|
||
# we should match nodes/states primarily by id, otherwise fuzzy label matching can
|
||
# produce confusing "same-node" matches (0-length paths) due to high token overlap.
|
||
path_like = q_raw.startswith("/")
|
||
path_like_with_query = path_like and ("?" in q_raw)
|
||
type_set = set(types) if types else None
|
||
matches: List[Match] = []
|
||
for entity in self.entities:
|
||
if type_set and entity.type not in type_set:
|
||
continue
|
||
if path_like and entity.type in ("node", "state"):
|
||
if path_like_with_query:
|
||
# For paths with query string, require exact id match.
|
||
score = 1.0 if q_raw == entity.id else 0.0
|
||
else:
|
||
score = _score(query, entity.id)
|
||
else:
|
||
score = max(_score(query, entity.label), _score(query, entity.id))
|
||
if score >= min_score:
|
||
matches.append(Match(entity=entity, score=score))
|
||
type_priority = {"node": 0, "state": 1, "action": 2, "transition": 3}
|
||
matches.sort(
|
||
key=lambda m: (-m.score, type_priority.get(m.entity.type, 99), m.entity.id)
|
||
)
|
||
return matches[:limit]
|
||
|
||
def _build_history_seeds(
|
||
self,
|
||
start: str,
|
||
edge_allowed: Optional[Callable[[Dict[str, Any]], bool]] = None,
|
||
node_allowed: Optional[Callable[[str], bool]] = None,
|
||
) -> List[Tuple[str, ...]]:
|
||
if self.history_depth <= 0:
|
||
return [()]
|
||
seeds: List[Tuple[str, ...]] = [()]
|
||
stack: List[Tuple[str, Tuple[str, ...], int]] = [(start, (), 0)]
|
||
visited = set()
|
||
while stack:
|
||
node, history, depth = stack.pop()
|
||
if depth >= self.history_depth:
|
||
continue
|
||
key = (node, history)
|
||
if key in visited:
|
||
continue
|
||
visited.add(key)
|
||
for prev_node, inc_edge in self.push_incoming.get(node, []):
|
||
if edge_allowed and not edge_allowed(inc_edge):
|
||
continue
|
||
if node_allowed and not node_allowed(prev_node):
|
||
continue
|
||
new_history = (prev_node,) + history
|
||
seeds.append(new_history)
|
||
if len(seeds) >= self.max_history_seeds:
|
||
return seeds
|
||
stack.append((prev_node, new_history, depth + 1))
|
||
return seeds
|
||
|
||
def _shortest_path_with_history(
|
||
self,
|
||
start: str,
|
||
target: str,
|
||
edge_allowed: Optional[Callable[[Dict[str, Any]], bool]] = None,
|
||
node_allowed: Optional[Callable[[str], bool]] = None,
|
||
) -> Optional[Tuple[List[Tuple[str, Optional[Dict[str, Any]]]], Tuple[str, ...]]]:
|
||
if node_allowed and (not node_allowed(start) or not node_allowed(target)):
|
||
return None
|
||
if start == target:
|
||
return [(start, None)], ()
|
||
start_histories = self._build_history_seeds(
|
||
start,
|
||
edge_allowed=edge_allowed,
|
||
node_allowed=node_allowed,
|
||
)
|
||
start_states = [(start, history) for history in start_histories]
|
||
queue = deque(start_states)
|
||
prev: Dict[Tuple[str, Tuple[str, ...]], Tuple[Tuple[str, Tuple[str, ...]], Dict[str, Any]]] = {}
|
||
visited = set(start_states)
|
||
while queue:
|
||
node, history = queue.popleft()
|
||
if node == target:
|
||
end_state = (node, history)
|
||
break
|
||
for neighbor, edge in self.adj.get(node, []):
|
||
if edge_allowed and not edge_allowed(edge):
|
||
continue
|
||
if node_allowed and not node_allowed(neighbor):
|
||
continue
|
||
next_history = history
|
||
# IMPORTANT: bound history length, otherwise state space can explode (infinite in cycles).
|
||
# We treat `history_depth` as the max stack depth we model for implicit back traversal.
|
||
if self.history_depth > 0 and self._is_push_edge(edge):
|
||
next_history = (history + (node,))[-self.history_depth :]
|
||
next_state = (neighbor, next_history)
|
||
if next_state in visited:
|
||
continue
|
||
visited.add(next_state)
|
||
prev[next_state] = ((node, history), edge)
|
||
queue.append(next_state)
|
||
if self.include_back_edges and history:
|
||
back_target = history[-1]
|
||
if node_allowed and not node_allowed(back_target):
|
||
continue
|
||
back_edge = {
|
||
"id": "back",
|
||
"label": "返回",
|
||
"type": "back",
|
||
"source": node,
|
||
"target": back_target,
|
||
"sourceNodeId": node,
|
||
"targetNodeId": back_target,
|
||
}
|
||
next_state = (back_target, history[:-1])
|
||
if next_state not in visited:
|
||
visited.add(next_state)
|
||
prev[next_state] = ((node, history), back_edge)
|
||
queue.append(next_state)
|
||
else:
|
||
return None
|
||
path: List[Tuple[str, Optional[Dict[str, Any]]]] = [(end_state[0], None)]
|
||
cur_state = end_state
|
||
while cur_state in prev:
|
||
prev_state, edge = prev[cur_state]
|
||
path.append((prev_state[0], edge))
|
||
cur_state = prev_state
|
||
path.reverse()
|
||
assumed_history = cur_state[1]
|
||
return path, assumed_history
|
||
|
||
def _render_path(
|
||
self, path: List[Tuple[str, Optional[Dict[str, Any]]]]
|
||
) -> List[Dict[str, Any]]:
|
||
steps: List[Dict[str, Any]] = []
|
||
for idx, (node_id, edge) in enumerate(path):
|
||
steps.append(self._node_step(node_id))
|
||
if edge and idx < len(path) - 1:
|
||
steps.append(
|
||
{
|
||
"type": "transition",
|
||
"id": edge.get("id") or edge.get("label"),
|
||
"label": edge.get("label") or edge.get("id"),
|
||
"edge": {
|
||
"source": edge.get("sourceNodeId") or edge.get("source"),
|
||
"target": edge.get("targetNodeId") or edge.get("target"),
|
||
"type": edge.get("type"),
|
||
"mode": edge.get("mode"),
|
||
# Key parameters for locating UI triggers (e.g. tab switch).
|
||
"search": edge.get("search"),
|
||
"searchParams": edge.get("searchParams"),
|
||
"params": edge.get("params"),
|
||
"availability": edge.get("availability"),
|
||
"availabilityNote": edge.get("availabilityNote"),
|
||
"notes": edge.get("notes"),
|
||
"when": edge.get("when"),
|
||
"uiCondition": edge.get("uiCondition"),
|
||
"conditionStatus": edge.get("conditionStatus"),
|
||
"needsPrompt": self._needs_prompt(edge),
|
||
"uiMeta": edge.get("uiMeta"),
|
||
},
|
||
}
|
||
)
|
||
return steps
|
||
|
||
def _resolve_from(self, match: Match) -> Tuple[str, List[Dict[str, Any]]]:
|
||
entity = match.entity
|
||
if entity.type == "transition" and entity.edge:
|
||
target = self._edge_endpoints(entity.edge)[1]
|
||
prefix = [
|
||
{
|
||
"type": "transition",
|
||
"id": entity.id,
|
||
"label": entity.label,
|
||
"edge": {
|
||
"source": self._edge_endpoints(entity.edge)[0],
|
||
"target": target,
|
||
"type": entity.edge.get("type"),
|
||
"mode": entity.edge.get("mode"),
|
||
"search": entity.edge.get("search"),
|
||
"searchParams": entity.edge.get("searchParams"),
|
||
"params": entity.edge.get("params"),
|
||
"availability": entity.edge.get("availability"),
|
||
"availabilityNote": entity.edge.get("availabilityNote"),
|
||
"notes": entity.edge.get("notes"),
|
||
"when": entity.edge.get("when"),
|
||
"uiCondition": entity.edge.get("uiCondition"),
|
||
"conditionStatus": entity.edge.get("conditionStatus"),
|
||
"needsPrompt": self._needs_prompt(entity.edge),
|
||
"uiMeta": entity.edge.get("uiMeta"),
|
||
},
|
||
}
|
||
]
|
||
return target or "", prefix
|
||
if entity.type == "action":
|
||
return entity.parent_node or "", [
|
||
{"type": "action", "id": entity.id, "label": entity.label}
|
||
]
|
||
if entity.type == "state":
|
||
return entity.parent_node or "", [
|
||
{"type": "state", "id": entity.id, "label": entity.label}
|
||
]
|
||
return entity.id, []
|
||
|
||
def _resolve_to(self, match: Match) -> Tuple[str, List[Dict[str, Any]]]:
|
||
entity = match.entity
|
||
if entity.type == "transition" and entity.edge:
|
||
source, target = self._edge_endpoints(entity.edge)
|
||
suffix = [
|
||
{
|
||
"type": "transition",
|
||
"id": entity.id,
|
||
"label": entity.label,
|
||
"edge": {
|
||
"source": source,
|
||
"target": target,
|
||
"type": entity.edge.get("type"),
|
||
"mode": entity.edge.get("mode"),
|
||
"search": entity.edge.get("search"),
|
||
"searchParams": entity.edge.get("searchParams"),
|
||
"params": entity.edge.get("params"),
|
||
"availability": entity.edge.get("availability"),
|
||
"availabilityNote": entity.edge.get("availabilityNote"),
|
||
"notes": entity.edge.get("notes"),
|
||
"when": entity.edge.get("when"),
|
||
"uiCondition": entity.edge.get("uiCondition"),
|
||
"conditionStatus": entity.edge.get("conditionStatus"),
|
||
"needsPrompt": self._needs_prompt(entity.edge),
|
||
"uiMeta": entity.edge.get("uiMeta"),
|
||
},
|
||
}
|
||
]
|
||
if target:
|
||
suffix.append(self._node_step(target))
|
||
return source or "", suffix
|
||
if entity.type == "action":
|
||
return entity.parent_node or "", [
|
||
{"type": "action", "id": entity.id, "label": entity.label}
|
||
]
|
||
if entity.type == "state":
|
||
return entity.parent_node or "", [
|
||
{"type": "state", "id": entity.id, "label": entity.label}
|
||
]
|
||
return entity.id, []
|
||
|
||
def find_paths(
|
||
self,
|
||
query_from: str,
|
||
query_to: str,
|
||
max_candidates: int = 5,
|
||
max_paths: int = 5,
|
||
types_from: Optional[Iterable[str]] = None,
|
||
types_to: Optional[Iterable[str]] = None,
|
||
) -> Dict[str, Any]:
|
||
if types_from is None:
|
||
types_from = ["node"]
|
||
from_matches = self.search_entities(
|
||
query_from, types=types_from, limit=max_candidates
|
||
)
|
||
to_matches = self.search_entities(
|
||
query_to, types=types_to, limit=max_candidates
|
||
)
|
||
# Keyed by (start_node_id, end_node_id). For each distinct endpoint pair,
|
||
# keep only the shortest valid path. This matches agent usage better:
|
||
# "all keyword-matching endpoints, each with its shortest path".
|
||
best_by_endpoints: Dict[Tuple[str, str], Dict[str, Any]] = {}
|
||
for from_match in from_matches:
|
||
start_node, prefix = self._resolve_from(from_match)
|
||
if not start_node:
|
||
continue
|
||
for to_match in to_matches:
|
||
target_node, suffix = self._resolve_to(to_match)
|
||
if not target_node:
|
||
continue
|
||
path_result = self._shortest_path_with_history(start_node, target_node)
|
||
if not path_result:
|
||
continue
|
||
path, assumed_history = path_result
|
||
steps = prefix + self._render_path(path) + suffix
|
||
start_step = next((s for s in steps if s.get("type") == "node"), None)
|
||
end_step = next((s for s in reversed(steps) if s.get("type") == "node"), None)
|
||
endpoints = (
|
||
(start_step.get("id") if start_step else "") or start_node,
|
||
(end_step.get("id") if end_step else "") or target_node,
|
||
)
|
||
conditional_edges = [
|
||
s
|
||
for s in steps
|
||
if s.get("type") == "transition"
|
||
and isinstance(s.get("edge"), dict)
|
||
and s["edge"].get("availability") is not None
|
||
]
|
||
prompt_edges = [
|
||
s
|
||
for s in steps
|
||
if s.get("type") == "transition"
|
||
and isinstance(s.get("edge"), dict)
|
||
and (s["edge"].get("uiCondition") is not None or s["edge"].get("conditionStatus") is not None)
|
||
]
|
||
prompt_nodes = [
|
||
s
|
||
for s in steps
|
||
if s.get("type") == "node"
|
||
and isinstance(s.get("node"), dict)
|
||
and (
|
||
s["node"].get("stateCondition") is not None
|
||
or s["node"].get("conditionStatus") is not None
|
||
or bool(s["node"].get("needsPrompt"))
|
||
)
|
||
]
|
||
item = {
|
||
"from": {
|
||
"query": query_from,
|
||
"match": {
|
||
"id": from_match.entity.id,
|
||
"type": from_match.entity.type,
|
||
"label": from_match.entity.label,
|
||
"score": from_match.score,
|
||
},
|
||
},
|
||
"to": {
|
||
"query": query_to,
|
||
"match": {
|
||
"id": to_match.entity.id,
|
||
"type": to_match.entity.type,
|
||
"label": to_match.entity.label,
|
||
"score": to_match.score,
|
||
},
|
||
},
|
||
"startNodeId": endpoints[0],
|
||
"endNodeId": endpoints[1],
|
||
"steps": steps,
|
||
"length": len([s for s in steps if s["type"] == "transition"]),
|
||
"assumedHistory": list(assumed_history),
|
||
# Naming alignment:
|
||
# - availability edges are NOT "condition" edges; they mean "not always available"
|
||
"hasAvailabilityEdges": len(conditional_edges) > 0,
|
||
"availabilityEdges": [
|
||
{
|
||
"id": ce.get("id"),
|
||
"label": ce.get("label"),
|
||
"edge": ce.get("edge"),
|
||
}
|
||
for ce in conditional_edges
|
||
],
|
||
# Need-prompt edges: uiCondition / conditionStatus
|
||
"hasPromptEdges": len(prompt_edges) > 0,
|
||
"promptEdges": [
|
||
{
|
||
"id": pe.get("id"),
|
||
"label": pe.get("label"),
|
||
"edge": pe.get("edge"),
|
||
}
|
||
for pe in prompt_edges
|
||
],
|
||
# Need-prompt nodes: stateCondition / conditionStatus
|
||
"hasPromptNodes": len(prompt_nodes) > 0,
|
||
"promptNodes": [
|
||
{"id": pn.get("id"), "label": pn.get("label"), "node": pn.get("node")}
|
||
for pn in prompt_nodes
|
||
],
|
||
"fallback": None,
|
||
}
|
||
existing = best_by_endpoints.get(endpoints)
|
||
if existing is None:
|
||
best_by_endpoints[endpoints] = item
|
||
continue
|
||
# Prefer shorter path; then higher match scores; then fewer steps.
|
||
if item["length"] < existing["length"]:
|
||
best_by_endpoints[endpoints] = item
|
||
continue
|
||
if item["length"] > existing["length"]:
|
||
continue
|
||
new_score = item["from"]["match"]["score"] + item["to"]["match"]["score"]
|
||
old_score = existing["from"]["match"]["score"] + existing["to"]["match"]["score"]
|
||
if new_score > old_score:
|
||
best_by_endpoints[endpoints] = item
|
||
continue
|
||
if new_score < old_score:
|
||
continue
|
||
if len(item["steps"]) < len(existing["steps"]):
|
||
best_by_endpoints[endpoints] = item
|
||
|
||
results = list(best_by_endpoints.values())
|
||
# For any path that uses availability edges, compute an additional shortest path
|
||
# that avoids all edges with `availability`.
|
||
def always_available(edge: Dict[str, Any]) -> bool:
|
||
return not self._has_availability(edge)
|
||
|
||
for item in results:
|
||
if not item.get("hasAvailabilityEdges"):
|
||
continue
|
||
start_id = item.get("startNodeId") or ""
|
||
end_id = item.get("endNodeId") or ""
|
||
if not start_id or not end_id:
|
||
continue
|
||
fallback_result = self._shortest_path_with_history(
|
||
start_id, end_id, edge_allowed=always_available
|
||
)
|
||
if not fallback_result:
|
||
item["fallback"] = None
|
||
continue
|
||
fb_path, fb_history = fallback_result
|
||
fb_steps = self._render_path(fb_path)
|
||
|
||
# If the original target was action/state, keep that terminal info in fallback.
|
||
to_match_type = item.get("to", {}).get("match", {}).get("type")
|
||
if to_match_type == "action":
|
||
last_action = next(
|
||
(s for s in reversed(item["steps"]) if s.get("type") == "action"),
|
||
None,
|
||
)
|
||
if last_action:
|
||
fb_steps = fb_steps + [last_action]
|
||
elif to_match_type == "state":
|
||
last_state = next(
|
||
(s for s in reversed(item["steps"]) if s.get("type") == "state"),
|
||
None,
|
||
)
|
||
if last_state:
|
||
fb_steps = fb_steps + [last_state]
|
||
|
||
# Avoid duplicate output when the fallback equals the main path.
|
||
sig_main = " -> ".join(f"{s['type']}:{s.get('id','')}" for s in item["steps"])
|
||
sig_fb = " -> ".join(f"{s['type']}:{s.get('id','')}" for s in fb_steps)
|
||
if sig_fb == sig_main:
|
||
item["fallback"] = None
|
||
continue
|
||
|
||
item["fallback"] = {
|
||
"steps": fb_steps,
|
||
"length": len([s for s in fb_steps if s["type"] == "transition"]),
|
||
"assumedHistory": list(fb_history),
|
||
}
|
||
|
||
# For any path that uses prompt edges/nodes, compute an additional shortest path
|
||
# that avoids ALL prompt edges (uiCondition/conditionStatus) and prompt nodes (stateCondition/conditionStatus).
|
||
def prompt_free_edge(edge: Dict[str, Any]) -> bool:
|
||
return not self._needs_prompt(edge)
|
||
|
||
def prompt_free_node(node_id: str) -> bool:
|
||
node = self.nodes.get(node_id, {"id": node_id})
|
||
return not self._node_needs_prompt(node)
|
||
|
||
for item in results:
|
||
if not (item.get("hasPromptEdges") or item.get("hasPromptNodes")):
|
||
item["fallbackNoPrompt"] = None
|
||
continue
|
||
start_id = item.get("startNodeId") or ""
|
||
end_id = item.get("endNodeId") or ""
|
||
if not start_id or not end_id:
|
||
item["fallbackNoPrompt"] = None
|
||
continue
|
||
|
||
fallback_result = self._shortest_path_with_history(
|
||
start_id,
|
||
end_id,
|
||
edge_allowed=prompt_free_edge,
|
||
node_allowed=prompt_free_node,
|
||
)
|
||
if fallback_result:
|
||
fb_path, fb_history = fallback_result
|
||
fb_steps = self._render_path(fb_path)
|
||
|
||
# If the original target was action/state, keep that terminal info in fallback.
|
||
to_match_type = item.get("to", {}).get("match", {}).get("type")
|
||
if to_match_type == "action":
|
||
last_action = next(
|
||
(s for s in reversed(item["steps"]) if s.get("type") == "action"),
|
||
None,
|
||
)
|
||
if last_action:
|
||
fb_steps = fb_steps + [last_action]
|
||
elif to_match_type == "state":
|
||
last_state = next(
|
||
(s for s in reversed(item["steps"]) if s.get("type") == "state"),
|
||
None,
|
||
)
|
||
if last_state:
|
||
fb_steps = fb_steps + [last_state]
|
||
|
||
# Avoid duplicate output when the fallback equals the main path.
|
||
sig_main = " -> ".join(f"{s['type']}:{s.get('id','')}" for s in item["steps"])
|
||
sig_fb = " -> ".join(f"{s['type']}:{s.get('id','')}" for s in fb_steps)
|
||
if sig_fb == sig_main:
|
||
item["fallbackNoPrompt"] = None
|
||
else:
|
||
item["fallbackNoPrompt"] = {
|
||
"steps": fb_steps,
|
||
"length": len([s for s in fb_steps if s["type"] == "transition"]),
|
||
"assumedHistory": list(fb_history),
|
||
}
|
||
else:
|
||
item["fallbackNoPrompt"] = None
|
||
|
||
# Add a readable natural-language narrative for each path/fallback.
|
||
for item in results:
|
||
item["narrative"] = _steps_to_narrative(item.get("steps", []))
|
||
fallback = item.get("fallback")
|
||
if isinstance(fallback, dict):
|
||
fallback["narrative"] = _steps_to_narrative(fallback.get("steps", []))
|
||
fallback_np = item.get("fallbackNoPrompt")
|
||
if isinstance(fallback_np, dict):
|
||
fallback_np["narrative"] = _steps_to_narrative(fallback_np.get("steps", []))
|
||
results.sort(
|
||
key=lambda r: (
|
||
r["length"],
|
||
-r["from"]["match"]["score"],
|
||
-r["to"]["match"]["score"],
|
||
len(r["steps"]),
|
||
)
|
||
)
|
||
return {
|
||
"summary": {
|
||
"fromQuery": query_from,
|
||
"toQuery": query_to,
|
||
"pathsCount": min(len(results), max_paths),
|
||
"fromCandidatesCount": len(from_matches),
|
||
"toCandidatesCount": len(to_matches),
|
||
},
|
||
"fromCandidates": [
|
||
{
|
||
"id": m.entity.id,
|
||
"type": m.entity.type,
|
||
"label": m.entity.label,
|
||
"score": m.score,
|
||
}
|
||
for m in from_matches
|
||
],
|
||
"toCandidates": [
|
||
{
|
||
"id": m.entity.id,
|
||
"type": m.entity.type,
|
||
"label": m.entity.label,
|
||
"score": m.score,
|
||
}
|
||
for m in to_matches
|
||
],
|
||
"paths": results[:max_paths],
|
||
}
|
||
|
||
|
||
def _parse_types(value: Optional[str]) -> Optional[List[str]]:
|
||
if not value:
|
||
return None
|
||
return [t.strip() for t in value.split(",") if t.strip()]
|
||
|
||
|
||
def main() -> None:
|
||
parser = argparse.ArgumentParser(description="Find shortest paths in nav graph")
|
||
parser.add_argument("--graph", required=True, help="Path to nav graph json")
|
||
parser.add_argument("--from", dest="from_query", required=True, help="Start query")
|
||
parser.add_argument("--to", dest="to_query", required=True, help="Target query")
|
||
parser.add_argument("--max-candidates", type=int, default=20)
|
||
parser.add_argument("--max-paths", type=int, default=20)
|
||
parser.add_argument("--json", action="store_true", help="Output JSON format")
|
||
parser.add_argument("--no-back", action="store_true", help="Disable implicit back edges")
|
||
parser.add_argument(
|
||
"--assume-push",
|
||
action="store_true",
|
||
help="When edge mode missing, assume push and add back edges",
|
||
)
|
||
parser.add_argument(
|
||
"--history-depth",
|
||
type=int,
|
||
default=10,
|
||
help="Max implicit history depth for back traversal (default: 4)",
|
||
)
|
||
parser.add_argument(
|
||
"--max-history-seeds",
|
||
type=int,
|
||
default=50,
|
||
help="Limit initial history seed count (default: 50)",
|
||
)
|
||
parser.add_argument(
|
||
"--from-types",
|
||
default=None,
|
||
help="Comma types: node,state,action,transition (default: node)",
|
||
)
|
||
parser.add_argument("--to-types", default=None, help="Comma types: node,state,action,transition")
|
||
args = parser.parse_args()
|
||
|
||
with open(args.graph, "r", encoding="utf-8") as f:
|
||
graph = json.load(f)
|
||
|
||
finder = NavGraphPathFinder(
|
||
graph,
|
||
include_back_edges=not args.no_back,
|
||
assume_push_when_mode_missing=args.assume_push,
|
||
history_depth=args.history_depth,
|
||
max_history_seeds=args.max_history_seeds,
|
||
)
|
||
result = finder.find_paths(
|
||
query_from=args.from_query,
|
||
query_to=args.to_query,
|
||
max_candidates=args.max_candidates,
|
||
max_paths=args.max_paths,
|
||
types_from=_parse_types(args.from_types),
|
||
types_to=_parse_types(args.to_types),
|
||
)
|
||
if args.json:
|
||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||
return
|
||
|
||
paths = result.get("paths", [])
|
||
print(f"paths: {len(paths)}")
|
||
for idx, path in enumerate(paths, start=1):
|
||
steps = path.get("steps", [])
|
||
start_node = next((s for s in steps if s.get("type") == "node"), None)
|
||
end_node = next((s for s in reversed(steps) if s.get("type") == "node"), None)
|
||
start_label = start_node.get("label") if start_node else ""
|
||
end_label = end_node.get("label") if end_node else ""
|
||
print(f"\n[{idx}] {start_label} -> {end_label}")
|
||
for step in steps:
|
||
if step.get("type") == "node":
|
||
node_meta = step.get("node") if isinstance(step.get("node"), dict) else {}
|
||
needs_prompt = bool(node_meta.get("needsPrompt")) or node_meta.get("stateCondition") is not None or node_meta.get("conditionStatus") is not None
|
||
prefix = " node(!prompt): " if needs_prompt else " node: "
|
||
print(prefix + f"{step.get('id')} | {step.get('label')}")
|
||
if needs_prompt:
|
||
if node_meta.get("stateCondition") is not None:
|
||
print(f" stateCondition: {node_meta.get('stateCondition')}")
|
||
if node_meta.get("conditionStatus") is not None:
|
||
print(f" conditionStatus: {node_meta.get('conditionStatus')}")
|
||
elif step.get("type") == "transition":
|
||
edge = step.get("edge", {})
|
||
availability = edge.get("availability")
|
||
needs_prompt = bool(edge.get("needsPrompt")) or edge.get("uiCondition") is not None or edge.get("conditionStatus") is not None
|
||
if availability is not None and needs_prompt:
|
||
prefix = " transition(!availability,!prompt): "
|
||
elif availability is not None:
|
||
prefix = " transition(!availability): "
|
||
elif needs_prompt:
|
||
prefix = " transition(!prompt): "
|
||
else:
|
||
prefix = " transition: "
|
||
print(
|
||
prefix
|
||
+ f"{step.get('id')} | {step.get('label')} | "
|
||
+ f"{edge.get('source')} -> {edge.get('target')} | "
|
||
+ f"{edge.get('type')}"
|
||
)
|
||
# Important: expose key params for locating UI elements (e.g. tab=total).
|
||
if isinstance(edge.get("search"), dict) and edge.get("search"):
|
||
print(f" search: {json.dumps(edge.get('search'), ensure_ascii=False)}")
|
||
if isinstance(edge.get("params"), dict) and edge.get("params"):
|
||
print(f" params: {json.dumps(edge.get('params'), ensure_ascii=False)}")
|
||
if availability is not None:
|
||
print(f" availability: {availability}")
|
||
if edge.get("availabilityNote"):
|
||
print(f" availabilityNote: {edge.get('availabilityNote')}")
|
||
if edge.get("notes"):
|
||
print(f" notes: {edge.get('notes')}")
|
||
if needs_prompt:
|
||
if edge.get("uiCondition") is not None:
|
||
print(f" uiCondition: {edge.get('uiCondition')}")
|
||
if edge.get("conditionStatus") is not None:
|
||
print(f" conditionStatus: {edge.get('conditionStatus')}")
|
||
elif step.get("type") == "action":
|
||
print(f" action: {step.get('id')} | {step.get('label')}")
|
||
elif step.get("type") == "state":
|
||
print(f" state: {step.get('id')} | {step.get('label')}")
|
||
|
||
narrative = path.get("narrative")
|
||
if narrative:
|
||
print(f"\n 自然语言: {narrative}")
|
||
|
||
if path.get("hasAvailabilityEdges"):
|
||
fallback = path.get("fallback")
|
||
if fallback is None:
|
||
print("\n fallback(no availability edges): <not found>")
|
||
else:
|
||
fb_steps = fallback.get("steps", [])
|
||
fb_start = next((s for s in fb_steps if s.get("type") == "node"), None)
|
||
fb_end = next((s for s in reversed(fb_steps) if s.get("type") == "node"), None)
|
||
fb_start_label = fb_start.get("label") if fb_start else ""
|
||
fb_end_label = fb_end.get("label") if fb_end else ""
|
||
print(f"\n fallback(no availability edges): {fb_start_label} -> {fb_end_label}")
|
||
for step in fb_steps:
|
||
if step.get("type") == "node":
|
||
node_meta = step.get("node") if isinstance(step.get("node"), dict) else {}
|
||
needs_prompt = bool(node_meta.get("needsPrompt")) or node_meta.get("stateCondition") is not None or node_meta.get("conditionStatus") is not None
|
||
prefix = " node(!prompt): " if needs_prompt else " node: "
|
||
print(prefix + f"{step.get('id')} | {step.get('label')}")
|
||
if needs_prompt:
|
||
if node_meta.get("stateCondition") is not None:
|
||
print(f" stateCondition: {node_meta.get('stateCondition')}")
|
||
if node_meta.get("conditionStatus") is not None:
|
||
print(f" conditionStatus: {node_meta.get('conditionStatus')}")
|
||
elif step.get("type") == "transition":
|
||
edge = step.get("edge", {})
|
||
print(
|
||
" transition: "
|
||
f"{step.get('id')} | {step.get('label')} | "
|
||
f"{edge.get('source')} -> {edge.get('target')} | "
|
||
f"{edge.get('type')}"
|
||
)
|
||
elif step.get("type") == "action":
|
||
print(f" action: {step.get('id')} | {step.get('label')}")
|
||
elif step.get("type") == "state":
|
||
print(f" state: {step.get('id')} | {step.get('label')}")
|
||
|
||
fb_narrative = fallback.get("narrative")
|
||
if fb_narrative:
|
||
print(f"\n 自然语言: {fb_narrative}")
|
||
|
||
if path.get("hasPromptEdges") or path.get("hasPromptNodes"):
|
||
fallback = path.get("fallbackNoPrompt")
|
||
if fallback is None:
|
||
print("\n fallback(no prompt edges/nodes): <not found>")
|
||
else:
|
||
fb_steps = fallback.get("steps", [])
|
||
fb_start = next((s for s in fb_steps if s.get("type") == "node"), None)
|
||
fb_end = next((s for s in reversed(fb_steps) if s.get("type") == "node"), None)
|
||
fb_start_label = fb_start.get("label") if fb_start else ""
|
||
fb_end_label = fb_end.get("label") if fb_end else ""
|
||
print(f"\n fallback(no prompt edges/nodes): {fb_start_label} -> {fb_end_label}")
|
||
for step in fb_steps:
|
||
if step.get("type") == "node":
|
||
node_meta = step.get("node") if isinstance(step.get("node"), dict) else {}
|
||
needs_prompt = bool(node_meta.get("needsPrompt")) or node_meta.get("stateCondition") is not None or node_meta.get("conditionStatus") is not None
|
||
prefix = " node(!prompt): " if needs_prompt else " node: "
|
||
print(prefix + f"{step.get('id')} | {step.get('label')}")
|
||
elif step.get("type") == "transition":
|
||
edge = step.get("edge", {})
|
||
needs_prompt = bool(edge.get("needsPrompt")) or edge.get("uiCondition") is not None or edge.get("conditionStatus") is not None
|
||
prefix = " transition(!prompt): " if needs_prompt else " transition: "
|
||
print(
|
||
prefix
|
||
+ f"{step.get('id')} | {step.get('label')} | "
|
||
+ f"{edge.get('source')} -> {edge.get('target')} | "
|
||
+ f"{edge.get('type')}"
|
||
)
|
||
elif step.get("type") == "action":
|
||
print(f" action: {step.get('id')} | {step.get('label')}")
|
||
elif step.get("type") == "state":
|
||
print(f" state: {step.get('id')} | {step.get('label')}")
|
||
|
||
fb_narrative = fallback.get("narrative")
|
||
if fb_narrative:
|
||
print(f"\n 自然语言: {fb_narrative}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|