matchlab 0.1.0.dev1__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.
- matchlab/__init__.py +55 -0
- matchlab/adapters/__init__.py +24 -0
- matchlab/adapters/base.py +446 -0
- matchlab/adapters/duckdb.py +659 -0
- matchlab/cli.py +177 -0
- matchlab/core/__init__.py +1 -0
- matchlab/core/dataframes.py +254 -0
- matchlab/core/dsu.py +62 -0
- matchlab/core/exceptions.py +40 -0
- matchlab/core/hash.py +141 -0
- matchlab/core/kinds.py +12 -0
- matchlab/core/logging.py +190 -0
- matchlab/core/resolver_output.py +173 -0
- matchlab/core/schemas.py +82 -0
- matchlab/core/sql.py +12 -0
- matchlab/document.py +329 -0
- matchlab/eval/__init__.py +11 -0
- matchlab/eval/judgements.py +41 -0
- matchlab/eval/metrics.py +244 -0
- matchlab/eval/samples.py +415 -0
- matchlab/eval/tui/__init__.py +66 -0
- matchlab/eval/tui/app.py +418 -0
- matchlab/eval/tui/modals.py +84 -0
- matchlab/eval/tui/styles.tcss +184 -0
- matchlab/eval/tui/widgets/__init__.py +1 -0
- matchlab/eval/tui/widgets/assignment.py +72 -0
- matchlab/eval/tui/widgets/styling.py +75 -0
- matchlab/eval/tui/widgets/table.py +285 -0
- matchlab/lineage.py +239 -0
- matchlab/locations.py +347 -0
- matchlab/models/__init__.py +10 -0
- matchlab/models/comparison.py +67 -0
- matchlab/models/dedupers/__init__.py +5 -0
- matchlab/models/dedupers/base.py +38 -0
- matchlab/models/dedupers/naive.py +77 -0
- matchlab/models/linkers/__init__.py +9 -0
- matchlab/models/linkers/base.py +41 -0
- matchlab/models/linkers/deterministic.py +229 -0
- matchlab/models/linkers/splinklinker.py +262 -0
- matchlab/models/linkers/weighteddeterministic.py +143 -0
- matchlab/models/models.py +198 -0
- matchlab/progress.py +455 -0
- matchlab/recordstep.py +181 -0
- matchlab/resolvers/__init__.py +10 -0
- matchlab/resolvers/base.py +63 -0
- matchlab/resolvers/components.py +66 -0
- matchlab/resolvers/resolvers.py +409 -0
- matchlab/sources.py +291 -0
- matchlab/specs.py +129 -0
- matchlab/steps.py +284 -0
- matchlab/testkit/__init__.py +104 -0
- matchlab/testkit/_generate.py +240 -0
- matchlab/testkit/compare.py +178 -0
- matchlab/testkit/entities.py +290 -0
- matchlab/testkit/features.py +146 -0
- matchlab/testkit/linked.py +527 -0
- matchlab/testkit/matchers.py +233 -0
- matchlab/testkit/models.py +165 -0
- matchlab/testkit/sources.py +327 -0
- matchlab/transformers/__init__.py +24 -0
- matchlab/transformers/base.py +41 -0
- matchlab/transformers/clean.py +48 -0
- matchlab/transformers/explode.py +67 -0
- matchlab/transformers/group.py +56 -0
- matchlab/transformers/select.py +31 -0
- matchlab/transformers/transform.py +101 -0
- matchlab-0.1.0.dev1.dist-info/METADATA +88 -0
- matchlab-0.1.0.dev1.dist-info/RECORD +72 -0
- matchlab-0.1.0.dev1.dist-info/WHEEL +5 -0
- matchlab-0.1.0.dev1.dist-info/entry_points.txt +2 -0
- matchlab-0.1.0.dev1.dist-info/licenses/LICENSE +21 -0
- matchlab-0.1.0.dev1.dist-info/top_level.txt +1 -0
matchlab/eval/tui/app.py
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
"""Textual app that drives `review()`.
|
|
2
|
+
|
|
3
|
+
`EvaluationQueue` holds the sampled clusters still to review, always processing from
|
|
4
|
+
the front. `EntityResolutionApp` composes the UI, handles key bindings, and turns a
|
|
5
|
+
fully painted cluster into a `Judgement` on submit.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from collections import deque
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
14
|
+
from textual.app import App, ComposeResult
|
|
15
|
+
from textual.containers import Horizontal, Vertical
|
|
16
|
+
from textual.reactive import reactive
|
|
17
|
+
from textual.timer import Timer
|
|
18
|
+
from textual.widgets import Footer, Label
|
|
19
|
+
|
|
20
|
+
from matchlab.eval.samples import EvaluationItem, create_judgement, get_samples
|
|
21
|
+
from matchlab.eval.tui.modals import HelpModal, NoSamplesModal
|
|
22
|
+
from matchlab.eval.tui.widgets.assignment import AssignmentBar
|
|
23
|
+
from matchlab.eval.tui.widgets.styling import get_group_style
|
|
24
|
+
from matchlab.eval.tui.widgets.table import ComparisonDisplayTable
|
|
25
|
+
from matchlab.steps import default_adapter
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from collections.abc import Sequence
|
|
29
|
+
|
|
30
|
+
from matchlab.adapters import Adapter
|
|
31
|
+
from matchlab.eval.samples import ResolverRef
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CLIEvaluationSession(BaseModel):
|
|
37
|
+
"""One queued cluster and the group assignments made for it so far."""
|
|
38
|
+
|
|
39
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
40
|
+
|
|
41
|
+
item: EvaluationItem
|
|
42
|
+
assignments: dict[int, str] = Field(default_factory=dict)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class EvaluationQueue:
|
|
46
|
+
"""Deque-based queue with current session always at front."""
|
|
47
|
+
|
|
48
|
+
def __init__(self) -> None:
|
|
49
|
+
"""Initialise the queue."""
|
|
50
|
+
self.sessions: deque[CLIEvaluationSession] = deque()
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def current(self) -> CLIEvaluationSession | None:
|
|
54
|
+
"""Get the current session (always at index 0)."""
|
|
55
|
+
return self.sessions[0] if self.sessions else None
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def total_count(self) -> int:
|
|
59
|
+
"""Total number of sessions in the queue."""
|
|
60
|
+
return len(self.sessions)
|
|
61
|
+
|
|
62
|
+
def skip_current(self) -> None:
|
|
63
|
+
"""Move the current session to the back of the queue."""
|
|
64
|
+
if len(self.sessions) > 1:
|
|
65
|
+
self.sessions.rotate(-1)
|
|
66
|
+
|
|
67
|
+
def remove_current(self) -> CLIEvaluationSession | None:
|
|
68
|
+
"""Remove and return the current session."""
|
|
69
|
+
return self.sessions.popleft() if self.sessions else None
|
|
70
|
+
|
|
71
|
+
def add_sessions(self, sessions: list[CLIEvaluationSession]) -> int:
|
|
72
|
+
"""Add new sessions to the queue, skipping ones already queued.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Number of unique sessions added.
|
|
76
|
+
"""
|
|
77
|
+
if not sessions:
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
existing_ids = {tuple(sorted(session.item.leaves)) for session in self.sessions}
|
|
81
|
+
unique_items = [
|
|
82
|
+
session
|
|
83
|
+
for session in sessions
|
|
84
|
+
if tuple(sorted(session.item.leaves)) not in existing_ids
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
if unique_items:
|
|
88
|
+
self.sessions.extend(unique_items)
|
|
89
|
+
|
|
90
|
+
return len(unique_items)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class EntityResolutionApp(App):
|
|
94
|
+
"""Main Textual application for entity resolution evaluation."""
|
|
95
|
+
|
|
96
|
+
CSS_PATH = Path(__file__).parent / "styles.tcss"
|
|
97
|
+
TITLE = "matchlab evaluate"
|
|
98
|
+
SUB_TITLE = "match labelling tool"
|
|
99
|
+
|
|
100
|
+
# Triggered by action_* methods
|
|
101
|
+
BINDINGS = [
|
|
102
|
+
("shift+right", "skip", "Skip"),
|
|
103
|
+
("space", "submit", "Submit"),
|
|
104
|
+
("escape", "clear", "Clear"),
|
|
105
|
+
("question_mark,f1", "show_help", "Help"),
|
|
106
|
+
("ctrl+q,ctrl+c", "quit", "Quit"),
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
# Reactive variables that trigger UI updates
|
|
110
|
+
status: reactive[tuple[str, str]] = reactive(("○ Ready", "dim"))
|
|
111
|
+
current_item: reactive[EvaluationItem | None] = reactive(None)
|
|
112
|
+
current_assignments: reactive[dict[int, str]] = reactive({}, init=False)
|
|
113
|
+
|
|
114
|
+
sample_limit: int
|
|
115
|
+
resolver: "ResolverRef | Sequence[ResolverRef]"
|
|
116
|
+
adapter: "Adapter | None"
|
|
117
|
+
session_tag: str | None
|
|
118
|
+
seed: int | None
|
|
119
|
+
show_help: bool
|
|
120
|
+
|
|
121
|
+
queue: EvaluationQueue
|
|
122
|
+
timer: Timer | None = None
|
|
123
|
+
|
|
124
|
+
def __init__(
|
|
125
|
+
self,
|
|
126
|
+
resolver: "ResolverRef | Sequence[ResolverRef]",
|
|
127
|
+
num_samples: int = 5,
|
|
128
|
+
adapter: "Adapter | None" = None,
|
|
129
|
+
session_tag: str | None = None,
|
|
130
|
+
seed: int | None = None,
|
|
131
|
+
show_help: bool = False,
|
|
132
|
+
scroll_debounce_delay: float | None = 0.3,
|
|
133
|
+
) -> None:
|
|
134
|
+
"""Initialise the entity resolution app.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
resolver: The resolver whose clusters are reviewed, or the name one was
|
|
138
|
+
published under. The second form reviews a store without the plan
|
|
139
|
+
that built it. Several of either reviews their merged components.
|
|
140
|
+
num_samples: Number of clusters to sample for evaluation.
|
|
141
|
+
adapter: Where judgements are stored. Defaults to the module default.
|
|
142
|
+
session_tag: String to use for tagging judgements.
|
|
143
|
+
seed: Fixes which clusters the session draws, so it can be reproduced.
|
|
144
|
+
show_help: Whether to show help on start.
|
|
145
|
+
scroll_debounce_delay: Delay before updating column headers after scroll.
|
|
146
|
+
Set to None to disable debouncing (useful for tests).
|
|
147
|
+
"""
|
|
148
|
+
super().__init__()
|
|
149
|
+
|
|
150
|
+
self.resolver = resolver
|
|
151
|
+
self.sample_limit = num_samples
|
|
152
|
+
self.adapter = adapter
|
|
153
|
+
self.session_tag = session_tag
|
|
154
|
+
self.seed = seed
|
|
155
|
+
self._refills = 0
|
|
156
|
+
|
|
157
|
+
self.show_help = show_help
|
|
158
|
+
self._scroll_debounce_delay = scroll_debounce_delay
|
|
159
|
+
self.queue = EvaluationQueue()
|
|
160
|
+
|
|
161
|
+
# Lifecycle methods
|
|
162
|
+
def compose(self) -> ComposeResult:
|
|
163
|
+
"""Compose the main application UI."""
|
|
164
|
+
yield Vertical(
|
|
165
|
+
Horizontal(
|
|
166
|
+
Label("-", id="current-group-label"),
|
|
167
|
+
AssignmentBar(id="assignment-bar"),
|
|
168
|
+
Label(id="status-right"),
|
|
169
|
+
id="status-bar",
|
|
170
|
+
classes="status-bar",
|
|
171
|
+
),
|
|
172
|
+
ComparisonDisplayTable(
|
|
173
|
+
id="record-table", scroll_debounce_delay=self._scroll_debounce_delay
|
|
174
|
+
),
|
|
175
|
+
id="main-container",
|
|
176
|
+
)
|
|
177
|
+
yield Footer()
|
|
178
|
+
|
|
179
|
+
async def on_mount(self) -> None:
|
|
180
|
+
"""Initialise the application."""
|
|
181
|
+
await self.load_samples()
|
|
182
|
+
|
|
183
|
+
if self.queue.total_count == 0:
|
|
184
|
+
await self._handle_no_samples()
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
self._load_current_item()
|
|
188
|
+
|
|
189
|
+
if self.show_help:
|
|
190
|
+
self.call_after_refresh(self.action_show_help)
|
|
191
|
+
|
|
192
|
+
# Message handlers
|
|
193
|
+
def on_comparison_display_table_assignment_made(
|
|
194
|
+
self, message: ComparisonDisplayTable.AssignmentMade
|
|
195
|
+
) -> None:
|
|
196
|
+
"""Update reactive assignments when table reports an assignment."""
|
|
197
|
+
# Create new dict to trigger reactivity
|
|
198
|
+
new_assignments = {
|
|
199
|
+
**self.current_assignments,
|
|
200
|
+
message.column_idx: message.group,
|
|
201
|
+
}
|
|
202
|
+
self.current_assignments = new_assignments
|
|
203
|
+
|
|
204
|
+
# Save to queue item
|
|
205
|
+
if current := self.queue.current:
|
|
206
|
+
current.assignments = new_assignments
|
|
207
|
+
|
|
208
|
+
# Update assignment bar
|
|
209
|
+
_, colour = get_group_style(message.group)
|
|
210
|
+
assignment_bar = self.query_one("#assignment-bar", AssignmentBar)
|
|
211
|
+
assignment_bar.set_position(message.column_idx, message.group.upper(), colour)
|
|
212
|
+
|
|
213
|
+
def on_comparison_display_table_current_group_changed(
|
|
214
|
+
self, message: ComparisonDisplayTable.CurrentGroupChanged
|
|
215
|
+
) -> None:
|
|
216
|
+
"""Update current group label when table's current group changes."""
|
|
217
|
+
# Update current group label
|
|
218
|
+
if message.group:
|
|
219
|
+
_, colour = get_group_style(message.group)
|
|
220
|
+
label_text = f"[{colour}]{message.group.upper()}[/{colour}]"
|
|
221
|
+
else:
|
|
222
|
+
label_text = "-"
|
|
223
|
+
self.query_one("#current-group-label", Label).update(label_text)
|
|
224
|
+
|
|
225
|
+
# Reactive watchers
|
|
226
|
+
def watch_status(self) -> None:
|
|
227
|
+
"""React to status changes."""
|
|
228
|
+
self._update_status_labels()
|
|
229
|
+
|
|
230
|
+
def watch_current_item(self, item: EvaluationItem | None) -> None:
|
|
231
|
+
"""Rebuild the assignment bar for the item, and propagate it to the table."""
|
|
232
|
+
table = self.query_one(ComparisonDisplayTable)
|
|
233
|
+
table.current_item = item
|
|
234
|
+
|
|
235
|
+
# Reset assignment bar for new item
|
|
236
|
+
if item:
|
|
237
|
+
unique_record_groups = item.get_unique_record_groups()
|
|
238
|
+
num_columns = len(unique_record_groups)
|
|
239
|
+
assignment_bar = self.query_one("#assignment-bar", AssignmentBar)
|
|
240
|
+
assignment_bar.reset(num_columns)
|
|
241
|
+
|
|
242
|
+
# Load existing assignments into the bar
|
|
243
|
+
if self.current_assignments:
|
|
244
|
+
for col_idx, group in self.current_assignments.items():
|
|
245
|
+
_, colour = get_group_style(group)
|
|
246
|
+
assignment_bar.set_position(col_idx, group.upper(), colour)
|
|
247
|
+
|
|
248
|
+
# Reset current group label
|
|
249
|
+
self.query_one("#current-group-label", Label).update("-")
|
|
250
|
+
|
|
251
|
+
def watch_current_assignments(self, assignments: dict[int, str]) -> None:
|
|
252
|
+
"""Propagate assignment changes to the table."""
|
|
253
|
+
table = self.query_one(ComparisonDisplayTable)
|
|
254
|
+
table.current_assignments = assignments
|
|
255
|
+
|
|
256
|
+
# Private methods
|
|
257
|
+
def _load_current_item(self) -> None:
|
|
258
|
+
"""Load current queue item into the display."""
|
|
259
|
+
current = self.queue.current
|
|
260
|
+
if current:
|
|
261
|
+
# Set reactive properties (will propagate to table via watchers)
|
|
262
|
+
self.current_assignments = current.assignments.copy()
|
|
263
|
+
self.current_item = current.item
|
|
264
|
+
|
|
265
|
+
def _update_status_labels(self) -> None:
|
|
266
|
+
"""Update right status label."""
|
|
267
|
+
self.query_one("#status-right", Label).update(self._build_status_right())
|
|
268
|
+
|
|
269
|
+
def _build_status_right(self) -> str:
|
|
270
|
+
"""Build right status text with status indicator."""
|
|
271
|
+
message, colour = self.status
|
|
272
|
+
if message:
|
|
273
|
+
return f"[{colour}]{message}[/]"
|
|
274
|
+
return "[dim]○ Ready[/]"
|
|
275
|
+
|
|
276
|
+
async def _handle_no_samples(self) -> None:
|
|
277
|
+
"""Handle empty queue state."""
|
|
278
|
+
self._update_status("◯ No data", "yellow")
|
|
279
|
+
logger.warning("No samples available for this resolver.")
|
|
280
|
+
await self.action_show_no_samples()
|
|
281
|
+
|
|
282
|
+
def _update_status(
|
|
283
|
+
self,
|
|
284
|
+
message: str,
|
|
285
|
+
colour: str = "dim",
|
|
286
|
+
clear_after: float | None = None,
|
|
287
|
+
) -> None:
|
|
288
|
+
"""Update status message with optional auto-clear.
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
message: Status message to display.
|
|
292
|
+
colour: Colour for the message.
|
|
293
|
+
clear_after: Seconds after which to auto-clear.
|
|
294
|
+
"""
|
|
295
|
+
if self.timer:
|
|
296
|
+
self.timer.stop()
|
|
297
|
+
|
|
298
|
+
self.status = (message, colour)
|
|
299
|
+
|
|
300
|
+
if clear_after:
|
|
301
|
+
self.timer = self.set_timer(
|
|
302
|
+
clear_after, lambda: self._update_status("○ Ready", "dim")
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
# Public methods
|
|
306
|
+
async def load_samples(self) -> None:
|
|
307
|
+
"""Fetch more samples from the store until the queue reaches its limit."""
|
|
308
|
+
needed = max(0, self.sample_limit - self.queue.total_count)
|
|
309
|
+
|
|
310
|
+
if needed <= 0:
|
|
311
|
+
return
|
|
312
|
+
|
|
313
|
+
self._update_status("⚡ Fetching", "yellow")
|
|
314
|
+
logger.info(
|
|
315
|
+
"Fetching %s samples to reach limit of %s",
|
|
316
|
+
needed,
|
|
317
|
+
self.sample_limit,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
# The queue is refilled as it drains, so a fixed seed would fetch the same
|
|
321
|
+
# clusters every time and the leaf-based dedupe would starve it. Moving the
|
|
322
|
+
# seed on per refill keeps a whole session reproducible while each fetch still
|
|
323
|
+
# reaches new clusters. The dedupe drops any overlap between fetches.
|
|
324
|
+
new_samples_dict = None
|
|
325
|
+
try:
|
|
326
|
+
new_samples_dict = get_samples(
|
|
327
|
+
n=needed,
|
|
328
|
+
resolver=self.resolver,
|
|
329
|
+
adapter=self.adapter,
|
|
330
|
+
seed=None if self.seed is None else self.seed + self._refills,
|
|
331
|
+
)
|
|
332
|
+
except Exception as e: # noqa: BLE001
|
|
333
|
+
logger.warning(f"Failed to fetch samples: {type(e).__name__}: {e}")
|
|
334
|
+
finally:
|
|
335
|
+
self._refills += 1
|
|
336
|
+
|
|
337
|
+
if new_samples_dict:
|
|
338
|
+
# Wrap evaluation items in CLI sessions
|
|
339
|
+
sessions = [
|
|
340
|
+
CLIEvaluationSession(item=item) for item in new_samples_dict.values()
|
|
341
|
+
]
|
|
342
|
+
self.queue.add_sessions(sessions)
|
|
343
|
+
|
|
344
|
+
if self.queue.total_count == 0:
|
|
345
|
+
await self._handle_no_samples()
|
|
346
|
+
else:
|
|
347
|
+
self._update_status("✓ Ready", "green")
|
|
348
|
+
|
|
349
|
+
# Action methods (public interface)
|
|
350
|
+
async def action_skip(self) -> None:
|
|
351
|
+
"""Skip current entity (moves to back of queue)."""
|
|
352
|
+
if self.queue.total_count > 1:
|
|
353
|
+
self.queue.skip_current()
|
|
354
|
+
self._load_current_item()
|
|
355
|
+
else:
|
|
356
|
+
await self.action_submit()
|
|
357
|
+
if self.queue.total_count == 0:
|
|
358
|
+
self.exit()
|
|
359
|
+
|
|
360
|
+
async def action_submit(self) -> None:
|
|
361
|
+
"""Submit current entity if fully painted."""
|
|
362
|
+
current = self.queue.current
|
|
363
|
+
|
|
364
|
+
if not current:
|
|
365
|
+
return
|
|
366
|
+
|
|
367
|
+
# Check if all columns are assigned
|
|
368
|
+
unique_record_groups = current.item.get_unique_record_groups()
|
|
369
|
+
is_painted = len(current.assignments) == len(unique_record_groups)
|
|
370
|
+
if not is_painted:
|
|
371
|
+
self._update_status("⚠ Incomplete", "yellow", clear_after=3.0)
|
|
372
|
+
return
|
|
373
|
+
|
|
374
|
+
try:
|
|
375
|
+
judgement = create_judgement(
|
|
376
|
+
item=current.item,
|
|
377
|
+
assignments=current.assignments,
|
|
378
|
+
tag=self.session_tag,
|
|
379
|
+
)
|
|
380
|
+
adapter = self.adapter or default_adapter()
|
|
381
|
+
adapter.store_judgement(judgement)
|
|
382
|
+
except Exception as exc:
|
|
383
|
+
self._update_status("⚠ Send failed", "red", clear_after=4.0)
|
|
384
|
+
logger.exception(f"Failed to submit: {exc}")
|
|
385
|
+
return
|
|
386
|
+
|
|
387
|
+
self.queue.remove_current()
|
|
388
|
+
await self.load_samples()
|
|
389
|
+
self._load_current_item()
|
|
390
|
+
self._update_status("✓ Sent", "green", clear_after=2.0)
|
|
391
|
+
|
|
392
|
+
async def action_clear(self) -> None:
|
|
393
|
+
"""Clear current entity's group assignments."""
|
|
394
|
+
if self.queue.current:
|
|
395
|
+
# Clear reactive assignments (will propagate to table)
|
|
396
|
+
self.current_assignments = {}
|
|
397
|
+
|
|
398
|
+
# Update queue item
|
|
399
|
+
self.queue.current.assignments = {}
|
|
400
|
+
|
|
401
|
+
# Reset assignment bar and current group label
|
|
402
|
+
if self.current_item:
|
|
403
|
+
unique_record_groups = self.current_item.get_unique_record_groups()
|
|
404
|
+
assignment_bar = self.query_one("#assignment-bar", AssignmentBar)
|
|
405
|
+
assignment_bar.reset(len(unique_record_groups))
|
|
406
|
+
self.query_one("#current-group-label", Label).update("-")
|
|
407
|
+
|
|
408
|
+
async def action_show_help(self) -> None:
|
|
409
|
+
"""Show the help modal."""
|
|
410
|
+
self.push_screen(HelpModal())
|
|
411
|
+
|
|
412
|
+
async def action_show_no_samples(self) -> None:
|
|
413
|
+
"""Show the no samples modal."""
|
|
414
|
+
self.push_screen(NoSamplesModal())
|
|
415
|
+
|
|
416
|
+
async def action_quit(self) -> None:
|
|
417
|
+
"""Quit the application."""
|
|
418
|
+
self.exit()
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Modal screens for entity resolution evaluation."""
|
|
2
|
+
|
|
3
|
+
from textual import events, on
|
|
4
|
+
from textual.app import ComposeResult
|
|
5
|
+
from textual.containers import Container
|
|
6
|
+
from textual.screen import ModalScreen
|
|
7
|
+
from textual.widgets import Button, Static
|
|
8
|
+
|
|
9
|
+
HELP_TEXT = """
|
|
10
|
+
A model thinks this data is one entity. Is it?
|
|
11
|
+
|
|
12
|
+
[b]Paint the columns to label this as one or more entities.[/b]
|
|
13
|
+
|
|
14
|
+
• Each column is data from a single source.
|
|
15
|
+
• Each row is a field from that source.
|
|
16
|
+
|
|
17
|
+
If you think the model got it right, paint all columns the same.
|
|
18
|
+
|
|
19
|
+
If you think column 1 and 2 belong to one entity, and 3-5 another,
|
|
20
|
+
paint them that way.
|
|
21
|
+
|
|
22
|
+
When you're done, submit, and do another!
|
|
23
|
+
|
|
24
|
+
• Press letter (a-z): Select that group (26 groups available)
|
|
25
|
+
• Press number (1-9, 0): Assign column to selected group
|
|
26
|
+
• Space: Submit completed review
|
|
27
|
+
• Esc: Clear group selection
|
|
28
|
+
• Shift+Right: Skip to next entity
|
|
29
|
+
• Left/Right: Switch between tabs
|
|
30
|
+
• Up/Down: Page through rows
|
|
31
|
+
""".strip()
|
|
32
|
+
|
|
33
|
+
NO_SAMPLES_TEXT = """
|
|
34
|
+
No samples are available for this resolver.
|
|
35
|
+
|
|
36
|
+
Possible reasons:
|
|
37
|
+
• All clusters have been recently judged by you
|
|
38
|
+
• The resolver has no score data
|
|
39
|
+
• No clusters exist for this resolver
|
|
40
|
+
|
|
41
|
+
Press Ctrl+Q to quit.
|
|
42
|
+
""".strip()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class HelpModal(ModalScreen):
|
|
46
|
+
"""Help screen showing commands and shortcuts."""
|
|
47
|
+
|
|
48
|
+
def compose(self) -> ComposeResult:
|
|
49
|
+
"""Compose the help modal UI."""
|
|
50
|
+
with Container(id="help-dialog"):
|
|
51
|
+
yield Static("Paint the columns!", id="help-title")
|
|
52
|
+
yield Static(HELP_TEXT, id="help-content")
|
|
53
|
+
yield Button("Close (Esc)", id="close-help")
|
|
54
|
+
|
|
55
|
+
@on(Button.Pressed, "#close-help")
|
|
56
|
+
def close_help(self) -> None:
|
|
57
|
+
"""Close the help modal."""
|
|
58
|
+
self.dismiss()
|
|
59
|
+
|
|
60
|
+
def on_key(self, event: events.Key) -> None:
|
|
61
|
+
"""Handle key events for closing the help modal."""
|
|
62
|
+
if event.key in ("escape", "question_mark", "f1"):
|
|
63
|
+
self.dismiss()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class NoSamplesModal(ModalScreen):
|
|
67
|
+
"""Modal screen showing no samples are available."""
|
|
68
|
+
|
|
69
|
+
def compose(self) -> ComposeResult:
|
|
70
|
+
"""Compose the no samples modal UI."""
|
|
71
|
+
with Container(id="no-samples-dialog"):
|
|
72
|
+
yield Static("No samples available", id="no-samples-title")
|
|
73
|
+
yield Static(NO_SAMPLES_TEXT, id="no-samples-content")
|
|
74
|
+
yield Button("Quit (Ctrl+Q)", id="quit-no-samples")
|
|
75
|
+
|
|
76
|
+
@on(Button.Pressed, "#quit-no-samples")
|
|
77
|
+
def quit_app(self) -> None:
|
|
78
|
+
"""Quit the application."""
|
|
79
|
+
self.app.exit()
|
|
80
|
+
|
|
81
|
+
def on_key(self, event: events.Key) -> None:
|
|
82
|
+
"""Handle key events for the no samples modal."""
|
|
83
|
+
if event.key in ("escape", "ctrl+q", "ctrl+c"):
|
|
84
|
+
self.app.exit()
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/* Entity Resolution UI Styles */
|
|
2
|
+
|
|
3
|
+
Screen {
|
|
4
|
+
align: center top;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/* Main container - expanded to fill space */
|
|
8
|
+
#main-container {
|
|
9
|
+
width: 100%;
|
|
10
|
+
height: 100%;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/* Modal dialogs */
|
|
14
|
+
#help-dialog {
|
|
15
|
+
width: 80;
|
|
16
|
+
height: 30;
|
|
17
|
+
background: $surface;
|
|
18
|
+
border: thick $primary;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
#help-title {
|
|
22
|
+
text-align: center;
|
|
23
|
+
text-style: bold;
|
|
24
|
+
color: $accent;
|
|
25
|
+
margin: 1;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
#help-content {
|
|
29
|
+
margin: 1 2;
|
|
30
|
+
height: 20;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
#close-help {
|
|
34
|
+
margin: 1 2;
|
|
35
|
+
width: 20;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
#no-samples-dialog {
|
|
39
|
+
width: 65;
|
|
40
|
+
height: 18;
|
|
41
|
+
background: $surface;
|
|
42
|
+
border: thick $primary;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#no-samples-title {
|
|
46
|
+
text-align: center;
|
|
47
|
+
text-style: bold;
|
|
48
|
+
color: $accent;
|
|
49
|
+
margin: 1;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#no-samples-content {
|
|
53
|
+
margin: 1 2;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
#quit-no-samples {
|
|
57
|
+
margin: 1 2;
|
|
58
|
+
width: 20;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* Status bar */
|
|
62
|
+
#status-bar {
|
|
63
|
+
height: 3;
|
|
64
|
+
background: #1B2E46;
|
|
65
|
+
padding: 0;
|
|
66
|
+
width: 100%;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#current-group-label {
|
|
70
|
+
width: auto;
|
|
71
|
+
height: auto;
|
|
72
|
+
padding: 0 1;
|
|
73
|
+
border: solid #E4A242;
|
|
74
|
+
text-align: center;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
#assignment-bar {
|
|
78
|
+
width: 1fr;
|
|
79
|
+
text-align: left;
|
|
80
|
+
padding: 1 1;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
#status-right {
|
|
84
|
+
width: auto;
|
|
85
|
+
text-align: right;
|
|
86
|
+
padding: 1 1;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* Table */
|
|
90
|
+
#record-table {
|
|
91
|
+
height: 1fr;
|
|
92
|
+
width: 100%;
|
|
93
|
+
background: $surface;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/* DataTable styling */
|
|
97
|
+
ComparisonDisplayTable {
|
|
98
|
+
height: 1fr;
|
|
99
|
+
width: 100%;
|
|
100
|
+
|
|
101
|
+
/* Scrollbar styling */
|
|
102
|
+
scrollbar-color: #342c20;
|
|
103
|
+
scrollbar-color-hover: #E4A242;
|
|
104
|
+
scrollbar-color-active: #E4A242;
|
|
105
|
+
scrollbar-background: #0d1318;
|
|
106
|
+
scrollbar-background-hover: #10161d;
|
|
107
|
+
scrollbar-background-active: #10161d;
|
|
108
|
+
scrollbar-corner-color: #0d1318;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* Disable default focus tint on table background */
|
|
112
|
+
ComparisonDisplayTable:focus {
|
|
113
|
+
background-tint: transparent 0%;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/* Header styling - matches fixed column */
|
|
117
|
+
ComparisonDisplayTable > .datatable--header {
|
|
118
|
+
text-style: bold;
|
|
119
|
+
background: #18283C;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* Prevent header color changes on focus/hover/cursor */
|
|
123
|
+
ComparisonDisplayTable:focus > .datatable--header,
|
|
124
|
+
ComparisonDisplayTable > .datatable--header-cursor,
|
|
125
|
+
ComparisonDisplayTable > .datatable--header-hover {
|
|
126
|
+
background: #18283C;
|
|
127
|
+
background-tint: transparent 0%;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/* Fixed column (row header) styling - matches header */
|
|
131
|
+
ComparisonDisplayTable > .datatable--fixed,
|
|
132
|
+
ComparisonDisplayTable:focus > .datatable--fixed {
|
|
133
|
+
text-style: bold;
|
|
134
|
+
background: #18283C;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/* Zebra striping with subtle grey-blue tones */
|
|
138
|
+
ComparisonDisplayTable > .datatable--odd-row {
|
|
139
|
+
background: #0d1318;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
ComparisonDisplayTable > .datatable--even-row {
|
|
143
|
+
background: #10161d;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/* Footer */
|
|
147
|
+
Footer {
|
|
148
|
+
background: #1B2E46;
|
|
149
|
+
color: $text;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
Footer > .footer--highlight {
|
|
153
|
+
background: #1B2E46;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
Footer > .footer--highlight-key {
|
|
157
|
+
background: #E4A242;
|
|
158
|
+
color: #1B2E46;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
Footer > .footer--key {
|
|
162
|
+
background: #E4A242;
|
|
163
|
+
color: #1B2E46;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
Footer > .footer--description {
|
|
167
|
+
color: $text;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/* Other views */
|
|
171
|
+
#pr-curve-display {
|
|
172
|
+
height: auto;
|
|
173
|
+
width: 100%;
|
|
174
|
+
overflow: auto;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.command-area {
|
|
178
|
+
height: 3;
|
|
179
|
+
background: #1B2E46;
|
|
180
|
+
padding: 1;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/* Dynamic group styles will be generated at runtime */
|
|
184
|
+
/* No need for hardcoded group-a through group-g classes */
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""UI widgets for entity resolution evaluation tool."""
|