Skip to content

Commit 20d27e9

Browse files
feat(clone): clone agentic verified-data; honour skip strategy for docs
Agentic verified-data ("ground truth manually verified by user") is curated input, not regenerable output, so it must be cloned. AgenticStudioPhase now re-points each source verified-data row to the cloned target document by filename and recreates it (skipping docs absent on target and rows already present). Extracted/comparison data stays uncloned — both regenerate on a re-run + re-verify. Also honour file_strategy="skip" in the new document path, matching the files and lookups phases: under skip, agentic documents are listed and counted as skipped (operator re-uploads), not transferred. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ja9H1rnSXmPUgQtHm8TNS
1 parent 9df7e52 commit 20d27e9

3 files changed

Lines changed: 221 additions & 1 deletion

File tree

src/unstract/clone/client.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -963,3 +963,20 @@ def upload_agentic_document(
963963
return self._request(
964964
"POST", f"agentic/projects/{project_id}/documents/upload/", files=files
965965
)
966+
967+
def list_agentic_verified_data(self, project_id: str) -> list[dict[str, Any]]:
968+
"""List a project's verified (ground-truth) data rows. Each carries
969+
``document_name``, ``document``, and ``data`` (the curated JSON).
970+
"""
971+
result = self._request(
972+
"GET", "agentic/verified-data/", params={"project_id": project_id}
973+
)
974+
return result if isinstance(result, list) else (result or {}).get("results", [])
975+
976+
def create_agentic_verified_data(self, payload: dict[str, Any]) -> dict[str, Any]:
977+
"""Create a verified-data row (``project``, ``document``, ``data``).
978+
979+
One row per (project, document); callers pre-check to avoid a
980+
uniqueness clash on re-runs.
981+
"""
982+
return self._request("POST", "agentic/verified-data/", json=payload)

src/unstract/clone/phases/agentic_studio.py

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@
2222
per-run ``agentic_prompt_version`` table and the child's ``parent_version``
2323
resolves through it. ``project`` is bound to the target id.
2424
- **schemas**: bound to the target ``project`` and created.
25+
- **documents**: source uploads re-uploaded to the target project (skipping
26+
filenames already present); they live in their own store, not the Prompt
27+
Studio document table.
28+
- **verified data**: ground-truth rows re-pointed to the cloned document by
29+
filename. Extracted/comparison data is regenerable and not cloned.
2530
3. **Registry**: if the project has an active schema + prompt, republish its
2631
registry entry via the ``export`` action (mirror of custom_tool) and
2732
record an ``agentic_studio_registry`` remap. Projects with no source
@@ -172,6 +177,7 @@ def _clone_project(
172177
self._clone_prompt_versions(name, src_project_id, tgt_project_id, result, lock)
173178
self._clone_schemas(name, src_project_id, tgt_project_id, result, lock)
174179
self._clone_documents(name, src_project_id, tgt_project_id, result, lock)
180+
self._clone_verified_data(name, src_project_id, tgt_project_id, result, lock)
175181
self._republish_registry(name, src_project_id, tgt_project_id, result, lock)
176182

177183
def _build_project_payload(
@@ -445,6 +451,17 @@ def _clone_documents(
445451
if not src_docs:
446452
return
447453

454+
# Honour the file strategy: 'skip' means no binary transfer, like the
455+
# files and lookups phases. Operator re-uploads on target.
456+
if self.ctx.options.file_strategy == "skip":
457+
with lock:
458+
result.skipped += len(src_docs)
459+
result.warnings.append(
460+
f"agentic {name}: {len(src_docs)} document(s) not copied "
461+
"(file_strategy=skip) — upload them manually on target"
462+
)
463+
return
464+
448465
try:
449466
tgt_docs = self.ctx.target.list_agentic_documents(tgt_project_id)
450467
except Exception as e:
@@ -521,6 +538,82 @@ def _clone_one_document(
521538
result.created += 1
522539
logger.info("agentic '%s': uploaded document '%s'", name, file_name)
523540

541+
# ----- verified (ground-truth) data -----
542+
543+
def _clone_verified_data(
544+
self,
545+
name: str,
546+
src_project_id: str,
547+
tgt_project_id: str,
548+
result: PhaseResult,
549+
lock: threading.Lock,
550+
) -> None:
551+
try:
552+
src_rows = self.ctx.source.list_agentic_verified_data(src_project_id)
553+
except Exception as e:
554+
logger.exception("agentic '%s': verified-data listing failed: %s", name, e)
555+
with lock:
556+
result.failed += 1
557+
result.errors.append(f"agentic {name} list verified-data: {e}")
558+
return
559+
if not src_rows:
560+
return
561+
562+
# Verified data FKs a document; map source rows to target docs by
563+
# filename, the only identity stable across orgs.
564+
try:
565+
tgt_docs = self.ctx.target.list_agentic_documents(tgt_project_id)
566+
tgt_existing = self.ctx.target.list_agentic_verified_data(tgt_project_id)
567+
except Exception as e:
568+
logger.warning(
569+
"agentic '%s': target verified-data lookup failed "
570+
"(re-run may duplicate): %s",
571+
name,
572+
e,
573+
)
574+
tgt_docs, tgt_existing = [], []
575+
doc_id_by_name = {d.get("original_filename"): d.get("id") for d in tgt_docs}
576+
verified_doc_ids = {r.get("document") for r in tgt_existing}
577+
578+
for src in src_rows:
579+
file_name = src.get("document_name")
580+
tgt_doc_id = doc_id_by_name.get(file_name)
581+
if not tgt_doc_id:
582+
with lock:
583+
result.skipped += 1
584+
result.warnings.append(
585+
f"agentic {name}: verified data for '{file_name}' skipped — "
586+
"document not on target"
587+
)
588+
continue
589+
if tgt_doc_id in verified_doc_ids:
590+
with lock:
591+
result.skipped += 1
592+
continue
593+
payload = {
594+
"project": tgt_project_id,
595+
"document": tgt_doc_id,
596+
"data": src.get("data"),
597+
}
598+
try:
599+
self.ctx.target.create_agentic_verified_data(payload)
600+
except Exception as e:
601+
logger.exception(
602+
"agentic '%s': verified data for '%s' create failed: %s",
603+
name,
604+
file_name,
605+
e,
606+
)
607+
with lock:
608+
result.failed += 1
609+
result.errors.append(
610+
f"agentic {name} create verified-data {file_name}: {e}"
611+
)
612+
continue
613+
with lock:
614+
result.created += 1
615+
logger.info("agentic '%s': cloned verified data for '%s'", name, file_name)
616+
524617
# ----- registry -----
525618

526619
def _republish_registry(
@@ -626,12 +719,21 @@ def _plan_children(
626719
src_docs = self.ctx.source.list_agentic_documents(src_project_id)
627720
except Exception:
628721
src_docs = []
722+
try:
723+
src_verified = self.ctx.source.list_agentic_verified_data(src_project_id)
724+
except Exception:
725+
src_verified = []
629726
with lock:
630727
for v in src_versions:
631728
self.ctx.remap.record_planned("agentic_prompt_version", v["id"])
632729
result.created += 1
633730
result.created += len(src_schemas)
634-
result.created += len(src_docs)
731+
# Documents move only when the file strategy copies binaries.
732+
if self.ctx.options.file_strategy == "skip":
733+
result.skipped += len(src_docs)
734+
else:
735+
result.created += len(src_docs)
736+
result.created += len(src_verified)
635737

636738
# ----- settings -----
637739

tests/clone/test_agentic_studio_phase.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ def __init__(
4141
users=None,
4242
documents=None,
4343
document_blobs=None,
44+
verified_data=None,
4445
):
4546
self.projects = list(projects or [])
4647
self.users = list(users or [])
@@ -56,6 +57,8 @@ def __init__(
5657
self.documents = {k: list(v) for k, v in (documents or {}).items()}
5758
# document_id -> raw bytes
5859
self.document_blobs = dict(document_blobs or {})
60+
# project_id -> list of verified-data rows
61+
self.verified_data = {k: list(v) for k, v in (verified_data or {}).items()}
5962

6063
self.created_projects: list[dict] = []
6164
self.created_versions: list[dict] = []
@@ -64,6 +67,7 @@ def __init__(
6467
self.updated_settings: list[tuple[str, dict]] = []
6568
self.exported_projects: list[str] = []
6669
self.uploaded_documents: list[tuple[str, str, bytes]] = []
70+
self.created_verified_data: list[dict] = []
6771
self._next_id = 1
6872

6973
def _mint(self, prefix: str) -> str:
@@ -162,6 +166,20 @@ def upload_agentic_document(self, project_id, file_name, data, mime_type):
162166
self.documents.setdefault(project_id, []).append(row)
163167
return {"data": [{"document_name": file_name}]}
164168

169+
# ----- verified data -----
170+
171+
def list_agentic_verified_data(self, project_id):
172+
return list(self.verified_data.get(project_id, []))
173+
174+
def create_agentic_verified_data(self, payload):
175+
new = dict(payload)
176+
new["id"] = self._mint("tgt-vd")
177+
self.created_verified_data.append(new)
178+
self.verified_data.setdefault(payload["project"], []).append(
179+
{"document": payload["document"], "data": payload["data"]}
180+
)
181+
return new
182+
165183

166184
def _ctx(source, target, *, remap=None, **opt_overrides):
167185
return CloneContext(
@@ -573,3 +591,86 @@ def test_dry_run_counts_documents_without_uploading():
573591
assert tgt.uploaded_documents == []
574592
# 1 project + 2 documents predicted as creates.
575593
assert result.created == 3
594+
595+
596+
def test_verified_data_cloned_mapped_to_target_doc_by_filename():
597+
src = FakeClient(
598+
projects=[_src_project("src-p", "Receipts")],
599+
documents={"src-p": [{"id": "d1", "original_filename": "report.pdf"}]},
600+
document_blobs={"d1": b"%PDF-r"},
601+
verified_data={
602+
"src-p": [
603+
{"document_name": "report.pdf", "document": "d1", "data": {"x": 1}},
604+
# Ground truth for a doc that won't exist on target → skipped.
605+
{"document_name": "gone.pdf", "document": "d9", "data": {"y": 2}},
606+
]
607+
},
608+
)
609+
tgt = FakeClient()
610+
ctx = _ctx(src, tgt)
611+
612+
result = AgenticStudioPhase(ctx).run(CloneReport())
613+
614+
assert result.failed == 0
615+
assert len(tgt.created_verified_data) == 1
616+
row = tgt.created_verified_data[0]
617+
# Re-pointed to the freshly uploaded target document, not the source id.
618+
uploaded_doc_id = tgt.documents[row["project"]][0]["id"]
619+
assert row["document"] == uploaded_doc_id
620+
assert row["data"] == {"x": 1}
621+
# 'gone.pdf' ground truth skipped (no matching target document).
622+
assert any("gone.pdf" in w for w in result.warnings)
623+
624+
625+
def test_verified_data_skipped_when_already_on_target():
626+
src = FakeClient(
627+
projects=[_src_project("src-p", "Receipts")],
628+
documents={"src-p": [{"id": "d1", "original_filename": "report.pdf"}]},
629+
verified_data={
630+
"src-p": [
631+
{"document_name": "report.pdf", "document": "d1", "data": {"x": 1}}
632+
]
633+
},
634+
)
635+
tgt = FakeClient()
636+
# Adopt an existing target project pre-seeded with the doc + its verified row.
637+
tgt.projects.append({"id": "tgt-proj-0001", "name": "Receipts"})
638+
tgt.documents["tgt-proj-0001"] = [{"id": "t-d1", "original_filename": "report.pdf"}]
639+
tgt.verified_data["tgt-proj-0001"] = [{"document": "t-d1", "data": {"x": 1}}]
640+
ctx = _ctx(src, tgt)
641+
642+
AgenticStudioPhase(ctx).run(CloneReport())
643+
644+
assert tgt.created_verified_data == []
645+
646+
647+
def test_dry_run_counts_verified_data_without_writing():
648+
src = FakeClient(
649+
projects=[_src_project("src-p", "Receipts")],
650+
verified_data={
651+
"src-p": [{"document_name": "a.pdf", "document": "d1", "data": {}}]
652+
},
653+
)
654+
tgt = FakeClient()
655+
ctx = _ctx(src, tgt, dry_run=True)
656+
657+
result = AgenticStudioPhase(ctx).run(CloneReport())
658+
659+
assert tgt.created_verified_data == []
660+
# 1 project + 1 verified-data row predicted as creates.
661+
assert result.created == 2
662+
663+
664+
def test_documents_not_copied_under_file_strategy_skip():
665+
src = FakeClient(
666+
projects=[_src_project("src-p", "Receipts")],
667+
documents={"src-p": [{"id": "d1", "original_filename": "report.pdf"}]},
668+
document_blobs={"d1": b"%PDF-r"},
669+
)
670+
tgt = FakeClient()
671+
ctx = _ctx(src, tgt, file_strategy="skip")
672+
673+
result = AgenticStudioPhase(ctx).run(CloneReport())
674+
675+
assert tgt.uploaded_documents == []
676+
assert result.skipped == 1

0 commit comments

Comments
 (0)