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.
@@ -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