231 lines
6.8 KiB
Python
231 lines
6.8 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Any, Protocol
|
|
|
|
from ..graph import GraphIndex
|
|
from ..models import NodeRecord, PromptSegment
|
|
from ..trace import TraceCollector
|
|
|
|
|
|
class NodeAdapter(Protocol):
|
|
def matches(self, node: NodeRecord) -> bool:
|
|
...
|
|
|
|
def resolve(
|
|
self,
|
|
node: NodeRecord,
|
|
context: TextResolutionContext,
|
|
role: str,
|
|
path: list[str],
|
|
depth: int,
|
|
output_index: int | None = None,
|
|
) -> list[PromptSegment]:
|
|
...
|
|
|
|
|
|
class AdapterRegistry:
|
|
def __init__(self) -> None:
|
|
self._adapters: list[NodeAdapter] = []
|
|
|
|
def register(self, adapter: NodeAdapter) -> None:
|
|
self._adapters.append(adapter)
|
|
|
|
def adapter_for(self, node: NodeRecord) -> NodeAdapter | None:
|
|
for adapter in self._adapters:
|
|
if adapter.matches(node):
|
|
return adapter
|
|
return None
|
|
|
|
|
|
class TextResolutionContext:
|
|
def __init__(
|
|
self,
|
|
graph: GraphIndex,
|
|
registry: AdapterRegistry | None = None,
|
|
prefer_cached_text: bool = True,
|
|
trace: TraceCollector | None = None,
|
|
max_depth: int = 40,
|
|
parse_max_depth: int | None = None,
|
|
parameter_index: int = 0,
|
|
include_raw_json: bool = True,
|
|
base_dir: str | None = None,
|
|
recursion_stack: tuple[str, ...] = (),
|
|
unresolved: list[dict[str, Any]] | None = None,
|
|
) -> None:
|
|
self.graph = graph
|
|
self.registry = registry or AdapterRegistry()
|
|
self.prefer_cached_text = prefer_cached_text
|
|
self.trace = trace or TraceCollector()
|
|
self.max_depth = max_depth
|
|
self.parse_max_depth = parse_max_depth if parse_max_depth is not None else max_depth
|
|
self.parameter_index = parameter_index
|
|
self.include_raw_json = include_raw_json
|
|
self.base_dir = base_dir
|
|
self.recursion_stack = recursion_stack
|
|
self.unresolved = unresolved
|
|
|
|
def resolve_node(
|
|
self,
|
|
node_id: str | int,
|
|
role: str,
|
|
path: list[str],
|
|
depth: int = 0,
|
|
output_index: int | None = None,
|
|
) -> list[PromptSegment]:
|
|
if depth > self.max_depth:
|
|
self.trace.add("TEXT_TRACE", f"max depth reached at node {node_id}")
|
|
return []
|
|
|
|
node = self.graph.get_node(node_id)
|
|
if node is None:
|
|
self.trace.add("TEXT_TRACE", f"missing node {node_id}")
|
|
return []
|
|
|
|
if self.is_llm_template_node(node):
|
|
self.trace.add(
|
|
"TEXT_TRACE",
|
|
(
|
|
f"{node.class_type} {node.node_id}: low confidence "
|
|
"LLM template input skipped, no cached generated output found"
|
|
),
|
|
)
|
|
return []
|
|
|
|
adapter = self.registry.adapter_for(node)
|
|
if adapter is None:
|
|
self.trace.add("TEXT_TRACE", f"{node.class_type} {node.node_id}: no text adapter")
|
|
return []
|
|
return adapter.resolve(
|
|
node=node,
|
|
context=self,
|
|
role=role,
|
|
path=path + [f"{node.class_type} {node.node_id}"],
|
|
depth=depth + 1,
|
|
output_index=output_index,
|
|
)
|
|
|
|
def resolve_input(
|
|
self,
|
|
node: NodeRecord,
|
|
field: str,
|
|
role: str,
|
|
path: list[str],
|
|
depth: int,
|
|
) -> list[PromptSegment]:
|
|
return self.resolve_value(
|
|
value=node.inputs.get(field),
|
|
role=role,
|
|
path=path + [f"{node.class_type} {node.node_id}.{field}"],
|
|
depth=depth,
|
|
source_node=node,
|
|
field=field,
|
|
)
|
|
|
|
def resolve_value(
|
|
self,
|
|
value: object,
|
|
role: str,
|
|
path: list[str],
|
|
depth: int,
|
|
source_node: NodeRecord | None = None,
|
|
field: str | None = None,
|
|
) -> list[PromptSegment]:
|
|
if depth > self.max_depth:
|
|
self.trace.add("TEXT_TRACE", "max depth reached while resolving value")
|
|
return []
|
|
|
|
if isinstance(value, str):
|
|
if not value.strip():
|
|
return []
|
|
return [
|
|
PromptSegment(
|
|
text=value,
|
|
node_id=source_node.node_id if source_node else None,
|
|
class_type=source_node.class_type if source_node else None,
|
|
field=field,
|
|
path=path,
|
|
)
|
|
]
|
|
|
|
target = self.graph.link_target(value)
|
|
if target is not None:
|
|
target_id, output_index = target
|
|
return self.resolve_node(
|
|
node_id=target_id,
|
|
role=role,
|
|
path=path + [f"link[{target_id}, {output_index}]"],
|
|
depth=depth + 1,
|
|
output_index=output_index,
|
|
)
|
|
|
|
if isinstance(value, list):
|
|
segments: list[PromptSegment] = []
|
|
for item in value:
|
|
segments.extend(
|
|
self.resolve_value(
|
|
value=item,
|
|
role=role,
|
|
path=path,
|
|
depth=depth + 1,
|
|
source_node=source_node,
|
|
field=field,
|
|
)
|
|
)
|
|
return segments
|
|
|
|
return []
|
|
|
|
def workflow_cache_for(self, node: NodeRecord, field_names: list[str]) -> str | None:
|
|
return self.graph.workflow_cache_for(node.node_id, field_names)
|
|
|
|
def workflow_cache_entry(
|
|
self,
|
|
node: NodeRecord,
|
|
field_names: list[str],
|
|
) -> tuple[str, str] | None:
|
|
return self.graph.workflow_cache_entry(node.node_id, field_names)
|
|
|
|
def is_llm_template_node(self, node: NodeRecord) -> bool:
|
|
lowered = node.class_type.lower()
|
|
markers = (
|
|
"gemini",
|
|
"openai",
|
|
"chatgpt",
|
|
"claude",
|
|
"llm",
|
|
"vlm",
|
|
"language_model",
|
|
"image caption",
|
|
"caption",
|
|
"prompt generator",
|
|
"promptgenerator",
|
|
"tagger",
|
|
)
|
|
return any(marker in lowered for marker in markers)
|
|
|
|
def record_unresolved(
|
|
self,
|
|
node: NodeRecord,
|
|
field: str,
|
|
role: str,
|
|
reason: str,
|
|
**extra: Any,
|
|
) -> None:
|
|
entry: dict[str, Any] = {
|
|
"node_id": node.node_id,
|
|
"class_type": node.class_type,
|
|
"field": field,
|
|
"role": role,
|
|
"reason": reason,
|
|
}
|
|
entry.update(extra)
|
|
if self.unresolved is not None:
|
|
self.unresolved.append(entry)
|
|
self.trace.add(
|
|
f"{role.upper()}_TRACE",
|
|
(
|
|
f"UNRESOLVED {node.class_type} {node.node_id} "
|
|
f"field={field} role={role} reason={reason}"
|
|
),
|
|
)
|