@@ -125,13 +125,21 @@ def _build_parser_kwargs(parser_cls: type[Parser], settings: dict) -> dict[str,
125125
126126
127127class 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