Skip to content

Commit abf558b

Browse files
committed
repl command /classify
1 parent 06b78ba commit abf558b

2 files changed

Lines changed: 239 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
- readers provide source information.
1212
- `hyperbase.parsers.badness` module for parser-agnostic combined structural + token-matching validation.
1313
- built-in REPL setting `check_badness` to render a badness panel after each parse, available regardless of the active parser plugin.
14-
- more REPL commands: /load, /save, /search, /count, /count-csv, /types and /transform.
14+
- more REPL commands: /load, /save, /search, /count, /count-csv, /types, /transform and /classify.
1515

1616
### Changed
1717

src/hyperbase/cli/repl.py

Lines changed: 238 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,13 +125,21 @@ def _build_parser_kwargs(parser_cls: type[Parser], settings: dict) -> dict[str,
125125

126126

127127
class FilteredFileHistory(FileHistory):
128-
"""Custom history that filters out blanks and consecutive duplicates."""
128+
"""Custom history that filters out blanks and consecutive duplicates.
129+
130+
Set ``paused = True`` to skip persistence around prompts whose input
131+
should not pollute the REPL's command history (e.g. classification
132+
labels typed during ``/classify``).
133+
"""
129134

130135
def __init__(self, filename: str) -> None:
131136
super().__init__(filename)
132137
self.last_saved: str | None = None
138+
self.paused: bool = False
133139

134140
def store_string(self, string: str) -> None:
141+
if self.paused:
142+
return
135143
if not string.strip():
136144
return
137145
if string == self.last_saved:
@@ -253,7 +261,7 @@ class CommandCompleter(Completer):
253261
# Commands whose argument is a filesystem path. The completer
254262
# delegates to ``PathCompleter`` once the user has typed past the
255263
# command name.
256-
PATH_ARG_COMMANDS = frozenset({"load", "save", "count-csv"})
264+
PATH_ARG_COMMANDS = frozenset({"load", "save", "count-csv", "classify"})
257265

258266
def __init__(self, commands: dict) -> None:
259267
self.commands = commands
@@ -370,6 +378,13 @@ def __init__(self, parser_name: str | None, settings: dict[str, Any]) -> None:
370378
"help": "Like /count, but write counts to a .csv file instead",
371379
"handler": self.cmd_count_csv,
372380
},
381+
"classify": {
382+
"help": (
383+
"Count pattern matches, prompt for a label per item, "
384+
"and write labels to a .csv file"
385+
),
386+
"handler": self.cmd_classify,
387+
},
373388
"types": {
374389
"help": (
375390
"Count atom types across loaded edges "
@@ -899,6 +914,15 @@ def _render_search_hit(
899914
self.console.print(line)
900915
self.console.print()
901916

917+
def _format_eta(self, minutes: float) -> str:
918+
if minutes < 1:
919+
return f"{round(minutes * 60)}s"
920+
if minutes < 60:
921+
return f"{minutes:.1f}m"
922+
if minutes < 1440:
923+
return f"{minutes / 60:.1f}h"
924+
return f"{minutes / 1440:.1f}d"
925+
902926
def _page_size(self) -> int:
903927
try:
904928
size = int(self.settings.get("page_size", 20))
@@ -1084,6 +1108,218 @@ def cmd_count_csv(self, args: list) -> bool:
10841108
)
10851109
return False
10861110

1111+
def cmd_classify(self, args: list) -> bool:
1112+
if len(args) < 2:
1113+
self.console.print(
1114+
"[red]Error:[/red] /classify requires a file path and a pattern: "
1115+
"[cyan]/classify <path> <pattern>[/cyan]"
1116+
)
1117+
return False
1118+
if not self.edges:
1119+
self.console.print("[yellow]No edges loaded.[/yellow]")
1120+
self.console.print(
1121+
"[dim]Use[/dim] [cyan]/load <path>[/cyan] [dim]first.[/dim]"
1122+
)
1123+
return False
1124+
1125+
csv_path = Path(args[0]).expanduser()
1126+
pattern_text = " ".join(args[1:])
1127+
try:
1128+
pattern = hedge(pattern_text)
1129+
except Exception as e:
1130+
self.console.print(f"[red]Error:[/red] failed to parse pattern: {e}")
1131+
return False
1132+
if pattern is None:
1133+
self.console.print(
1134+
f"[red]Error:[/red] could not parse pattern: "
1135+
f"[cyan]{pattern_text}[/cyan]"
1136+
)
1137+
return False
1138+
1139+
recursive = bool(self.settings.get("search_recursive", True))
1140+
1141+
counter: Counter[Any] = Counter()
1142+
for top_edge in self.edges:
1143+
candidates: Iterable[Hyperedge] = (
1144+
self._iter_subedges_ordered(top_edge) if recursive else [top_edge]
1145+
)
1146+
for sub in candidates:
1147+
bindings_list = sub.match(pattern)
1148+
for b in bindings_list:
1149+
key: Any = tuple(sorted(b.items())) if b else sub
1150+
counter[key] += 1
1151+
1152+
if not counter:
1153+
self.console.print(
1154+
f"[yellow]No matches[/yellow] for [cyan]{pattern_text}[/cyan]"
1155+
)
1156+
return False
1157+
1158+
items = counter.most_common()
1159+
total = len(items)
1160+
self.console.print(
1161+
f"[green]{sum(counter.values())}[/green] match(es), "
1162+
f"[cyan]{total}[/cyan] distinct "
1163+
f"({'recursive' if recursive else 'top-level only'})"
1164+
)
1165+
1166+
first_key = items[0][0]
1167+
if isinstance(first_key, tuple):
1168+
id_cols = [var for var, _ in first_key]
1169+
1170+
def identity_of(key: Any) -> tuple[str, ...]: # noqa: ANN401
1171+
return tuple(str(val) for _, val in key)
1172+
else:
1173+
id_cols = ["edge"]
1174+
1175+
def identity_of(key: Any) -> tuple[str, ...]: # noqa: ANN401
1176+
return (str(key),)
1177+
1178+
existing_size = csv_path.stat().st_size if csv_path.exists() else 0
1179+
done_identities: set[tuple[str, ...]] = set()
1180+
if existing_size > 0:
1181+
try:
1182+
with open(csv_path, newline="") as f:
1183+
reader = csv.reader(f)
1184+
header = next(reader, None)
1185+
if header is None:
1186+
pass
1187+
elif (
1188+
not header
1189+
or header[-1] != "classification"
1190+
or header[:-1] != id_cols
1191+
):
1192+
self.console.print(
1193+
f"[red]Error:[/red] [cyan]{csv_path}[/cyan] header "
1194+
f"{header} does not match expected "
1195+
f"{[*id_cols, 'classification']}"
1196+
)
1197+
return False
1198+
else:
1199+
for row in reader:
1200+
if not row or len(row) < len(header):
1201+
continue
1202+
done_identities.add(tuple(row[: len(id_cols)]))
1203+
except OSError as e:
1204+
self.console.print(f"[red]Error:[/red] failed to read {csv_path}: {e}")
1205+
return False
1206+
1207+
existing_count = len(done_identities)
1208+
pending: list[tuple[Any, int]] = [
1209+
(key, count)
1210+
for key, count in items
1211+
if identity_of(key) not in done_identities
1212+
]
1213+
pending_total = len(pending)
1214+
1215+
if existing_count > 0:
1216+
self.console.print(
1217+
f"[dim]Resuming from[/dim] [cyan]{csv_path}[/cyan] "
1218+
f"[dim]({existing_count} already classified, "
1219+
f"{pending_total} pending)[/dim]"
1220+
)
1221+
1222+
if not pending:
1223+
self.console.print(
1224+
f"[green]All[/green] [cyan]{total}[/cyan] item(s) already "
1225+
f"classified in [cyan]{csv_path}[/cyan]"
1226+
)
1227+
return False
1228+
1229+
self.console.print(
1230+
"[dim]Enter a label for each item. "
1231+
"Empty input asks to finish early.[/dim]\n"
1232+
)
1233+
1234+
classifications: list[tuple[Any, str]] = []
1235+
finished_early = False
1236+
aborted = False
1237+
start_time = time.perf_counter()
1238+
1239+
self.history.paused = True
1240+
try:
1241+
for n, (key, count) in enumerate(pending, start=1):
1242+
self._render_count_row(existing_count + n, key, count)
1243+
while True:
1244+
session_done = len(classifications)
1245+
total_done = existing_count + session_done
1246+
elapsed_min = (time.perf_counter() - start_time) / 60.0
1247+
if session_done > 0 and elapsed_min > 0:
1248+
rate = session_done / elapsed_min
1249+
pending_remaining = pending_total - session_done
1250+
eta = pending_remaining / rate if rate > 0 else None
1251+
rate_str = f"{rate:.1f}/min"
1252+
eta_str = self._format_eta(eta) if eta is not None else "—"
1253+
else:
1254+
rate_str = "—/min"
1255+
eta_str = "—"
1256+
self.console.print(
1257+
f"[dim]{total_done}/{total} classified | "
1258+
f"{rate_str} | ~{eta_str} remaining[/dim]"
1259+
)
1260+
try:
1261+
label = self.session.prompt(
1262+
f"[{n}/{pending_total}] label > "
1263+
).strip()
1264+
except (KeyboardInterrupt, EOFError):
1265+
self.console.print("[dim](aborted)[/dim]")
1266+
aborted = True
1267+
break
1268+
1269+
if label == "":
1270+
try:
1271+
confirm = (
1272+
self.session.prompt("Finish classification? [y/N] ")
1273+
.strip()
1274+
.lower()
1275+
)
1276+
except (KeyboardInterrupt, EOFError):
1277+
self.console.print("[dim](aborted)[/dim]")
1278+
aborted = True
1279+
break
1280+
if confirm in ("y", "yes"):
1281+
finished_early = True
1282+
break
1283+
continue
1284+
1285+
classifications.append((key, label))
1286+
break
1287+
1288+
if aborted or finished_early:
1289+
break
1290+
finally:
1291+
self.history.paused = False
1292+
1293+
if aborted:
1294+
return False
1295+
1296+
if not classifications:
1297+
self.console.print("[yellow]No new classifications recorded.[/yellow]")
1298+
return False
1299+
1300+
append = existing_size > 0
1301+
try:
1302+
with open(csv_path, "a" if append else "w", newline="") as f:
1303+
writer = csv.writer(f)
1304+
if not append:
1305+
writer.writerow([*id_cols, "classification"])
1306+
for key, label in classifications:
1307+
if isinstance(first_key, tuple):
1308+
writer.writerow([*(str(val) for _, val in key), label])
1309+
else:
1310+
writer.writerow([str(key), label])
1311+
except OSError as e:
1312+
self.console.print(f"[red]Error:[/red] failed to write {csv_path}: {e}")
1313+
return False
1314+
1315+
verb = "Appended" if append else "Wrote"
1316+
suffix = " [dim](finished early)[/dim]" if finished_early else ""
1317+
self.console.print(
1318+
f"[green]✓[/green] {verb} [cyan]{len(classifications)}[/cyan] "
1319+
f"classification(s) to [cyan]{csv_path}[/cyan]{suffix}"
1320+
)
1321+
return False
1322+
10871323
def cmd_types(self, args: list) -> bool:
10881324
if not self.edges:
10891325
self.console.print("[yellow]No edges loaded.[/yellow]")

0 commit comments

Comments
 (0)