synkro 0.4.36__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of synkro might be problematic. Click here for more details.
- synkro/__init__.py +331 -0
- synkro/advanced.py +184 -0
- synkro/cli.py +156 -0
- synkro/core/__init__.py +7 -0
- synkro/core/checkpoint.py +250 -0
- synkro/core/dataset.py +432 -0
- synkro/core/policy.py +337 -0
- synkro/errors.py +178 -0
- synkro/examples/__init__.py +148 -0
- synkro/factory.py +291 -0
- synkro/formatters/__init__.py +18 -0
- synkro/formatters/chatml.py +121 -0
- synkro/formatters/langfuse.py +98 -0
- synkro/formatters/langsmith.py +98 -0
- synkro/formatters/qa.py +112 -0
- synkro/formatters/sft.py +90 -0
- synkro/formatters/tool_call.py +127 -0
- synkro/generation/__init__.py +9 -0
- synkro/generation/follow_ups.py +134 -0
- synkro/generation/generator.py +314 -0
- synkro/generation/golden_responses.py +269 -0
- synkro/generation/golden_scenarios.py +333 -0
- synkro/generation/golden_tool_responses.py +791 -0
- synkro/generation/logic_extractor.py +126 -0
- synkro/generation/multiturn_responses.py +177 -0
- synkro/generation/planner.py +131 -0
- synkro/generation/responses.py +189 -0
- synkro/generation/scenarios.py +90 -0
- synkro/generation/tool_responses.py +625 -0
- synkro/generation/tool_simulator.py +114 -0
- synkro/interactive/__init__.py +16 -0
- synkro/interactive/hitl_session.py +205 -0
- synkro/interactive/intent_classifier.py +94 -0
- synkro/interactive/logic_map_editor.py +176 -0
- synkro/interactive/rich_ui.py +459 -0
- synkro/interactive/scenario_editor.py +198 -0
- synkro/llm/__init__.py +7 -0
- synkro/llm/client.py +309 -0
- synkro/llm/rate_limits.py +99 -0
- synkro/models/__init__.py +50 -0
- synkro/models/anthropic.py +26 -0
- synkro/models/google.py +19 -0
- synkro/models/local.py +104 -0
- synkro/models/openai.py +31 -0
- synkro/modes/__init__.py +13 -0
- synkro/modes/config.py +66 -0
- synkro/modes/conversation.py +35 -0
- synkro/modes/tool_call.py +18 -0
- synkro/parsers.py +442 -0
- synkro/pipeline/__init__.py +20 -0
- synkro/pipeline/phases.py +592 -0
- synkro/pipeline/runner.py +769 -0
- synkro/pipelines.py +136 -0
- synkro/prompts/__init__.py +57 -0
- synkro/prompts/base.py +167 -0
- synkro/prompts/golden_templates.py +533 -0
- synkro/prompts/interactive_templates.py +198 -0
- synkro/prompts/multiturn_templates.py +156 -0
- synkro/prompts/templates.py +281 -0
- synkro/prompts/tool_templates.py +318 -0
- synkro/quality/__init__.py +14 -0
- synkro/quality/golden_refiner.py +163 -0
- synkro/quality/grader.py +153 -0
- synkro/quality/multiturn_grader.py +150 -0
- synkro/quality/refiner.py +137 -0
- synkro/quality/tool_grader.py +126 -0
- synkro/quality/tool_refiner.py +128 -0
- synkro/quality/verifier.py +228 -0
- synkro/reporting.py +464 -0
- synkro/schemas.py +521 -0
- synkro/types/__init__.py +43 -0
- synkro/types/core.py +153 -0
- synkro/types/dataset_type.py +33 -0
- synkro/types/logic_map.py +348 -0
- synkro/types/tool.py +94 -0
- synkro-0.4.36.data/data/examples/__init__.py +148 -0
- synkro-0.4.36.dist-info/METADATA +507 -0
- synkro-0.4.36.dist-info/RECORD +81 -0
- synkro-0.4.36.dist-info/WHEEL +4 -0
- synkro-0.4.36.dist-info/entry_points.txt +2 -0
- synkro-0.4.36.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Tool response simulator for training data generation."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import uuid
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from synkro.prompts.tool_templates import TOOL_SIMULATION_PROMPT
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from synkro.llm.client import LLM
|
|
11
|
+
from synkro.types.tool import ToolDefinition, ToolCall
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ToolSimulator:
|
|
15
|
+
"""
|
|
16
|
+
Simulates tool responses for training data generation.
|
|
17
|
+
|
|
18
|
+
Uses an LLM to generate realistic, contextual tool responses
|
|
19
|
+
based on tool definitions and call arguments.
|
|
20
|
+
|
|
21
|
+
Example:
|
|
22
|
+
>>> from synkro.types.tool import ToolDefinition, ToolCall, ToolFunction
|
|
23
|
+
>>> simulator = ToolSimulator(tools=[web_search_tool], llm=llm)
|
|
24
|
+
>>> call = ToolCall(
|
|
25
|
+
... id="call_1",
|
|
26
|
+
... function=ToolFunction(name="web_search", arguments='{"query": "weather NYC"}')
|
|
27
|
+
... )
|
|
28
|
+
>>> response = await simulator.simulate(call)
|
|
29
|
+
>>> print(response)
|
|
30
|
+
"NYC: 72°F, sunny with a high of 75°F expected"
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, tools: list["ToolDefinition"], llm: "LLM"):
|
|
34
|
+
"""
|
|
35
|
+
Initialize the simulator.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
tools: List of available tool definitions
|
|
39
|
+
llm: LLM client for generating responses
|
|
40
|
+
"""
|
|
41
|
+
self.tools = {t.name: t for t in tools}
|
|
42
|
+
self.llm = llm
|
|
43
|
+
|
|
44
|
+
async def simulate(self, tool_call: "ToolCall") -> str:
|
|
45
|
+
"""
|
|
46
|
+
Simulate a tool response for the given call.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
tool_call: The tool call to simulate
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Simulated tool response content
|
|
53
|
+
"""
|
|
54
|
+
tool_name = tool_call.function.name
|
|
55
|
+
|
|
56
|
+
if tool_name not in self.tools:
|
|
57
|
+
return json.dumps({"error": f"Unknown tool: {tool_name}"})
|
|
58
|
+
|
|
59
|
+
tool = self.tools[tool_name]
|
|
60
|
+
|
|
61
|
+
# Format mock responses for the prompt
|
|
62
|
+
mock_responses = "\n".join(
|
|
63
|
+
f"- {r}" for r in tool.mock_responses
|
|
64
|
+
) if tool.mock_responses else "No example responses provided"
|
|
65
|
+
|
|
66
|
+
prompt = TOOL_SIMULATION_PROMPT.format(
|
|
67
|
+
TOOL_NAME=tool.name,
|
|
68
|
+
TOOL_DESCRIPTION=tool.description,
|
|
69
|
+
TOOL_PARAMETERS=json.dumps(tool.parameters, indent=2),
|
|
70
|
+
ARGUMENTS=tool_call.function.arguments,
|
|
71
|
+
MOCK_RESPONSES=mock_responses,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
response = await self.llm.generate(prompt)
|
|
75
|
+
return response.strip()
|
|
76
|
+
|
|
77
|
+
async def simulate_batch(self, tool_calls: list["ToolCall"]) -> list[str]:
|
|
78
|
+
"""
|
|
79
|
+
Simulate responses for multiple tool calls.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
tool_calls: List of tool calls to simulate
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
List of simulated responses in order
|
|
86
|
+
"""
|
|
87
|
+
import asyncio
|
|
88
|
+
return await asyncio.gather(*[self.simulate(tc) for tc in tool_calls])
|
|
89
|
+
|
|
90
|
+
def generate_call_id(self) -> str:
|
|
91
|
+
"""Generate a unique tool call ID."""
|
|
92
|
+
return f"call_{uuid.uuid4().hex[:12]}"
|
|
93
|
+
|
|
94
|
+
def get_tools_description(self) -> str:
|
|
95
|
+
"""
|
|
96
|
+
Get a formatted description of all available tools.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Formatted string describing all tools
|
|
100
|
+
"""
|
|
101
|
+
descriptions = []
|
|
102
|
+
for tool in self.tools.values():
|
|
103
|
+
descriptions.append(tool.to_system_prompt())
|
|
104
|
+
return "\n\n".join(descriptions)
|
|
105
|
+
|
|
106
|
+
def get_tools_json(self) -> list[dict]:
|
|
107
|
+
"""
|
|
108
|
+
Get tools in OpenAI function format.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
List of tool definitions in OpenAI format
|
|
112
|
+
"""
|
|
113
|
+
return [tool.to_openai_format() for tool in self.tools.values()]
|
|
114
|
+
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Interactive Human-in-the-Loop components for Logic Map and Scenario editing."""
|
|
2
|
+
|
|
3
|
+
from synkro.interactive.logic_map_editor import LogicMapEditor
|
|
4
|
+
from synkro.interactive.scenario_editor import ScenarioEditor
|
|
5
|
+
from synkro.interactive.hitl_session import HITLSession
|
|
6
|
+
from synkro.interactive.rich_ui import LogicMapDisplay, InteractivePrompt
|
|
7
|
+
from synkro.interactive.intent_classifier import HITLIntentClassifier
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"LogicMapEditor",
|
|
11
|
+
"ScenarioEditor",
|
|
12
|
+
"HITLSession",
|
|
13
|
+
"LogicMapDisplay",
|
|
14
|
+
"InteractivePrompt",
|
|
15
|
+
"HITLIntentClassifier",
|
|
16
|
+
]
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Human-in-the-Loop session state management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from synkro.types.logic_map import LogicMap, GoldenScenario
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class FeedbackEntry:
|
|
14
|
+
"""Single feedback entry in conversation history."""
|
|
15
|
+
|
|
16
|
+
user_feedback: str
|
|
17
|
+
intent_type: str # "turns", "rules", "scenarios"
|
|
18
|
+
action_summary: str # "Added R015: No smoking policy"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class HITLSession:
|
|
23
|
+
"""
|
|
24
|
+
Tracks state of an interactive Logic Map and Scenario editing session.
|
|
25
|
+
|
|
26
|
+
Supports undo/reset operations and maintains edit history for both
|
|
27
|
+
Logic Map changes and scenario changes.
|
|
28
|
+
|
|
29
|
+
Example:
|
|
30
|
+
>>> session = HITLSession(original_logic_map=logic_map)
|
|
31
|
+
>>> session.apply_change("Added rule R009", new_logic_map)
|
|
32
|
+
>>> session.set_scenarios(scenarios, distribution)
|
|
33
|
+
>>> session.apply_scenario_change("Added S21", new_scenarios, new_distribution)
|
|
34
|
+
>>> session.undo() # Reverts last change (rule or scenario)
|
|
35
|
+
>>> session.reset() # Reverts to original
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
original_logic_map: "LogicMap"
|
|
39
|
+
current_logic_map: "LogicMap" = field(init=False)
|
|
40
|
+
history: list[tuple[str, "LogicMap"]] = field(default_factory=list)
|
|
41
|
+
|
|
42
|
+
# Scenario tracking
|
|
43
|
+
original_scenarios: list["GoldenScenario"] | None = field(default=None)
|
|
44
|
+
current_scenarios: list["GoldenScenario"] | None = field(default=None)
|
|
45
|
+
original_distribution: dict[str, int] | None = field(default=None)
|
|
46
|
+
current_distribution: dict[str, int] | None = field(default=None)
|
|
47
|
+
scenario_history: list[tuple[str, list["GoldenScenario"], dict[str, int]]] = field(
|
|
48
|
+
default_factory=list
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Conversation history for context-aware feedback
|
|
52
|
+
conversation_history: list[FeedbackEntry] = field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
def __post_init__(self) -> None:
|
|
55
|
+
"""Initialize current_logic_map from original."""
|
|
56
|
+
self.current_logic_map = self.original_logic_map
|
|
57
|
+
|
|
58
|
+
def set_scenarios(
|
|
59
|
+
self,
|
|
60
|
+
scenarios: list["GoldenScenario"],
|
|
61
|
+
distribution: dict[str, int],
|
|
62
|
+
) -> None:
|
|
63
|
+
"""
|
|
64
|
+
Initialize scenarios after they're generated.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
scenarios: List of generated golden scenarios
|
|
68
|
+
distribution: Type distribution dict
|
|
69
|
+
"""
|
|
70
|
+
self.original_scenarios = scenarios
|
|
71
|
+
self.current_scenarios = scenarios
|
|
72
|
+
self.original_distribution = distribution
|
|
73
|
+
self.current_distribution = distribution
|
|
74
|
+
|
|
75
|
+
def apply_change(self, feedback: str, new_map: "LogicMap") -> None:
|
|
76
|
+
"""
|
|
77
|
+
Record a Logic Map change in history and update current state.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
feedback: The user feedback that triggered this change
|
|
81
|
+
new_map: The new Logic Map after applying the change
|
|
82
|
+
"""
|
|
83
|
+
self.history.append((feedback, self.current_logic_map))
|
|
84
|
+
self.current_logic_map = new_map
|
|
85
|
+
|
|
86
|
+
def apply_scenario_change(
|
|
87
|
+
self,
|
|
88
|
+
feedback: str,
|
|
89
|
+
new_scenarios: list["GoldenScenario"],
|
|
90
|
+
new_distribution: dict[str, int],
|
|
91
|
+
) -> None:
|
|
92
|
+
"""
|
|
93
|
+
Record a scenario change in history and update current state.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
feedback: The user feedback that triggered this change
|
|
97
|
+
new_scenarios: The updated scenario list
|
|
98
|
+
new_distribution: The updated distribution dict
|
|
99
|
+
"""
|
|
100
|
+
if self.current_scenarios is not None and self.current_distribution is not None:
|
|
101
|
+
self.scenario_history.append(
|
|
102
|
+
(feedback, self.current_scenarios, self.current_distribution)
|
|
103
|
+
)
|
|
104
|
+
self.current_scenarios = new_scenarios
|
|
105
|
+
self.current_distribution = new_distribution
|
|
106
|
+
|
|
107
|
+
def undo(self) -> tuple["LogicMap | None", list["GoldenScenario"] | None, dict[str, int] | None]:
|
|
108
|
+
"""
|
|
109
|
+
Undo the last change (either rule or scenario).
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Tuple of (logic_map or None, scenarios or None, distribution or None)
|
|
113
|
+
indicating which was restored
|
|
114
|
+
"""
|
|
115
|
+
# Check which has the most recent change
|
|
116
|
+
rule_has_history = len(self.history) > 0
|
|
117
|
+
scenario_has_history = len(self.scenario_history) > 0
|
|
118
|
+
|
|
119
|
+
if not rule_has_history and not scenario_has_history:
|
|
120
|
+
return None, None, None
|
|
121
|
+
|
|
122
|
+
# For simplicity, undo the most recent change of either type
|
|
123
|
+
# In practice, we could track a unified history with timestamps
|
|
124
|
+
if scenario_has_history:
|
|
125
|
+
_, prev_scenarios, prev_distribution = self.scenario_history.pop()
|
|
126
|
+
self.current_scenarios = prev_scenarios
|
|
127
|
+
self.current_distribution = prev_distribution
|
|
128
|
+
return None, prev_scenarios, prev_distribution
|
|
129
|
+
|
|
130
|
+
if rule_has_history:
|
|
131
|
+
_, previous_map = self.history.pop()
|
|
132
|
+
self.current_logic_map = previous_map
|
|
133
|
+
return previous_map, None, None
|
|
134
|
+
|
|
135
|
+
return None, None, None
|
|
136
|
+
|
|
137
|
+
def reset(self) -> tuple["LogicMap", list["GoldenScenario"] | None, dict[str, int] | None]:
|
|
138
|
+
"""
|
|
139
|
+
Reset to the original state, clearing all history.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Tuple of (original LogicMap, original scenarios, original distribution)
|
|
143
|
+
"""
|
|
144
|
+
self.history.clear()
|
|
145
|
+
self.scenario_history.clear()
|
|
146
|
+
self.conversation_history.clear()
|
|
147
|
+
self.current_logic_map = self.original_logic_map
|
|
148
|
+
self.current_scenarios = self.original_scenarios
|
|
149
|
+
self.current_distribution = self.original_distribution
|
|
150
|
+
return self.original_logic_map, self.original_scenarios, self.original_distribution
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def can_undo(self) -> bool:
|
|
154
|
+
"""Check if undo is available."""
|
|
155
|
+
return len(self.history) > 0 or len(self.scenario_history) > 0
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def change_count(self) -> int:
|
|
159
|
+
"""Number of changes made in this session (rules + scenarios)."""
|
|
160
|
+
return len(self.history) + len(self.scenario_history)
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def rule_change_count(self) -> int:
|
|
164
|
+
"""Number of rule changes made in this session."""
|
|
165
|
+
return len(self.history)
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def scenario_change_count(self) -> int:
|
|
169
|
+
"""Number of scenario changes made in this session."""
|
|
170
|
+
return len(self.scenario_history)
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def has_scenarios(self) -> bool:
|
|
174
|
+
"""Check if scenarios have been initialized."""
|
|
175
|
+
return self.current_scenarios is not None
|
|
176
|
+
|
|
177
|
+
def record_feedback(self, feedback: str, intent_type: str, summary: str) -> None:
|
|
178
|
+
"""
|
|
179
|
+
Record feedback for conversation context.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
feedback: The user's original feedback text
|
|
183
|
+
intent_type: Type of intent ("turns", "rules", "scenarios")
|
|
184
|
+
summary: Summary of what action was taken
|
|
185
|
+
"""
|
|
186
|
+
self.conversation_history.append(FeedbackEntry(feedback, intent_type, summary))
|
|
187
|
+
|
|
188
|
+
def get_history_for_prompt(self, max_entries: int = 5) -> str:
|
|
189
|
+
"""
|
|
190
|
+
Format recent history for LLM prompts.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
max_entries: Maximum number of entries to include
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
Formatted string of recent feedback history
|
|
197
|
+
"""
|
|
198
|
+
if not self.conversation_history:
|
|
199
|
+
return "No previous feedback in this session."
|
|
200
|
+
|
|
201
|
+
lines = []
|
|
202
|
+
for i, entry in enumerate(self.conversation_history[-max_entries:], 1):
|
|
203
|
+
lines.append(f"{i}. User: \"{entry.user_feedback}\"")
|
|
204
|
+
lines.append(f" Result: {entry.action_summary}")
|
|
205
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""HITL Intent Classifier - LLM-powered classification of user feedback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from synkro.llm.client import LLM
|
|
6
|
+
from synkro.models import Model, OpenAI
|
|
7
|
+
from synkro.schemas import HITLIntent
|
|
8
|
+
from synkro.prompts.interactive_templates import HITL_INTENT_CLASSIFIER_PROMPT
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class HITLIntentClassifier:
|
|
12
|
+
"""
|
|
13
|
+
LLM-powered classifier for user feedback in unified HITL sessions.
|
|
14
|
+
|
|
15
|
+
Classifies user input into one of five intent types:
|
|
16
|
+
- "turns": User wants to adjust conversation turns
|
|
17
|
+
- "rules": User wants to modify the Logic Map
|
|
18
|
+
- "scenarios": User wants to add/delete/modify scenarios or adjust distribution
|
|
19
|
+
- "command": User typed a built-in command
|
|
20
|
+
- "unclear": Cannot determine intent
|
|
21
|
+
|
|
22
|
+
Examples:
|
|
23
|
+
>>> classifier = HITLIntentClassifier(llm=LLM(model=OpenAI.GPT_4O))
|
|
24
|
+
>>> intent = await classifier.classify(
|
|
25
|
+
... user_input="I want shorter conversations",
|
|
26
|
+
... current_turns=3,
|
|
27
|
+
... complexity_level="conditional",
|
|
28
|
+
... rule_count=10,
|
|
29
|
+
... scenario_count=20
|
|
30
|
+
... )
|
|
31
|
+
>>> intent.intent_type
|
|
32
|
+
'turns'
|
|
33
|
+
>>> intent.target_turns
|
|
34
|
+
2
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
llm: LLM | None = None,
|
|
40
|
+
model: Model = OpenAI.GPT_4O,
|
|
41
|
+
):
|
|
42
|
+
"""
|
|
43
|
+
Initialize the HITL Intent Classifier.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
llm: LLM client to use (creates one if not provided)
|
|
47
|
+
model: Model to use if creating LLM (default: GPT-4O for accuracy)
|
|
48
|
+
"""
|
|
49
|
+
self.llm = llm or LLM(model=model, temperature=0.2)
|
|
50
|
+
|
|
51
|
+
async def classify(
|
|
52
|
+
self,
|
|
53
|
+
user_input: str,
|
|
54
|
+
current_turns: int,
|
|
55
|
+
complexity_level: str,
|
|
56
|
+
rule_count: int,
|
|
57
|
+
scenario_count: int = 0,
|
|
58
|
+
conversation_history: str = "No previous feedback in this session.",
|
|
59
|
+
) -> HITLIntent:
|
|
60
|
+
"""
|
|
61
|
+
Classify user input and extract structured intent.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
user_input: The user's natural language feedback
|
|
65
|
+
current_turns: Current conversation turns setting
|
|
66
|
+
complexity_level: Policy complexity level (simple/conditional/complex)
|
|
67
|
+
rule_count: Number of rules in the Logic Map
|
|
68
|
+
scenario_count: Number of scenarios generated
|
|
69
|
+
conversation_history: Formatted history of previous feedback in this session
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
HITLIntent with classified intent_type and relevant fields populated
|
|
73
|
+
"""
|
|
74
|
+
prompt = HITL_INTENT_CLASSIFIER_PROMPT.format(
|
|
75
|
+
user_input=user_input,
|
|
76
|
+
current_turns=current_turns,
|
|
77
|
+
complexity_level=complexity_level,
|
|
78
|
+
rule_count=rule_count,
|
|
79
|
+
scenario_count=scenario_count,
|
|
80
|
+
conversation_history=conversation_history,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
return await self.llm.generate_structured(prompt, HITLIntent)
|
|
85
|
+
except Exception:
|
|
86
|
+
# Default to treating as rule feedback (preserves existing behavior)
|
|
87
|
+
return HITLIntent(
|
|
88
|
+
intent_type="rules",
|
|
89
|
+
confidence=0.5,
|
|
90
|
+
rule_feedback=user_input,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
__all__ = ["HITLIntentClassifier"]
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Logic Map Editor - LLM-powered interactive refinement of Logic Maps."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from synkro.llm.client import LLM
|
|
8
|
+
from synkro.models import Model, OpenAI
|
|
9
|
+
from synkro.schemas import RefinedLogicMapOutput
|
|
10
|
+
from synkro.types.logic_map import LogicMap, Rule, RuleCategory
|
|
11
|
+
from synkro.prompts.interactive_templates import LOGIC_MAP_REFINEMENT_PROMPT
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LogicMapEditor:
|
|
18
|
+
"""
|
|
19
|
+
LLM-powered Logic Map editor that interprets natural language feedback.
|
|
20
|
+
|
|
21
|
+
The editor takes user feedback in natural language (e.g., "add a rule for...",
|
|
22
|
+
"remove R005", "merge R002 and R003") and uses an LLM to interpret and apply
|
|
23
|
+
the changes to the Logic Map.
|
|
24
|
+
|
|
25
|
+
Examples:
|
|
26
|
+
>>> editor = LogicMapEditor(llm=LLM(model=OpenAI.GPT_4O))
|
|
27
|
+
>>> new_logic_map = await editor.refine(
|
|
28
|
+
... logic_map=current_map,
|
|
29
|
+
... user_feedback="Add a rule for overtime approval",
|
|
30
|
+
... policy_text=policy.text
|
|
31
|
+
... )
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
llm: LLM | None = None,
|
|
37
|
+
model: Model = OpenAI.GPT_4O,
|
|
38
|
+
):
|
|
39
|
+
"""
|
|
40
|
+
Initialize the Logic Map Editor.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
llm: LLM client to use (creates one if not provided)
|
|
44
|
+
model: Model to use if creating LLM (default: GPT-4O for accuracy)
|
|
45
|
+
"""
|
|
46
|
+
self.llm = llm or LLM(model=model, temperature=0.3)
|
|
47
|
+
|
|
48
|
+
async def refine(
|
|
49
|
+
self,
|
|
50
|
+
logic_map: LogicMap,
|
|
51
|
+
user_feedback: str,
|
|
52
|
+
policy_text: str,
|
|
53
|
+
conversation_history: str = "No previous feedback in this session.",
|
|
54
|
+
) -> tuple[LogicMap, str]:
|
|
55
|
+
"""
|
|
56
|
+
Refine the Logic Map based on natural language feedback.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
logic_map: Current Logic Map to refine
|
|
60
|
+
user_feedback: Natural language instruction from user
|
|
61
|
+
policy_text: Original policy text for context
|
|
62
|
+
conversation_history: Formatted history of previous feedback in this session
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Tuple of (refined LogicMap, changes summary string)
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
ValueError: If refinement produces invalid DAG
|
|
69
|
+
"""
|
|
70
|
+
# Format current Logic Map as string for prompt
|
|
71
|
+
current_map_str = self._format_logic_map_for_prompt(logic_map)
|
|
72
|
+
|
|
73
|
+
# Format the prompt
|
|
74
|
+
prompt = LOGIC_MAP_REFINEMENT_PROMPT.format(
|
|
75
|
+
current_logic_map=current_map_str,
|
|
76
|
+
policy_text=policy_text,
|
|
77
|
+
user_feedback=user_feedback,
|
|
78
|
+
conversation_history=conversation_history,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Generate structured output
|
|
82
|
+
result = await self.llm.generate_structured(prompt, RefinedLogicMapOutput)
|
|
83
|
+
|
|
84
|
+
# Convert to domain model
|
|
85
|
+
refined_map = self._convert_to_logic_map(result)
|
|
86
|
+
|
|
87
|
+
# Validate DAG properties
|
|
88
|
+
if not refined_map.validate_dag():
|
|
89
|
+
raise ValueError(
|
|
90
|
+
"Refined Logic Map contains circular dependencies. "
|
|
91
|
+
"Please try a different modification."
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
return refined_map, result.changes_summary
|
|
95
|
+
|
|
96
|
+
def _format_logic_map_for_prompt(self, logic_map: LogicMap) -> str:
|
|
97
|
+
"""Format a Logic Map as a string for the LLM prompt."""
|
|
98
|
+
lines = []
|
|
99
|
+
lines.append(f"Total Rules: {len(logic_map.rules)}")
|
|
100
|
+
lines.append(f"Root Rules: {', '.join(logic_map.root_rules)}")
|
|
101
|
+
lines.append("")
|
|
102
|
+
lines.append("Rules:")
|
|
103
|
+
|
|
104
|
+
for rule in logic_map.rules:
|
|
105
|
+
deps = f" -> {', '.join(rule.dependencies)}" if rule.dependencies else ""
|
|
106
|
+
lines.append(f" {rule.rule_id}: {rule.text}")
|
|
107
|
+
lines.append(f" Category: {rule.category.value}")
|
|
108
|
+
lines.append(f" Condition: {rule.condition}")
|
|
109
|
+
lines.append(f" Action: {rule.action}")
|
|
110
|
+
if deps:
|
|
111
|
+
lines.append(f" Dependencies: {', '.join(rule.dependencies)}")
|
|
112
|
+
lines.append("")
|
|
113
|
+
|
|
114
|
+
return "\n".join(lines)
|
|
115
|
+
|
|
116
|
+
def _convert_to_logic_map(self, output: RefinedLogicMapOutput) -> LogicMap:
|
|
117
|
+
"""Convert schema output to domain model."""
|
|
118
|
+
rules = []
|
|
119
|
+
for rule_out in output.rules:
|
|
120
|
+
# Convert category string to enum
|
|
121
|
+
category = RuleCategory(rule_out.category)
|
|
122
|
+
|
|
123
|
+
rule = Rule(
|
|
124
|
+
rule_id=rule_out.rule_id,
|
|
125
|
+
text=rule_out.text,
|
|
126
|
+
condition=rule_out.condition,
|
|
127
|
+
action=rule_out.action,
|
|
128
|
+
dependencies=rule_out.dependencies,
|
|
129
|
+
category=category,
|
|
130
|
+
)
|
|
131
|
+
rules.append(rule)
|
|
132
|
+
|
|
133
|
+
return LogicMap(
|
|
134
|
+
rules=rules,
|
|
135
|
+
root_rules=output.root_rules,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def validate_refinement(
|
|
139
|
+
self,
|
|
140
|
+
original: LogicMap,
|
|
141
|
+
refined: LogicMap,
|
|
142
|
+
) -> tuple[bool, list[str]]:
|
|
143
|
+
"""
|
|
144
|
+
Validate that refinement maintains DAG properties and is sensible.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
original: Original Logic Map
|
|
148
|
+
refined: Refined Logic Map
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Tuple of (is_valid, list of issue descriptions)
|
|
152
|
+
"""
|
|
153
|
+
issues = []
|
|
154
|
+
|
|
155
|
+
# Check DAG validity
|
|
156
|
+
if not refined.validate_dag():
|
|
157
|
+
issues.append("Refined Logic Map has circular dependencies")
|
|
158
|
+
|
|
159
|
+
# Check that all dependencies reference existing rules
|
|
160
|
+
rule_ids = {r.rule_id for r in refined.rules}
|
|
161
|
+
for rule in refined.rules:
|
|
162
|
+
for dep in rule.dependencies:
|
|
163
|
+
if dep not in rule_ids:
|
|
164
|
+
issues.append(f"Rule {rule.rule_id} depends on non-existent rule {dep}")
|
|
165
|
+
|
|
166
|
+
# Check root_rules consistency
|
|
167
|
+
for root_id in refined.root_rules:
|
|
168
|
+
if root_id not in rule_ids:
|
|
169
|
+
issues.append(f"Root rule {root_id} does not exist")
|
|
170
|
+
|
|
171
|
+
# Check that rules with no dependencies are in root_rules
|
|
172
|
+
for rule in refined.rules:
|
|
173
|
+
if not rule.dependencies and rule.rule_id not in refined.root_rules:
|
|
174
|
+
issues.append(f"Rule {rule.rule_id} has no dependencies but is not in root_rules")
|
|
175
|
+
|
|
176
|
+
return len(issues) == 0, issues
|