Skip to content

Commit f36ac63

Browse files
authored
Merge pull request #45 from peteromallet/reliability/merge-and-privacy-filter
Reliable dataset merge-on-upload + restore model privacy filter
2 parents 4786464 + a4ff9f7 commit f36ac63

11 files changed

Lines changed: 2180 additions & 26 deletions

dataclaw/_cli/commands.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -239,9 +239,13 @@ def run_export(
239239
has_session_sources_fn: Callable[[str], bool],
240240
export_to_jsonl_fn: Callable[..., dict],
241241
summarize_jsonl_fn: Callable[[Path], dict],
242-
push_to_huggingface_fn: Callable[[Path, str, dict], None],
242+
push_to_huggingface_fn: Callable[..., None],
243243
) -> None:
244244
config = load_config_fn()
245+
redaction = {
246+
"redact_strings": config.get("redact_strings", []) or [],
247+
"redact_usernames": config.get("redact_usernames", []) or [],
248+
}
245249
source_choice, source_explicit = _resolve_source_choice(args.source, config)
246250
source_filter = _normalize_source_filter(source_choice)
247251

@@ -420,7 +424,13 @@ def run_export(
420424
_print_export_elapsed(export_start_time)
421425
return
422426

423-
push_to_huggingface_fn(confirmed_file, repo_id, meta)
427+
push_to_huggingface_fn(confirmed_file, repo_id, meta, redaction)
428+
429+
# push_to_huggingface updates meta["sessions"] to the merged remote+local total;
430+
# keep last_export.sessions consistent with what was actually published (H5).
431+
last_export = config.get("last_export")
432+
if isinstance(last_export, dict):
433+
last_export["sessions"] = meta.get("sessions", last_export.get("sessions"))
424434

425435
config["stage"] = "done"
426436
save_config_fn(config)
@@ -596,7 +606,11 @@ def run_export(
596606
_print_export_elapsed(export_start_time)
597607
return
598608

599-
push_to_huggingface_fn(output_path, repo_id, meta)
609+
push_to_huggingface_fn(output_path, repo_id, meta, redaction)
610+
611+
last_export = config.get("last_export")
612+
if isinstance(last_export, dict):
613+
last_export["sessions"] = meta.get("sessions", last_export.get("sessions"))
600614

601615
config["stage"] = "done"
602616
save_config_fn(config)

dataclaw/_cli/exporting.py

Lines changed: 300 additions & 13 deletions
Large diffs are not rendered by default.

dataclaw/jsonl_tools.py

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,208 @@ class DiffResult:
135135
summary: dict[str, int]
136136

137137

138+
@dataclass
139+
class MergeStats:
140+
"""Outcome of a union merge of remote + local JSONL records."""
141+
142+
remote_total: int = 0
143+
local_total: int = 0
144+
merged_total: int = 0
145+
added: int = 0 # local-only records added
146+
updated: int = 0 # records present in both where the local copy won (superset)
147+
carried_forward: int = 0 # remote-only records preserved (re-redacted)
148+
unchanged: int = 0 # records present in both where the remote copy won (re-redacted)
149+
malformed_preserved: int = 0 # unparseable lines carried through verbatim
150+
151+
def changelog_line(self) -> str:
152+
line = (
153+
f"Merge: added {self.added}, updated {self.updated}, "
154+
f"carried_forward {self.carried_forward}, unchanged {self.unchanged} "
155+
f"(remote {self.remote_total} -> merged {self.merged_total})"
156+
)
157+
if self.malformed_preserved:
158+
line += f"; preserved {self.malformed_preserved} unparseable line(s) verbatim"
159+
return line
160+
161+
162+
def merge_identity_key(obj: dict[str, Any]) -> tuple[Any, ...]:
163+
"""Dedup key for the union merge.
164+
165+
Uses ``(source, session_id)`` when ``session_id`` is truthy, otherwise falls
166+
back to the full ``identity_key()`` tuple. Keying on ``session_id`` avoids the
167+
``start_time`` format drift (H1) and anonymized ``project`` drift (H2) bugs that
168+
would otherwise let one session appear under two keys (duplicate + leak).
169+
"""
170+
session_id = obj.get("session_id")
171+
if session_id:
172+
return ("sid", obj.get("source"), session_id)
173+
return ("identity", *identity_key(obj))
174+
175+
176+
def _message_count(obj: dict[str, Any]) -> int:
177+
messages = obj.get("messages")
178+
return len(messages) if isinstance(messages, list) else 0
179+
180+
181+
def _end_time(obj: dict[str, Any]) -> str:
182+
end_time = obj.get("end_time")
183+
return end_time if isinstance(end_time, str) else ""
184+
185+
186+
def _record_prefers(candidate: dict[str, Any], current: dict[str, Any]) -> bool:
187+
"""Return True if ``candidate`` should replace ``current`` in the union.
188+
189+
Tie-break order: more messages, then larger canonical byte size, then later
190+
``end_time``. A tie on all three keeps ``current`` (caller decides which side
191+
that is so "unchanged vs updated" classification stays meaningful).
192+
"""
193+
cand_messages = _message_count(candidate)
194+
cur_messages = _message_count(current)
195+
if cand_messages != cur_messages:
196+
return cand_messages > cur_messages
197+
198+
cand_bytes = len(canonical_record_bytes(candidate))
199+
cur_bytes = len(canonical_record_bytes(current))
200+
if cand_bytes != cur_bytes:
201+
return cand_bytes > cur_bytes
202+
203+
return _end_time(candidate) > _end_time(current)
204+
205+
206+
def _load_raw_records(path: Path) -> tuple[list[dict[str, Any]], list[bytes]]:
207+
"""Load JSONL records RAW (no diff normalization, preserves ``originalFile``).
208+
209+
Returns ``(records, malformed_lines)``. A line that is not valid JSON (or not a
210+
JSON object) is returned verbatim in ``malformed_lines`` rather than raising or
211+
being dropped: the merge preserves it so a single corrupt remote line can never
212+
silently lose data nor permanently wedge all future pushes.
213+
"""
214+
records: list[dict[str, Any]] = []
215+
malformed: list[bytes] = []
216+
with path.open("rb") as handle:
217+
for line in handle:
218+
stripped = line.strip()
219+
if not stripped:
220+
continue
221+
try:
222+
obj = orjson.loads(stripped)
223+
except ValueError: # orjson.JSONDecodeError subclasses ValueError
224+
malformed.append(stripped)
225+
continue
226+
if isinstance(obj, dict):
227+
records.append(obj)
228+
else:
229+
malformed.append(stripped)
230+
return records, malformed
231+
232+
233+
def merge_jsonl_union(
234+
remote_path: Path,
235+
local_path: Path,
236+
output_path: Path,
237+
*,
238+
redact_fn: Callable[[dict[str, Any]], dict[str, Any]],
239+
) -> MergeStats:
240+
"""Union-merge remote + local JSONL into ``output_path``.
241+
242+
Rules (keyed by :func:`merge_identity_key`):
243+
- remote-only -> carry forward, re-redacted through ``redact_fn``
244+
- local-only -> add (already current-redacted upstream, passes through)
245+
- both -> keep the superset (see :func:`_record_prefers`)
246+
247+
Local records are written first when they win; carried-forward remote records
248+
are always re-redacted via ``redact_fn`` before being written, which closes the
249+
old-policy redaction-republish hole. The merge preserves first-seen ordering
250+
(remote order, then any local-only additions) and guarantees
251+
``merged_total >= remote_total``.
252+
253+
``redact_fn`` is injected so this module stays free of import cycles with the
254+
redaction pipeline.
255+
"""
256+
remote_records, remote_malformed = _load_raw_records(remote_path)
257+
local_records, local_malformed = _load_raw_records(local_path)
258+
# Preserve unparseable lines verbatim (deduped by exact bytes) so a corrupt
259+
# remote line is never dropped (data loss) nor allowed to abort the push (wedge).
260+
malformed = list(dict.fromkeys(remote_malformed + local_malformed))
261+
262+
# Totals are counted by UNIQUE merge key, not raw line count. A remote file
263+
# written by the old non-deduping uploader can contain duplicate
264+
# (source, session_id) lines; counting raw lines would make merged_total <
265+
# remote_total trip the union-invariant guard and permanently block publishing
266+
# even though no session was dropped. (Set precisely after the build below.)
267+
stats = MergeStats()
268+
269+
# Build winning record per key, tracking origin (which side won) and which sides
270+
# the key appeared on, so we can classify the change and re-redact correctly.
271+
order: list[tuple[Any, ...]] = []
272+
winners: dict[tuple[Any, ...], dict[str, Any]] = {}
273+
origin: dict[tuple[Any, ...], str] = {} # "remote" or "local" (winning side)
274+
in_remote: set[tuple[Any, ...]] = set()
275+
in_local: set[tuple[Any, ...]] = set()
276+
277+
for record in remote_records:
278+
key = merge_identity_key(record)
279+
in_remote.add(key)
280+
if key not in winners:
281+
order.append(key)
282+
winners[key] = record
283+
origin[key] = "remote"
284+
elif _record_prefers(record, winners[key]):
285+
winners[key] = record
286+
origin[key] = "remote"
287+
288+
for record in local_records:
289+
key = merge_identity_key(record)
290+
in_local.add(key)
291+
if key not in winners:
292+
order.append(key)
293+
winners[key] = record
294+
origin[key] = "local"
295+
elif _record_prefers(record, winners[key]):
296+
winners[key] = record
297+
origin[key] = "local"
298+
299+
output_path.parent.mkdir(parents=True, exist_ok=True)
300+
merged_total = 0
301+
with output_path.open("wb") as handle:
302+
for key in order:
303+
record = winners[key]
304+
seen_remote = key in in_remote
305+
seen_local = key in in_local
306+
if origin[key] == "remote":
307+
# Winning side is a remote record: re-redact through the CURRENT
308+
# pipeline before publishing (closes the old-policy redaction hole).
309+
record = redact_fn(record)
310+
if seen_local:
311+
stats.unchanged += 1 # in both, remote copy won
312+
else:
313+
stats.carried_forward += 1 # remote-only
314+
else:
315+
# Winning side is a local record (already current-redacted upstream).
316+
if seen_remote:
317+
stats.updated += 1 # in both, local copy won (superset)
318+
else:
319+
stats.added += 1 # local-only
320+
handle.write(canonical_record_bytes(record))
321+
handle.write(b"\n")
322+
merged_total += 1
323+
324+
for raw in malformed:
325+
handle.write(raw)
326+
handle.write(b"\n")
327+
merged_total += 1
328+
329+
# Malformed remote lines count toward remote_total (by UNIQUE bytes, matching
330+
# the unique-key counting above) so the union invariant merged_total >=
331+
# remote_total accounts for the records they represent without false-tripping
332+
# on duplicate corrupt lines.
333+
stats.remote_total = len(in_remote) + len(set(remote_malformed))
334+
stats.local_total = len(in_local) + len(set(local_malformed))
335+
stats.merged_total = merged_total
336+
stats.malformed_preserved = len(malformed)
337+
return stats
338+
339+
138340
def clean_strings(obj: Any) -> Any:
139341
if isinstance(obj, str):
140342
text = ANSI_RE.sub("", obj)

0 commit comments

Comments
 (0)