eq-layer 0.1.0__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.
eq_layer/dbdc3.py ADDED
@@ -0,0 +1,197 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import io
5
+ import json
6
+ import urllib.request
7
+ import zipfile
8
+ from collections import Counter
9
+ from dataclasses import dataclass
10
+
11
+ DBDC3_URL = "https://dbd-challenge.github.io/dbdc3/data/DBDC3.zip"
12
+ DBDC3_ARCHIVE_SHA256 = "736229795dc3732f8e6bb421f094dc820ef944fef9d9d320d4110ef992b60e85"
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class BreakdownTurn:
17
+ source_file: str
18
+ split: str
19
+ dialogue_id: str
20
+ turn_index: int
21
+ text: str
22
+ context: tuple[str, ...]
23
+ counts: dict[str, int]
24
+
25
+ @property
26
+ def model_text(self) -> str:
27
+ context = "\n".join(self.context)
28
+ if context:
29
+ return f"{context}\n[SYSTEM]\n{self.text}"
30
+ return self.text
31
+
32
+ @property
33
+ def score(self) -> float:
34
+ total = sum(self.counts.values())
35
+ if not total:
36
+ return 0.0
37
+ weighted = (
38
+ self.counts.get("T", 0) * 0.5
39
+ + self.counts.get("X", 0) * 1.0
40
+ )
41
+ return weighted / total
42
+
43
+ @property
44
+ def hard_breakdown(self) -> bool:
45
+ total = sum(self.counts.values())
46
+ if not total:
47
+ return False
48
+ return self.counts.get("X", 0) / total >= 0.5
49
+
50
+
51
+ def download_archive() -> bytes:
52
+ request = urllib.request.Request(
53
+ DBDC3_URL,
54
+ headers={"User-Agent": "eq-layer-dbdc3-audit/0.1"},
55
+ )
56
+ with urllib.request.urlopen(request, timeout=120) as response:
57
+ return response.read()
58
+
59
+
60
+ def sha256_bytes(data: bytes) -> str:
61
+ return hashlib.sha256(data).hexdigest()
62
+
63
+
64
+ def _iter_json_members(archive: zipfile.ZipFile) -> list[str]:
65
+ return sorted(
66
+ name
67
+ for name in archive.namelist()
68
+ if name.lower().endswith(".json")
69
+ )
70
+
71
+
72
+ def _english_member(name: str) -> bool:
73
+ low = name.lower().replace("\\", "/")
74
+ return "/en/" in f"/{low}"
75
+
76
+
77
+ def _effective_english_members(names: list[str]) -> list[str]:
78
+ english = [name for name in names if _english_member(name)]
79
+ revised = [
80
+ name
81
+ for name in english
82
+ if "/dbdc3_revised/en/" in name.lower().replace("\\", "/")
83
+ ]
84
+ if revised:
85
+ return revised
86
+ return [
87
+ name
88
+ for name in english
89
+ if "/dbdc3/en/" in name.lower().replace("\\", "/")
90
+ ]
91
+
92
+
93
+ def _split_from_member(name: str) -> str:
94
+ low = name.lower().replace("\\", "/")
95
+ if "/en/dev/" in low:
96
+ return "dev"
97
+ if "/en/eval/" in low or "/en/test/" in low:
98
+ return "eval"
99
+ return "unknown"
100
+
101
+
102
+ def _dialogue_id(payload: dict, member: str) -> str:
103
+ for key in ("dialogue-id", "dialogue_id", "dialog-id", "id"):
104
+ if payload.get(key) is not None:
105
+ return str(payload[key])
106
+ return member.rsplit("/", 1)[-1].rsplit(".", 1)[0]
107
+
108
+
109
+ def parse_english_turns(data: bytes) -> tuple[list[BreakdownTurn], dict]:
110
+ turns: list[BreakdownTurn] = []
111
+ with zipfile.ZipFile(io.BytesIO(data)) as archive:
112
+ members = _iter_json_members(archive)
113
+ all_english = [name for name in members if _english_member(name)]
114
+ english = _effective_english_members(members)
115
+
116
+ for member in english:
117
+ with archive.open(member) as fh:
118
+ payload = json.load(fh)
119
+
120
+ dialogues = payload if isinstance(payload, list) else [payload]
121
+ for dialogue in dialogues:
122
+ if not isinstance(dialogue, dict):
123
+ continue
124
+ dialogue_turns = dialogue.get("turns")
125
+ if not isinstance(dialogue_turns, list):
126
+ continue
127
+
128
+ history: list[str] = []
129
+ did = _dialogue_id(dialogue, member)
130
+ for position, turn in enumerate(dialogue_turns):
131
+ if not isinstance(turn, dict):
132
+ continue
133
+ text = str(turn.get("utterance") or "").strip()
134
+ speaker = str(turn.get("speaker") or "").upper()
135
+ annotations = turn.get("annotations") or []
136
+
137
+ if speaker == "S" and text:
138
+ counts = Counter()
139
+ for ann in annotations:
140
+ if not isinstance(ann, dict):
141
+ continue
142
+ label = str(ann.get("breakdown") or "").upper()
143
+ if label in {"O", "T", "X"}:
144
+ counts[label] += 1
145
+ if counts:
146
+ turns.append(
147
+ BreakdownTurn(
148
+ source_file=member,
149
+ split=_split_from_member(member),
150
+ dialogue_id=did,
151
+ turn_index=int(turn.get("turn-index", position)),
152
+ text=text,
153
+ context=tuple(history[-3:]),
154
+ counts=dict(counts),
155
+ )
156
+ )
157
+
158
+ if text:
159
+ history.append(f"{speaker}: {text}")
160
+
161
+ meta = {
162
+ "zip_members": len(members),
163
+ "all_english_json_members": len(all_english),
164
+ "effective_english_json_members": len(english),
165
+ "effective_source": (
166
+ "dbdc3_revised"
167
+ if any("/dbdc3_revised/en/" in name.lower().replace("\\", "/") for name in english)
168
+ else "dbdc3"
169
+ ),
170
+ "effective_dev_members": sum("/en/dev/" in name.lower().replace("\\", "/") for name in english),
171
+ "effective_eval_members": sum(
172
+ "/en/eval/" in name.lower().replace("\\", "/")
173
+ or "/en/test/" in name.lower().replace("\\", "/")
174
+ for name in english
175
+ ),
176
+ }
177
+ return turns, meta
178
+
179
+
180
+ def inventory(turns: list[BreakdownTurn]) -> dict:
181
+ aggregate = Counter()
182
+ hard = 0
183
+ for turn in turns:
184
+ aggregate.update(turn.counts)
185
+ hard += int(turn.hard_breakdown)
186
+
187
+ return {
188
+ "system_turns": len(turns),
189
+ "dialogues": len({turn.dialogue_id for turn in turns}),
190
+ "annotation_counts": dict(sorted(aggregate.items())),
191
+ "hard_breakdown_turns": hard,
192
+ "hard_breakdown_rate": round(hard / len(turns), 4) if turns else 0.0,
193
+ "mean_breakdown_score": round(
194
+ sum(turn.score for turn in turns) / len(turns),
195
+ 4,
196
+ ) if turns else 0.0,
197
+ }
@@ -0,0 +1,272 @@
1
+ from __future__ import annotations
2
+
3
+ import html.parser
4
+ import re
5
+ import urllib.parse
6
+ import urllib.request
7
+ import xml.etree.ElementTree as ET
8
+ from collections import Counter
9
+ from dataclasses import dataclass
10
+
11
+ DIALOGBANK_BASE = "https://dialogbank.lsv.uni-saarland.de/"
12
+ DIALOGBANK_ENGLISH_PAGES = (
13
+ "https://dialogbank.lsv.uni-saarland.de/?page_id=90", # HCRC MapTask
14
+ "https://dialogbank.lsv.uni-saarland.de/?page_id=92", # Switchboard
15
+ "https://dialogbank.lsv.uni-saarland.de/?page_id=312", # DBOX
16
+ "https://dialogbank.lsv.uni-saarland.de/?page_id=280", # TRAINS
17
+ )
18
+
19
+ XML_ID = "{http://www.w3.org/XML/1998/namespace}id"
20
+
21
+
22
+ class _LinkParser(html.parser.HTMLParser):
23
+ def __init__(self) -> None:
24
+ super().__init__()
25
+ self.links: list[str] = []
26
+
27
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
28
+ if tag != "a":
29
+ return
30
+ for key, value in attrs:
31
+ if key == "href" and value:
32
+ self.links.append(value)
33
+
34
+
35
+ def fetch_text(url: str) -> str:
36
+ request = urllib.request.Request(
37
+ url,
38
+ headers={"User-Agent": "eq-layer-dialogbank-audit/0.1"},
39
+ )
40
+ with urllib.request.urlopen(request, timeout=30) as response:
41
+ return response.read().decode("utf-8")
42
+
43
+
44
+ def discover_diaml_urls() -> list[str]:
45
+ urls: set[str] = set()
46
+ for page in DIALOGBANK_ENGLISH_PAGES:
47
+ parser = _LinkParser()
48
+ parser.feed(fetch_text(page))
49
+ for href in parser.links:
50
+ absolute = urllib.parse.urljoin(DIALOGBANK_BASE, href)
51
+ if absolute.lower().endswith(".diaml"):
52
+ urls.add(absolute)
53
+ return sorted(urls)
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class ISOExample:
58
+ dialogue: str
59
+ text: str
60
+ function: str
61
+ dimension: str
62
+
63
+
64
+ def _local(tag: str) -> str:
65
+ return tag.rsplit("}", 1)[-1]
66
+
67
+
68
+ def _strip_ref(value: str | None) -> str:
69
+ return (value or "").lstrip("#")
70
+
71
+
72
+ def _repair_xml_text(xml_text: str) -> str:
73
+ # Some legacy DialogBank files contain bare ampersands or control
74
+ # characters in transcribed speech. Repair only characters that XML 1.0
75
+ # cannot represent; do not alter annotation structure or labels.
76
+ xml_text = re.sub(
77
+ r"&(?!#\d+;|#x[0-9A-Fa-f]+;|[A-Za-z_][A-Za-z0-9_.:-]*;)",
78
+ "&",
79
+ xml_text,
80
+ )
81
+ return "".join(
82
+ ch
83
+ for ch in xml_text
84
+ if ch in "\t\n\r" or ord(ch) >= 0x20
85
+ )
86
+
87
+
88
+
89
+ def _parse_attrs(raw: str) -> dict[str, str]:
90
+ attrs: dict[str, str] = {}
91
+ for key, _quote, value in re.findall(
92
+ r"([A-Za-z_][\w:.-]*)\s*=\s*(['\"])(.*?)\2",
93
+ raw,
94
+ flags=re.DOTALL,
95
+ ):
96
+ attrs[key] = html.unescape(value)
97
+ return attrs
98
+
99
+
100
+ def _plain_xml_text(raw: str) -> str:
101
+ without_tags = re.sub(r"<[^>]+>", "", raw)
102
+ return " ".join(html.unescape(without_tags).split())
103
+
104
+
105
+ def _parse_diaml_legacy(xml_text: str, source_url: str) -> list[ISOExample]:
106
+ """Extract the annotation graph without requiring globally valid XML.
107
+
108
+ A few legacy DialogBank files contain malformed transcript characters.
109
+ The annotation tags themselves are regular enough to recover the fields
110
+ EQ-Layer needs. This fallback never rewrites or fabricates labels.
111
+ """
112
+ words: dict[str, str] = {}
113
+ for match in re.finditer(r"<(?:[A-Za-z_][\w.-]*:)?w\b([^>]*)>(.*?)</(?:[A-Za-z_][\w.-]*:)?w>", xml_text, flags=re.DOTALL):
114
+ attrs = _parse_attrs(match.group(1))
115
+ wid = attrs.get("xml:id")
116
+ if wid:
117
+ words[wid] = _plain_xml_text(match.group(2))
118
+
119
+ verbal_segments: dict[str, list[str]] = {}
120
+ for match in re.finditer(
121
+ r"<(?:[A-Za-z_][\w.-]*:)?spanGrp\b([^>]*)>(.*?)</(?:[A-Za-z_][\w.-]*:)?spanGrp>",
122
+ xml_text,
123
+ flags=re.DOTALL,
124
+ ):
125
+ attrs = _parse_attrs(match.group(1))
126
+ if attrs.get("type") != "functionalVerbalSegment":
127
+ continue
128
+ sid = attrs.get("xml:id")
129
+ if not sid:
130
+ continue
131
+ ids: list[str] = []
132
+ for span in re.finditer(r"<(?:[A-Za-z_][\w.-]*:)?span\b([^>]*)/?>", match.group(2)):
133
+ span_attrs = _parse_attrs(span.group(1))
134
+ start = _strip_ref(span_attrs.get("from"))
135
+ end = _strip_ref(span_attrs.get("to"))
136
+ if start:
137
+ ids.append(start)
138
+ if end and end != start:
139
+ ids.append(end)
140
+ verbal_segments[sid] = ids
141
+
142
+ functional_to_verbal: dict[str, str] = {}
143
+ for match in re.finditer(r"<(?:[A-Za-z_][\w.-]*:)?fs\b([^>]*)>(.*?)</(?:[A-Za-z_][\w.-]*:)?fs>", xml_text, flags=re.DOTALL):
144
+ attrs = _parse_attrs(match.group(1))
145
+ if attrs.get("type") != "functionalSegment":
146
+ continue
147
+ fsid = attrs.get("xml:id")
148
+ if not fsid:
149
+ continue
150
+ for fmatch in re.finditer(r"<(?:[A-Za-z_][\w.-]*:)?f\b([^>]*)/?>", match.group(2)):
151
+ fattrs = _parse_attrs(fmatch.group(1))
152
+ if fattrs.get("name") == "verbalComponent":
153
+ functional_to_verbal[fsid] = _strip_ref(fattrs.get("fVal"))
154
+ break
155
+
156
+ examples: list[ISOExample] = []
157
+ for match in re.finditer(r"<(?:[A-Za-z_][\w.-]*:)?dialogueAct\b([^>]*)/?>", xml_text):
158
+ attrs = _parse_attrs(match.group(1))
159
+ function = attrs.get("communicativeFunction")
160
+ target = _strip_ref(attrs.get("target"))
161
+ if not function or not target:
162
+ continue
163
+ verbal = functional_to_verbal.get(target)
164
+ token_ids = verbal_segments.get(verbal or "", [])
165
+ text = " ".join(words.get(token, "") for token in token_ids).strip()
166
+ if not text:
167
+ continue
168
+ examples.append(
169
+ ISOExample(
170
+ dialogue=source_url,
171
+ text=text,
172
+ function=function,
173
+ dimension=attrs.get("dimension", ""),
174
+ )
175
+ )
176
+
177
+ if not examples:
178
+ tags = Counter(
179
+ re.findall(r"<\s*/?\s*([A-Za-z_][\w:.-]*)", xml_text)
180
+ ).most_common(24)
181
+ raise ValueError(
182
+ f"Legacy DialogBank parser recovered no dialogue acts: {source_url}; "
183
+ f"top_tags={tags}"
184
+ )
185
+ return examples
186
+
187
+
188
+ def parse_diaml(xml_text: str, source_url: str) -> list[ISOExample]:
189
+ try:
190
+ root = ET.fromstring(xml_text)
191
+ except ET.ParseError:
192
+ try:
193
+ root = ET.fromstring(_repair_xml_text(xml_text))
194
+ except ET.ParseError:
195
+ return _parse_diaml_legacy(xml_text, source_url)
196
+
197
+ words: dict[str, str] = {}
198
+ for elem in root.iter():
199
+ if _local(elem.tag) != "w":
200
+ continue
201
+ wid = elem.attrib.get(XML_ID)
202
+ if wid:
203
+ words[wid] = "".join(elem.itertext()).strip()
204
+
205
+ verbal_segments: dict[str, list[str]] = {}
206
+ for elem in root.iter():
207
+ if _local(elem.tag) != "spanGrp" or elem.attrib.get("type") != "functionalVerbalSegment":
208
+ continue
209
+ sid = elem.attrib.get(XML_ID)
210
+ if not sid:
211
+ continue
212
+ ids = []
213
+ for span in elem:
214
+ if _local(span.tag) != "span":
215
+ continue
216
+ start = _strip_ref(span.attrib.get("from"))
217
+ end = _strip_ref(span.attrib.get("to"))
218
+ if start:
219
+ ids.append(start)
220
+ if end and end != start:
221
+ ids.append(end)
222
+ verbal_segments[sid] = ids
223
+
224
+ functional_to_verbal: dict[str, str] = {}
225
+ for elem in root.iter():
226
+ if _local(elem.tag) != "fs" or elem.attrib.get("type") != "functionalSegment":
227
+ continue
228
+ fsid = elem.attrib.get(XML_ID)
229
+ if not fsid:
230
+ continue
231
+ for child in elem:
232
+ if _local(child.tag) == "f" and child.attrib.get("name") == "verbalComponent":
233
+ functional_to_verbal[fsid] = _strip_ref(child.attrib.get("fVal"))
234
+
235
+ examples: list[ISOExample] = []
236
+ for elem in root.iter():
237
+ if _local(elem.tag) != "dialogueAct":
238
+ continue
239
+ function = elem.attrib.get("communicativeFunction")
240
+ target = _strip_ref(elem.attrib.get("target"))
241
+ if not function or not target:
242
+ continue
243
+ verbal = functional_to_verbal.get(target)
244
+ token_ids = verbal_segments.get(verbal or "", [])
245
+ text = " ".join(words.get(token, "") for token in token_ids).strip()
246
+ if not text:
247
+ continue
248
+ examples.append(
249
+ ISOExample(
250
+ dialogue=source_url,
251
+ text=text,
252
+ function=function,
253
+ dimension=elem.attrib.get("dimension", ""),
254
+ )
255
+ )
256
+ return examples
257
+
258
+
259
+ def load_dialogbank_english() -> list[ISOExample]:
260
+ examples: list[ISOExample] = []
261
+ for url in discover_diaml_urls():
262
+ examples.extend(parse_diaml(fetch_text(url), url))
263
+ return examples
264
+
265
+
266
+ def inventory(examples: list[ISOExample]) -> dict:
267
+ counts = Counter(example.function for example in examples)
268
+ return {
269
+ "dialogues": len({example.dialogue for example in examples}),
270
+ "examples": len(examples),
271
+ "functions": dict(sorted(counts.items())),
272
+ }