Skip to content

Commit

Permalink
Show history and allow user to update the decision on recent edges
Browse files Browse the repository at this point in the history
  • Loading branch information
jbothma committed Jan 30, 2025
1 parent cdaea19 commit 9369cc0
Show file tree
Hide file tree
Showing 4 changed files with 219 additions and 17 deletions.
1 change: 0 additions & 1 deletion nomenklatura/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@ def xref_file(
algorithm: str = DefaultAlgorithm.NAME,
limit: int = 5000,
scored: bool = True,
index: str = Index.name,
clear: bool = False,
) -> None:
resolver = Resolver[Entity].make_default()
Expand Down
17 changes: 16 additions & 1 deletion nomenklatura/resolver/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ def get_linker(self) -> Linker[CE]:
return Linker(clusters)

def get_edge(self, left_id: StrIdent, right_id: StrIdent) -> Optional[Edge]:
"""Get an edge matching the given keys in any direction, if it exists."""

key = Identifier.pair(left_id, right_id)
stmt = self._table.select()
stmt = stmt.where(self._table.c.target == key[0].id)
Expand Down Expand Up @@ -279,6 +281,18 @@ def check_candidate(self, left: StrIdent, right: StrIdent) -> bool:
judgement = self.get_judgement(left, right)
return judgement == Judgement.NO_JUDGEMENT

def get_judgements(self, limit: Optional[int] = None) -> Generator[Edge, None, None]:
"""Get most recently updated edges other than NO_JUDGEMENT."""
stmt = self._table.select()
stmt = stmt.where(self._table.c.judgement != Judgement.NO_JUDGEMENT.value)
stmt = stmt.order_by(self._table.c.timestamp.desc())
if limit is not None:
stmt = stmt.limit(limit)
cursor = self._get_connection().execute(stmt)
while batch := cursor.fetchmany(25):
for row in batch:
yield Edge.from_dict(row._mapping)

