difficult-dialogs 0.5.1a1__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.
- difficult_dialogs/__init__.py +62 -0
- difficult_dialogs/arguments.py +344 -0
- difficult_dialogs/cli.py +476 -0
- difficult_dialogs/exceptions.py +35 -0
- difficult_dialogs/export/__init__.py +25 -0
- difficult_dialogs/export/json.py +168 -0
- difficult_dialogs/export/markdown.py +81 -0
- difficult_dialogs/export/sqlite.py +388 -0
- difficult_dialogs/llm/__init__.py +6 -0
- difficult_dialogs/llm/client.py +185 -0
- difficult_dialogs/llm/enhancer.py +185 -0
- difficult_dialogs/llm/generator.py +318 -0
- difficult_dialogs/policy.py +975 -0
- difficult_dialogs/premises.py +247 -0
- difficult_dialogs/statements.py +45 -0
- difficult_dialogs/validators.py +404 -0
- difficult_dialogs/version.py +8 -0
- difficult_dialogs-0.5.1a1.dist-info/METADATA +564 -0
- difficult_dialogs-0.5.1a1.dist-info/RECORD +22 -0
- difficult_dialogs-0.5.1a1.dist-info/WHEEL +5 -0
- difficult_dialogs-0.5.1a1.dist-info/entry_points.txt +3 -0
- difficult_dialogs-0.5.1a1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Difficult Dialogs - Structured argumentation framework.
|
|
2
|
+
|
|
3
|
+
Tools to guide conversations towards a certain objective using file-based
|
|
4
|
+
argument definitions and pluggable policy engines.
|
|
5
|
+
"""
|
|
6
|
+
from difficult_dialogs.exceptions import (
|
|
7
|
+
DifficultDialogsError,
|
|
8
|
+
ArgumentLoadError,
|
|
9
|
+
ArgumentSaveError,
|
|
10
|
+
InvalidPolicyError,
|
|
11
|
+
MissingStatementError,
|
|
12
|
+
)
|
|
13
|
+
from difficult_dialogs.statements import Statement
|
|
14
|
+
from difficult_dialogs.premises import Premise
|
|
15
|
+
from difficult_dialogs.arguments import Argument
|
|
16
|
+
from difficult_dialogs.policy import (
|
|
17
|
+
BasePolicy,
|
|
18
|
+
KnowItAllPolicy,
|
|
19
|
+
SilentPolicy,
|
|
20
|
+
SocraticPolicy,
|
|
21
|
+
DebatePolicy,
|
|
22
|
+
ExploratoryPolicy,
|
|
23
|
+
MaieuticPolicy,
|
|
24
|
+
SkepticPolicy,
|
|
25
|
+
TeacherPolicy,
|
|
26
|
+
DebaterPolicy,
|
|
27
|
+
MinimalistPolicy,
|
|
28
|
+
PolicyState,
|
|
29
|
+
POLICY_REGISTRY,
|
|
30
|
+
get_policy,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
from difficult_dialogs.version import __version__
|
|
34
|
+
__all__ = [
|
|
35
|
+
# Exceptions
|
|
36
|
+
"DifficultDialogsError",
|
|
37
|
+
"ArgumentLoadError",
|
|
38
|
+
"ArgumentSaveError",
|
|
39
|
+
"InvalidPolicyError",
|
|
40
|
+
"MissingStatementError",
|
|
41
|
+
# Core data model
|
|
42
|
+
"Statement",
|
|
43
|
+
"Premise",
|
|
44
|
+
"Argument",
|
|
45
|
+
# Policy base + state
|
|
46
|
+
"BasePolicy",
|
|
47
|
+
"PolicyState",
|
|
48
|
+
# Built-in policies
|
|
49
|
+
"KnowItAllPolicy",
|
|
50
|
+
"SilentPolicy",
|
|
51
|
+
"SocraticPolicy",
|
|
52
|
+
"DebatePolicy",
|
|
53
|
+
"ExploratoryPolicy",
|
|
54
|
+
"MaieuticPolicy",
|
|
55
|
+
"SkepticPolicy",
|
|
56
|
+
"TeacherPolicy",
|
|
57
|
+
"DebaterPolicy",
|
|
58
|
+
"MinimalistPolicy",
|
|
59
|
+
# Policy registry
|
|
60
|
+
"POLICY_REGISTRY",
|
|
61
|
+
"get_policy",
|
|
62
|
+
]
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""Argument module - a collection of premises forming a complete argument.
|
|
2
|
+
|
|
3
|
+
An Argument is loaded from a folder structure with plain text files:
|
|
4
|
+
|
|
5
|
+
my_argument/
|
|
6
|
+
├── intro.dialog # Opening statement
|
|
7
|
+
├── conclusion.conclusion # Final statement
|
|
8
|
+
├── premise_name/
|
|
9
|
+
│ ├── premise_name.premise # Supporting statements (one per line)
|
|
10
|
+
│ ├── premise_name.support # Fallback arguments when user disagrees
|
|
11
|
+
│ ├── premise_name.source # Evidence URLs
|
|
12
|
+
│ ├── premise_name.what # What it means
|
|
13
|
+
│ ├── premise_name.why # Why it is true
|
|
14
|
+
│ ├── premise_name.how # How it works
|
|
15
|
+
│ ├── premise_name.when # When it applies
|
|
16
|
+
│ └── premise_name.where # Where it is observed
|
|
17
|
+
└── another_premise/
|
|
18
|
+
└── ...
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from difficult_dialogs.exceptions import ArgumentLoadError, ArgumentSaveError
|
|
27
|
+
from difficult_dialogs.premises import Premise
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Argument:
|
|
32
|
+
"""An argument composed of multiple premises.
|
|
33
|
+
|
|
34
|
+
An argument represents a complete dialog flow. It is considered True
|
|
35
|
+
when all its premises have been successfully presented and agreed with.
|
|
36
|
+
|
|
37
|
+
Attributes:
|
|
38
|
+
name: Identifier for this argument.
|
|
39
|
+
intro: Opening statement text.
|
|
40
|
+
conclusion: Closing statement text.
|
|
41
|
+
path: Optional path to load argument from.
|
|
42
|
+
"""
|
|
43
|
+
name: str = ""
|
|
44
|
+
intro: str = ""
|
|
45
|
+
conclusion: str = ""
|
|
46
|
+
path: Path | None = field(default=None, init=False)
|
|
47
|
+
_premises: dict[str, Premise] = field(default_factory=dict, repr=False)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def premises(self) -> list[Premise]:
|
|
51
|
+
"""Return list of all premises in this argument."""
|
|
52
|
+
return list(self._premises.values())
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def premise_names(self) -> list[str]:
|
|
56
|
+
"""Return list of premise names."""
|
|
57
|
+
return list(self._premises.keys())
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def is_true(self) -> bool:
|
|
61
|
+
"""Return True if all premises are agreed with."""
|
|
62
|
+
return all(p.is_true for p in self.premises) if self.premises else True
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def is_complete(self) -> bool:
|
|
66
|
+
"""Return True if argument has at least one premise."""
|
|
67
|
+
return len(self.premises) > 0
|
|
68
|
+
|
|
69
|
+
def add_premise(self, premise: Premise) -> Argument:
|
|
70
|
+
"""Add a premise to this argument.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
premise: The premise to add.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Self for method chaining.
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
ValueError: If premise name is empty.
|
|
80
|
+
"""
|
|
81
|
+
if not premise.name:
|
|
82
|
+
raise ValueError("Premise must have a name")
|
|
83
|
+
|
|
84
|
+
self._premises[premise.name] = premise
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
def get_premise(self, name: str) -> Premise | None:
|
|
88
|
+
"""Get a premise by name.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
name: The premise name.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
The premise, or None if not found.
|
|
95
|
+
"""
|
|
96
|
+
return self._premises.get(name)
|
|
97
|
+
|
|
98
|
+
def get_next_premise(self, cache: set[str]) -> Premise | None:
|
|
99
|
+
"""Get the next unspoken premise.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
cache: Set of already spoken premise names.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Next premise to present, or None if all spoken.
|
|
106
|
+
"""
|
|
107
|
+
for name, premise in self._premises.items():
|
|
108
|
+
if name not in cache and premise.is_complete:
|
|
109
|
+
return premise
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
def load(self, path: str | Path) -> Argument:
|
|
113
|
+
"""Load argument from a directory structure.
|
|
114
|
+
|
|
115
|
+
Expected layout::
|
|
116
|
+
|
|
117
|
+
path/
|
|
118
|
+
├── intro.dialog
|
|
119
|
+
├── conclusion.conclusion
|
|
120
|
+
└── premise_name/
|
|
121
|
+
├── premise_name.premise
|
|
122
|
+
├── premise_name.support (optional)
|
|
123
|
+
├── premise_name.source (optional)
|
|
124
|
+
├── premise_name.what (optional)
|
|
125
|
+
├── premise_name.why (optional)
|
|
126
|
+
├── premise_name.how (optional)
|
|
127
|
+
├── premise_name.when (optional)
|
|
128
|
+
└── premise_name.where (optional)
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
path: Path to the argument directory.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Self for method chaining.
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
ArgumentLoadError: If path doesn't exist or is not a directory.
|
|
138
|
+
"""
|
|
139
|
+
path = Path(path)
|
|
140
|
+
|
|
141
|
+
if not path.exists():
|
|
142
|
+
raise ArgumentLoadError(f"Argument path does not exist: {path}")
|
|
143
|
+
|
|
144
|
+
if not path.is_dir():
|
|
145
|
+
raise ArgumentLoadError(f"Argument path must be a directory: {path}")
|
|
146
|
+
|
|
147
|
+
self.path = path
|
|
148
|
+
|
|
149
|
+
if not self.name:
|
|
150
|
+
self.name = path.name.replace("_", " ")
|
|
151
|
+
|
|
152
|
+
intro_file = path / "intro.dialog"
|
|
153
|
+
if intro_file.exists():
|
|
154
|
+
self.intro = intro_file.read_text().strip()
|
|
155
|
+
|
|
156
|
+
conclusion_file = path / "conclusion.conclusion"
|
|
157
|
+
if conclusion_file.exists():
|
|
158
|
+
self.conclusion = conclusion_file.read_text().strip()
|
|
159
|
+
|
|
160
|
+
for item in path.iterdir():
|
|
161
|
+
if item.is_dir():
|
|
162
|
+
self._load_premise(item)
|
|
163
|
+
|
|
164
|
+
return self
|
|
165
|
+
|
|
166
|
+
@classmethod
|
|
167
|
+
def from_directory(cls, path: str | Path) -> Argument:
|
|
168
|
+
"""Create a new Argument loaded from *path*.
|
|
169
|
+
|
|
170
|
+
Equivalent to ``Argument().load(path)`` but more idiomatic.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
path: Path to the argument directory.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
New Argument instance populated from *path*.
|
|
177
|
+
|
|
178
|
+
Raises:
|
|
179
|
+
ArgumentLoadError: If path doesn't exist or is not a directory.
|
|
180
|
+
"""
|
|
181
|
+
return cls().load(path)
|
|
182
|
+
|
|
183
|
+
def _load_premise(self, premise_dir: Path) -> None:
|
|
184
|
+
"""Load a single premise from a subdirectory.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
premise_dir: Directory containing premise files.
|
|
188
|
+
"""
|
|
189
|
+
premise = Premise(name=premise_dir.name)
|
|
190
|
+
|
|
191
|
+
for file in premise_dir.iterdir():
|
|
192
|
+
if file.is_file():
|
|
193
|
+
premise.apply_file(file)
|
|
194
|
+
|
|
195
|
+
if premise.is_complete:
|
|
196
|
+
self.add_premise(premise)
|
|
197
|
+
|
|
198
|
+
def save(self, path: str | Path | None = None) -> Path:
|
|
199
|
+
"""Write the argument to the plain-text directory format.
|
|
200
|
+
|
|
201
|
+
Creates one subdirectory per premise under *path*, writing each
|
|
202
|
+
populated field to its corresponding file extension. Empty lists
|
|
203
|
+
are skipped so the directory stays clean.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
path: Directory to write into. If ``None``, re-uses
|
|
207
|
+
``self.path`` (i.e. overwrites the directory it was
|
|
208
|
+
loaded from). The directory is created if it does not
|
|
209
|
+
exist.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
The resolved ``Path`` that was written.
|
|
213
|
+
|
|
214
|
+
Raises:
|
|
215
|
+
ValueError: If no path is available (never loaded and none given).
|
|
216
|
+
"""
|
|
217
|
+
dest = Path(path) if path is not None else self.path
|
|
218
|
+
if dest is None:
|
|
219
|
+
raise ArgumentSaveError(
|
|
220
|
+
"No path specified and argument was not loaded from disk. "
|
|
221
|
+
"Pass an explicit path to save()."
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
225
|
+
|
|
226
|
+
if self.intro:
|
|
227
|
+
(dest / "intro.dialog").write_text(self.intro)
|
|
228
|
+
|
|
229
|
+
if self.conclusion:
|
|
230
|
+
(dest / "conclusion.conclusion").write_text(self.conclusion)
|
|
231
|
+
|
|
232
|
+
for premise in self.premises:
|
|
233
|
+
pdir = dest / premise.name
|
|
234
|
+
pdir.mkdir(exist_ok=True)
|
|
235
|
+
|
|
236
|
+
_FIELDS: list[tuple[list[str], str]] = [
|
|
237
|
+
(premise.statements, ".premise"), # Statement objects → text
|
|
238
|
+
(premise.support, ".support"),
|
|
239
|
+
(premise.sources, ".source"),
|
|
240
|
+
(premise.what, ".what"),
|
|
241
|
+
(premise.why, ".why"),
|
|
242
|
+
(premise.how, ".how"),
|
|
243
|
+
(premise.when, ".when"),
|
|
244
|
+
(premise.where, ".where"),
|
|
245
|
+
]
|
|
246
|
+
|
|
247
|
+
for items, ext in _FIELDS:
|
|
248
|
+
if not items:
|
|
249
|
+
continue
|
|
250
|
+
lines = [str(item) for item in items]
|
|
251
|
+
(pdir / f"{premise.name}{ext}").write_text("\n".join(lines))
|
|
252
|
+
|
|
253
|
+
self.path = dest
|
|
254
|
+
return dest
|
|
255
|
+
|
|
256
|
+
def to_dict(self) -> dict[str, Any]:
|
|
257
|
+
"""Convert argument to dictionary representation.
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
Dictionary with argument data.
|
|
261
|
+
"""
|
|
262
|
+
return {
|
|
263
|
+
"name": self.name,
|
|
264
|
+
"intro": self.intro,
|
|
265
|
+
"conclusion": self.conclusion,
|
|
266
|
+
"premises": [p.to_dict() for p in self.premises],
|
|
267
|
+
"is_true": self.is_true,
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
@classmethod
|
|
271
|
+
def from_dict(cls, data: dict[str, Any]) -> Argument:
|
|
272
|
+
"""Create an Argument from a dictionary.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
data: Dictionary with argument data.
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
New Argument instance.
|
|
279
|
+
"""
|
|
280
|
+
arg = cls(
|
|
281
|
+
name=data.get("name", ""),
|
|
282
|
+
intro=data.get("intro", ""),
|
|
283
|
+
conclusion=data.get("conclusion", ""),
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
for premise_data in data.get("premises", []):
|
|
287
|
+
premise = Premise.from_dict(premise_data)
|
|
288
|
+
arg.add_premise(premise)
|
|
289
|
+
|
|
290
|
+
return arg
|
|
291
|
+
|
|
292
|
+
def diff(self, other: Argument) -> dict[str, Any]:
|
|
293
|
+
"""Compare this argument with *other* and return a structured diff.
|
|
294
|
+
|
|
295
|
+
Useful for reviewing LLM-generated updates before committing them.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
other: The argument to compare against (typically the updated version).
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
Dictionary with keys:
|
|
302
|
+
- ``meta``: changes to name/intro/conclusion (field → (old, new)).
|
|
303
|
+
- ``added_premises``: premise names present in *other* but not here.
|
|
304
|
+
- ``removed_premises``: premise names present here but not in *other*.
|
|
305
|
+
- ``modified_premises``: names present in both where statements differ
|
|
306
|
+
(name → {added_statements, removed_statements}).
|
|
307
|
+
"""
|
|
308
|
+
meta: dict[str, tuple[str, str]] = {}
|
|
309
|
+
for field in ("name", "intro", "conclusion"):
|
|
310
|
+
old_val = getattr(self, field)
|
|
311
|
+
new_val = getattr(other, field)
|
|
312
|
+
if old_val != new_val:
|
|
313
|
+
meta[field] = (old_val, new_val)
|
|
314
|
+
|
|
315
|
+
self_names = set(self.premise_names)
|
|
316
|
+
other_names = set(other.premise_names)
|
|
317
|
+
|
|
318
|
+
added_premises = sorted(other_names - self_names)
|
|
319
|
+
removed_premises = sorted(self_names - other_names)
|
|
320
|
+
|
|
321
|
+
modified_premises: dict[str, dict[str, list[str]]] = {}
|
|
322
|
+
for name in self_names & other_names:
|
|
323
|
+
old_stmts = {s.text for s in self._premises[name].statements}
|
|
324
|
+
new_stmts = {s.text for s in other._premises[name].statements}
|
|
325
|
+
if old_stmts != new_stmts:
|
|
326
|
+
modified_premises[name] = {
|
|
327
|
+
"added_statements": sorted(new_stmts - old_stmts),
|
|
328
|
+
"removed_statements": sorted(old_stmts - new_stmts),
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
"meta": meta,
|
|
333
|
+
"added_premises": added_premises,
|
|
334
|
+
"removed_premises": removed_premises,
|
|
335
|
+
"modified_premises": modified_premises,
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
def __bool__(self) -> bool:
|
|
339
|
+
"""Return whether this argument is currently accepted as true."""
|
|
340
|
+
return self.is_true
|
|
341
|
+
|
|
342
|
+
def __str__(self) -> str:
|
|
343
|
+
"""Return the argument name."""
|
|
344
|
+
return self.name
|