feat(vector): index files in real time on vector-index tag changes

Tagging an existing file/folder emits only OCP\SystemTag\MapperEvent — never a
Node*Event — so tagged PDFs were previously only picked up by the hourly
scanner. Subscribe to the tag event and reconcile membership so adding/removing
the `vector-index` tag (re)indexes in near-real time.

- webhook_presets: add OCP\SystemTag\MapperEvent to the files_sync preset
  (NC 32+, where MapperEvent gained getWebhookSerializable(); harmless on older
  servers — it just never fires).
- webhook_parser: parse MapperEvent (objectType=files) into a path-less file
  "reconcile" task. The payload carries only a fileid + tagIds (no name/path),
  so assign and unassign both collapse to a reconcile.
- processor._reconcile_tag_event: resolve the fileid against the user's current
  vector-index PDFs (find_files_by_tag). Present -> index with the resolved
  path/etag; absent -> flip to delete. Naturally handles "an unrelated tag
  changed" and a tagged folder's own fileid (no-op; the scanner still expands
  folders to descendants).
- Unit tests for the parser branch and the reconcile.

The matching admin-UI preset change ships separately in the astrolabe app repo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-06-05 16:20:05 +02:00
co-authored by Claude Opus 4.8
parent 78f3f284a6
commit 1322e5aba0
6 changed files with 313 additions and 4 deletions
+91
View File
@@ -216,3 +216,94 @@ def test_missing_time_field_defaults_to_zero():
task = extract_document_task(payload)
assert task is not None
assert task.modified_at == 0
# --- SystemTag MapperEvent (assign/unassign) -------------------------------
#
# NC 32+ serializes the event as {eventType, objectType, objectId, tagIds}.
# Both directions collapse to a path-less file "reconcile" task; the processor
# resolves current vector-index membership to decide index-vs-delete.
_TAG_MAPPER = "OCP\\SystemTag\\MapperEvent"
@pytest.mark.unit
def test_tag_assign_event_returns_reconcile_task():
payload = {
"user": {"uid": "alice"},
"time": 1762850245,
"event": {
"class": _TAG_MAPPER,
"eventType": "OCP\\SystemTag\\ISystemTagObjectMapper::assignTags",
"objectType": "files",
"objectId": "478087",
"tagIds": [7],
},
}
task = extract_document_task(payload)
assert task is not None
assert task.user_id == "alice"
assert task.doc_type == "file"
assert task.doc_id == "478087"
assert task.operation == "index"
# No path in the payload -> the processor reconciles membership.
assert task.file_path is None
assert task.modified_at == 1762850245
@pytest.mark.unit
def test_tag_unassign_event_also_returns_reconcile_task():
"""Unassign collapses to the same reconcile (processor flips to delete)."""
payload = {
"user": {"uid": "alice"},
"time": 1762850245,
"event": {
"class": _TAG_MAPPER,
"eventType": "OCP\\SystemTag\\ISystemTagObjectMapper::unassignTags",
"objectType": "files",
"objectId": "478087",
"tagIds": [7],
},
}
task = extract_document_task(payload)
assert task is not None
assert task.doc_type == "file"
assert task.operation == "index"
assert task.file_path is None
@pytest.mark.unit
def test_tag_event_non_files_object_type_returns_none():
"""Tags on non-file objects (e.g. comments) don't drive vector sync."""
payload = {
"user": {"uid": "alice"},
"time": 1762850245,
"event": {
"class": _TAG_MAPPER,
"eventType": "OCP\\SystemTag\\ISystemTagObjectMapper::assignTags",
"objectType": "comments",
"objectId": "12",
"tagIds": [7],
},
}
assert extract_document_task(payload) is None
@pytest.mark.unit
def test_tag_event_missing_object_id_returns_none():
payload = {
"user": {"uid": "alice"},
"time": 1762850245,
"event": {
"class": _TAG_MAPPER,
"eventType": "OCP\\SystemTag\\ISystemTagObjectMapper::assignTags",
"objectType": "files",
"objectId": "",
"tagIds": [7],
},
}
assert extract_document_task(payload) is None