def _get_suggested(self) -> Generator[Edge, None, None]:
"""Get all NO_JUDGEMENT edges in descending order of score."""
stmt = self._table.select()
Expand Down Expand Up @@ -351,14 +365,15 @@ def decide(
return canonical

edge.judgement = judgement
edge.timestamp = utc_now().isoformat()[:16]
edge.timestamp = utc_now().isoformat()[:28]
edge.user = user or getpass.getuser()
edge.score = score or edge.score
self._register(edge)
self._invalidate()
return edge.target

def _register(self, edge: Edge) -> None:
"""Ensure the edge exists in the resolver, as provided."""
if edge.judgement != Judgement.NO_JUDGEMENT:
edge.score = None
istmt = self._upsert(self._table).values(edge.to_dict())
Expand Down
165 changes: 150 additions & 15 deletions nomenklatura/tui/app.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
from typing import Optional, Set, Tuple, cast
from rich.text import Text
import asyncio
from typing import Dict, Optional, Set, Tuple, cast

from rich.console import RenderableType
from rich.text import Text
from textual.app import App, ComposeResult
from textual.containers import Grid
from textual.screen import ModalScreen
from textual.widget import Widget
from textual.widgets import Footer
from textual.widgets import Button, Footer, Label, ListItem, ListView, Static

from nomenklatura.dataset import DS
from nomenklatura.entity import CE
from nomenklatura.judgement import Judgement
from nomenklatura.store import Store
from nomenklatura.resolver import Resolver
from nomenklatura.entity import CE
from nomenklatura.dataset import DS
from nomenklatura.resolver.edge import Edge
from nomenklatura.store import Store
from nomenklatura.tui.comparison import render_comparison

HISTORY_LENGTH = 20


class DedupeState(object):
def __init__(
Expand All @@ -30,6 +37,7 @@ def __init__(
self.left: Optional[CE] = None
self.right: Optional[CE] = None
self.score = 0.0
self.recents: Dict[str, CE] = dict()

def load(self) -> bool:
self.left = None
Expand Down Expand Up @@ -58,6 +66,9 @@ def load(self) -> bool:
def decide(self, judgement: Judgement) -> None:
if self.left is not None and self.left.id is not None:
if self.right is not None and self.right.id is not None:
# Hold on to pre-merge entities to show in history.
self.recents[self.left.id] = self.left
self.recents[self.right.id] = self.right
canonical_id = self.resolver.decide(
self.left.id,
self.right.id,
Expand All @@ -67,15 +78,123 @@ def decide(self, judgement: Judgement) -> None:
self.resolver.commit()
self.load()

def edit(self, edge: Edge, judgement: Judgement) -> None:
self.resolver.decide(edge.source, edge.target, judgement)
self.resolver.commit()
self.load()

class DedupeWidget(Widget):
def on_mount(self) -> None:
self.styles.height = "auto"

class DedupeAppWidget(Widget):
@property
def dedupe(self) -> DedupeState:
return cast(DedupeApp, self.app).dedupe


class HistoryItem(Static, DedupeAppWidget):
def __init__(self, edge: Edge) -> None:
self.edge = edge
source = self.dedupe.recents.get(edge.source.id, None)
target = self.dedupe.recents.get(edge.target.id, None)
if target is None:
target = self.dedupe.view.get_entity(edge.target.id)
source_str = f"src: {edge.source.id}"
if source:
source_str += f"\n {source.caption}"
target_str = f"tgt: {edge.target.id}"
if target:
target_str += f"\n {target.caption}"

content = (
f"{edge.timestamp if edge.timestamp else 'unknown time'}\n"
f"{source_str}\n"
f"{target_str}\n"
f"{edge.user} decided {edge.judgement.value}"
)
super().__init__(content)


class ConfirmEditModal(ModalScreen[bool]):
edge: Optional[Edge] = None
judgement: Optional[Judgement] = None

def compose(self) -> ComposeResult:
assert self.edge is not None
assert self.judgement is not None
message = f"Change {self.edge.source.id} -> {self.edge.target.id} to {self.judgement.value}?"
yield Grid(
Label(message, id="question"),
Button("Yes", variant="error", id="yes"),
Button("No", variant="primary", id="no"),
id="dialog",
)

def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "yes":
self.dismiss(True)
else:
self.dismiss(False)


class HistoryListView(ListView):
BINDINGS = [
("x", "positive", "Match"),
("n", "negative", "No match"),
("u", "unsure", "Unsure"),
("d", "delete", "No judgement"),
]

async def action_positive(self) -> None:
await self.trigger_edit(Judgement.POSITIVE)

async def action_negative(self) -> None:
await self.trigger_edit(Judgement.NEGATIVE)

async def action_unsure(self) -> None:
await self.trigger_edit(Judgement.UNSURE)

async def action_delete(self) -> None:
await self.trigger_edit(Judgement.NO_JUDGEMENT)

async def trigger_edit(self, judgement: Judgement) -> None:
selected = self.highlighted_child
if selected is None:
return
edge = selected.query_one(HistoryItem).edge
await cast(DedupeApp, self.app).edit(edge, judgement)


class HistoryWidget(DedupeAppWidget):
list_view: ListView

def on_mount(self) -> None:
self.border_title = "History"
self.reload_history()

def compose(self) -> ComposeResult:
self.list_view = HistoryListView()
yield Static(
(
"Tab to toggle between dedupe and history.\n"
"Arrow up/down to select history to edit."
),
classes="help",
)
yield self.list_view

def reload_history(self) -> None:
self.list_view.clear()
for edge in self.dedupe.resolver.get_judgements(HISTORY_LENGTH):
self.list_view.append(ListItem(HistoryItem(edge)))
self.list_view.scroll_home(animate=False)


class DedupeWidget(Widget):
def compose(self) -> ComposeResult:
yield CompareWidget()
yield HistoryWidget()


class CompareWidget(DedupeAppWidget, can_focus=True):
def render(self) -> RenderableType:
if self.dedupe.message is not None:
return Text(self.dedupe.message, justify="center")
Expand All @@ -92,6 +211,7 @@ def render(self) -> RenderableType:


class DedupeApp(App[int]):
CSS_PATH = "app.tcss"
dedupe: DedupeState

BINDINGS = [
Expand All @@ -102,15 +222,31 @@ class DedupeApp(App[int]):
("q", "exit_hard", "Quit"),
]

def on_mount(self) -> None:
self.screen.styles.layout = "vertical"

async def decide(self, judgement: Judgement) -> None:
self.dedupe.decide(judgement)
self.force_render()

async def edit(self, edge: Edge, judgement: Judgement) -> None:
async def handle_confirmation(confirmed: bool | None) -> None:
if confirmed:
self.dedupe.edit(edge, judgement)
self.force_render()
else:
self.dedupe.message = "Canceled edit."
self.force_render()
await asyncio.sleep(1)
self.dedupe.message = None
self.force_render()

screen = ConfirmEditModal()
screen.edge = edge
screen.judgement = judgement
self.app.push_screen(screen, handle_confirmation)

def force_render(self) -> None:
self.widget.refresh(layout=True)
self.query_one(CompareWidget).refresh(layout=True)
self.query_one(HistoryWidget).reload_history()
self.query_one(HistoryWidget).refresh(layout=True)

async def action_positive(self) -> None:
await self.decide(Judgement.POSITIVE)
Expand All @@ -130,6 +266,5 @@ async def action_exit_hard(self) -> None:

def compose(self) -> ComposeResult:
self.dedupe.load()
self.widget = DedupeWidget()
yield self.widget
yield DedupeWidget()
yield Footer()
53 changes: 53 additions & 0 deletions nomenklatura/tui/app.tcss
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
CompareWidget:focus {
background: #222222;
}

ConfirmEditModal {
align: center middle;
}

#dialog {
grid-size: 2;
grid-gutter: 1 2;
grid-rows: 1fr 3;
padding: 0 1;
width: 60;
height: 11;
border: thick $background 80%;
background: $surface;
}

#question {
column-span: 2;
height: 1fr;
width: 1fr;
content-align: center middle;
}


HistoryItem {
border: solid white;
}

HistoryWidget {
width: 50;
border: solid white;
}

HistoryWidget > .help {
padding: 1
}

DedupeWidget {
layout: horizontal;
overflow-x: auto;
}

CompareWidget {
height: auto;
width: 1fr;
}

DedupeApp {
layout: vertical;
}

0 comments on commit 9369cc0

Please sign in to comment.