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.

Files changed (81) hide show
  1. synkro/__init__.py +331 -0
  2. synkro/advanced.py +184 -0
  3. synkro/cli.py +156 -0
  4. synkro/core/__init__.py +7 -0
  5. synkro/core/checkpoint.py +250 -0
  6. synkro/core/dataset.py +432 -0
  7. synkro/core/policy.py +337 -0
  8. synkro/errors.py +178 -0
  9. synkro/examples/__init__.py +148 -0
  10. synkro/factory.py +291 -0
  11. synkro/formatters/__init__.py +18 -0
  12. synkro/formatters/chatml.py +121 -0
  13. synkro/formatters/langfuse.py +98 -0
  14. synkro/formatters/langsmith.py +98 -0
  15. synkro/formatters/qa.py +112 -0
  16. synkro/formatters/sft.py +90 -0
  17. synkro/formatters/tool_call.py +127 -0
  18. synkro/generation/__init__.py +9 -0
  19. synkro/generation/follow_ups.py +134 -0
  20. synkro/generation/generator.py +314 -0
  21. synkro/generation/golden_responses.py +269 -0
  22. synkro/generation/golden_scenarios.py +333 -0
  23. synkro/generation/golden_tool_responses.py +791 -0
  24. synkro/generation/logic_extractor.py +126 -0
  25. synkro/generation/multiturn_responses.py +177 -0
  26. synkro/generation/planner.py +131 -0
  27. synkro/generation/responses.py +189 -0
  28. synkro/generation/scenarios.py +90 -0
  29. synkro/generation/tool_responses.py +625 -0
  30. synkro/generation/tool_simulator.py +114 -0
  31. synkro/interactive/__init__.py +16 -0
  32. synkro/interactive/hitl_session.py +205 -0
  33. synkro/interactive/intent_classifier.py +94 -0
  34. synkro/interactive/logic_map_editor.py +176 -0
  35. synkro/interactive/rich_ui.py +459 -0
  36. synkro/interactive/scenario_editor.py +198 -0
  37. synkro/llm/__init__.py +7 -0
  38. synkro/llm/client.py +309 -0
  39. synkro/llm/rate_limits.py +99 -0
  40. synkro/models/__init__.py +50 -0
  41. synkro/models/anthropic.py +26 -0
  42. synkro/models/google.py +19 -0
  43. synkro/models/local.py +104 -0
  44. synkro/models/openai.py +31 -0
  45. synkro/modes/__init__.py +13 -0
  46. synkro/modes/config.py +66 -0
  47. synkro/modes/conversation.py +35 -0
  48. synkro/modes/tool_call.py +18 -0
  49. synkro/parsers.py +442 -0
  50. synkro/pipeline/__init__.py +20 -0
  51. synkro/pipeline/phases.py +592 -0
  52. synkro/pipeline/runner.py +769 -0
  53. synkro/pipelines.py +136 -0
  54. synkro/prompts/__init__.py +57 -0
  55. synkro/prompts/base.py +167 -0
  56. synkro/prompts/golden_templates.py +533 -0
  57. synkro/prompts/interactive_templates.py +198 -0
  58. synkro/prompts/multiturn_templates.py +156 -0
  59. synkro/prompts/templates.py +281 -0
  60. synkro/prompts/tool_templates.py +318 -0
  61. synkro/quality/__init__.py +14 -0
  62. synkro/quality/golden_refiner.py +163 -0
  63. synkro/quality/grader.py +153 -0
  64. synkro/quality/multiturn_grader.py +150 -0
  65. synkro/quality/refiner.py +137 -0
  66. synkro/quality/tool_grader.py +126 -0
  67. synkro/quality/tool_refiner.py +128 -0
  68. synkro/quality/verifier.py +228 -0
  69. synkro/reporting.py +464 -0
  70. synkro/schemas.py +521 -0
  71. synkro/types/__init__.py +43 -0
  72. synkro/types/core.py +153 -0
  73. synkro/types/dataset_type.py +33 -0
  74. synkro/types/logic_map.py +348 -0
  75. synkro/types/tool.py +94 -0
  76. synkro-0.4.36.data/data/examples/__init__.py +148 -0
  77. synkro-0.4.36.dist-info/METADATA +507 -0
  78. synkro-0.4.36.dist-info/RECORD +81 -0
  79. synkro-0.4.36.dist-info/WHEEL +4 -0
  80. synkro-0.4.36.dist-info/entry_points.txt +2 -0
  81. 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