Skip to content

Commit 0436eb3

Browse files
fix(ingestion): resolve Slack authors by real_name, not display_name (#580)
* fix(ingestion): resolve Slack authors by real_name, not display_name Slack messages store author ids, so every speaker, @mention, and DM label is resolved through the export's users.json roster. That lookup preferred profile.display_name, which in a real export is frequently a short handle ("morgan"). Zep merges entities by the names it sees in text, so a handle never merges with the same person written in full ("Morgan Lee") in an email or document: one person silently becomes two nodes, and half their facts hang off each. Verified against a live graph — ingesting a handle-based export alongside full-name documents produced both 'morgan' and 'Morgan Lee' as Person nodes; after this change it produces one. Prefer real_name, then display_name, then the username, then the raw id. Slack's own precedence is the opposite, but it optimizes for how a name reads in a chat client, not for entity resolution. Report authors whose roster entry has no real_name, counting only those whose content was actually ingested (including via an @mention) so a handle-only user who never posted is not noise. Expose the raw Slack id as SlackMessage.user_id so formatter= can substitute names from a directory of your own when the roster is thin. Both fixtures previously wrote display_name so it could never behave like a display name — the example roster left it empty and the test roster put a full name in it — which is why this went unnoticed. They now carry realistic handles, making the fixtures a regression guard: reverting only the loader change fails nine tests. Co-Authored-By: Claude <noreply@anthropic.com> * fix(ingestion): report weak Slack names that reach the graph via a DM label A DM or group DM is labeled by its members, and that label is written into every episode's text and metadata. It is built straight from the roster in _label(), never through _resolve(), so a member whose only name is a handle was put into the graph untracked: if they never authored a message and were never @mentioned, _weak_names stayed empty and no warning was emitted. Carry the roster ids a label names on _Conversation and record the weak ones in _load_conversation, which is the first point that knows the conversation was both selected and non-empty. Recording them in _label() instead would warn about members of a conversation the run skipped — a private DM excluded by the default conversation_types — and recording them on selection alone would warn for a selected folder that yielded no episodes. The warning now says "named in ingested content" rather than "whose content was ingested", which no longer fits a member who never posted. Co-Authored-By: Claude <noreply@anthropic.com> * fix(ingestion): only report weak Slack names from messages that were kept _normalize_text resolves @mentions, and it runs before a message is known to be usable: the ts / thread_ts checks come after it, and the duplicate-ts drop happens later still in _load_conversation. A handle mentioned only by a message this run threw away therefore landed in _weak_names, and the warning claimed it was named in ingested content when nothing of it reached the graph. Buffer weak names per message in _parse and promote them in _load_conversation once the message is accepted. Buffering rather than reordering _parse keeps the existing skip precedence intact — moving the ts checks above the empty-text check would start counting _invalid_ts for messages dropped for having no text. _unresolved_users deliberately keeps recording immediately: it reports ids "referenced in messages", which is true whether or not the message survived, whereas a weak name is reported as "named in ingested content". Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 823125d commit 0436eb3

6 files changed

Lines changed: 408 additions & 22 deletions

File tree

ingestion/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ structured data into Context Graphs correctly.
4646
size, metadata keys, UUIDs, RFC3339 timestamps, SCREAMING_SNAKE fact names, …)
4747
is checked before the first network call — a bad item is a clear Python error
4848
naming the field, not an HTTP 400 mid-run.
49+
- **Canonical Slack names:** speakers, `@mentions`, and DM labels resolve through
50+
the export roster preferring `profile.real_name` over `profile.display_name`,
51+
so a workspace handle ("morgan") does not split one person from the full name
52+
used in other sources ("Morgan Lee"). Authors with no `real_name` are reported
53+
in `warnings`, and `SlackMessage.user_id` exposes the raw Slack id so
54+
`formatter=` can substitute names from your own directory.
4955
- **Runnable examples and sample data** for the Slack, document, email,
5056
JSON-record, thread-backfill, fact-triple, and user-graph paths, built around
5157
one coherent sample dataset.

ingestion/README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,26 @@ Blake Carter") instead of the opaque id or slug Slack names their folder with
111111
raw ids degrade entity extraction — and every episode carries its
112112
`conversation_type` in `metadata` for filtering at search time.
113113

114+
**Slack names:** messages store author *ids*, so every speaker, `@mention`, and
115+
DM label is resolved through the export's `users.json` roster, preferring
116+
`profile.real_name` over `profile.display_name` (then the username, then the raw
117+
id). Slack's own precedence is the opposite, but it optimizes for how a name
118+
reads in a chat client: a display name is often a short handle ("morgan") that
119+
Zep cannot merge with the same person written in full ("Morgan Lee") in an email
120+
or document, which silently splits one person into two nodes. Authors whose
121+
roster entry has no `real_name` are counted in `result.warnings`. When your
122+
roster is thin, `formatter=` receives each `SlackMessage` — including its raw
123+
`user_id` — so you can substitute names from your own directory:
124+
125+
```python
126+
ingest_slack_export(
127+
client,
128+
"export.zip",
129+
graph_id="team_knowledge",
130+
formatter=lambda m: f"{DIRECTORY.get(m.user_id, m.sender)}: {m.text}",
131+
)
132+
```
133+
114134
**Batch vs sequential:** the Batch API (fast, 50k items/batch) is the default
115135
high-throughput submission path. `method="auto"` tries batch and transparently
116136
falls back to sequential `graph.add` calls with rate-limit-aware pacing in

ingestion/examples/data/slack_export/users.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@
44
"name": "avery-brown",
55
"profile": {
66
"real_name": "Avery Brown",
7-
"display_name": ""
7+
"display_name": "avery"
88
}
99
},
1010
{
1111
"id": "U002",
1212
"name": "blake-carter",
1313
"profile": {
1414
"real_name": "Blake Carter",
15-
"display_name": ""
15+
"display_name": "blake"
1616
}
1717
},
1818
{

ingestion/src/zep_ingest/loaders/slack.py

Lines changed: 110 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,13 @@
9191
_LINK_BARE = re.compile(r"<(https?://[^>]+)>")
9292

9393

94+
def _looks_like_handle(name: str) -> bool:
95+
"""True for a single-token name ("morgan"), which reads as a Slack handle
96+
rather than a person. A bare first name fails to merge with the full name
97+
just as a handle does, so both are reported."""
98+
return not any(character.isspace() for character in name)
99+
100+
94101
@dataclass(slots=True)
95102
class SlackMessage:
96103
sender: str
@@ -99,6 +106,10 @@ class SlackMessage:
99106
channel: str # the readable conversation label: a channel name, or DM members
100107
thread_ts: str | None = None
101108
conversation_type: ConversationType = "public_channel"
109+
# The raw Slack ID behind ``sender``, so a formatter= can substitute names
110+
# from its own directory when the export's roster is thin. None for a bot
111+
# post, which Slack writes with a username instead of a user id.
112+
user_id: str | None = None
102113

103114

104115
@dataclass(slots=True)
@@ -108,6 +119,11 @@ class _Conversation:
108119
folder: str
109120
label: str
110121
kind: ConversationType
122+
# roster ids rendered into ``label`` (DMs and group DMs only). The label names
123+
# them in every episode without going through _resolve, so they are carried
124+
# here and recorded once the conversation is known to be both selected and
125+
# non-empty — a skipped conversation must not warn about its members.
126+
member_ids: tuple[str, ...] = ()
111127

112128

113129
class _DirReader:
@@ -286,6 +302,13 @@ def __init__(
286302
self.formatter = formatter or _default_formatter
287303
self.warnings: list[str] = []
288304
self._unresolved_users: set[str] = set()
305+
# roster ids whose best name is not a person's full name, and the subset
306+
# of those actually used by ingested content (id -> the name used)
307+
self._weak_name_ids: frozenset[str] = frozenset()
308+
self._weak_names: dict[str, str] = {}
309+
# weak names seen while parsing one message, promoted to _weak_names only
310+
# once that message survives validation (see _resolve)
311+
self._pending_weak_names: dict[str, str] = {}
289312
self._duplicate_ts = 0
290313
self._invalid_ts = 0
291314

@@ -294,11 +317,13 @@ def load(self) -> Iterator[Episode]:
294317
# both reset per pass: a second load() re-derives them, and appending to
295318
# the previous pass's list would report every warning twice
296319
self._unresolved_users = set()
320+
self._weak_names = {}
321+
self._pending_weak_names = {}
297322
self._duplicate_ts = 0
298323
self._invalid_ts = 0
299324
self.warnings = []
300325
roster = self._read_roster(reader)
301-
users = self._user_map(roster)
326+
users, self._weak_name_ids = self._user_map(roster)
302327
inventory = self._inventory(reader, users)
303328
if roster is None and not inventory:
304329
raise ConfigurationError(
@@ -336,6 +361,16 @@ def _summarize(self) -> None:
336361
"messages were absent from the roster (typically deactivated, bot, "
337362
"or Slack Connect users) and were left as raw IDs."
338363
)
364+
if self._weak_names:
365+
examples = ", ".join(sorted(self._weak_names.values())[:3])
366+
self.warnings.append(
367+
f"{len(self._weak_names)} Slack user(s) named in ingested content have "
368+
f"no real_name in the roster, so they are labeled with a display-name "
369+
f"handle, a username, or a raw ID instead (e.g. {examples}). Zep merges "
370+
"entities by the names it sees, so these may not merge with the same "
371+
"person written in full in another source. Populate real_name in the "
372+
"export, or pass formatter= and map SlackMessage.user_id to your own names."
373+
)
339374
if self._invalid_ts:
340375
self.warnings.append(
341376
f"{self._invalid_ts} Slack message(s) had a timestamp that is not a "
@@ -361,17 +396,41 @@ def _read_roster(reader: _DirReader | _ZipReader) -> Any:
361396
return None
362397

363398
@staticmethod
364-
def _user_map(roster: Any) -> dict[str, str]:
399+
def _user_map(roster: Any) -> tuple[dict[str, str], frozenset[str]]:
400+
"""Map each Slack user ID to the best name the roster offers.
401+
402+
``real_name`` is preferred over ``display_name``. Zep merges entities by
403+
the names it sees in text, and a Slack display name is frequently a short
404+
handle ("morgan") that will not merge with the same person written in full
405+
("Morgan Lee") in an email or document, splitting one person into two
406+
nodes. Slack's own precedence is the opposite, but it optimizes for how a
407+
name reads in a chat client, not for entity resolution.
408+
409+
Returns the mapping plus the IDs whose name is *not* a person's full name,
410+
so the run can warn about the ones it actually used.
411+
"""
365412
mapping: dict[str, str] = {}
413+
weak: set[str] = set()
366414
for user in roster or []:
367415
profile = user.get("profile") or {}
368-
mapping[user["id"]] = (
369-
profile.get("display_name")
370-
or profile.get("real_name")
371-
or user.get("name")
372-
or user["id"]
373-
)
374-
return mapping
416+
real_name = (profile.get("real_name") or "").strip()
417+
display_name = (profile.get("display_name") or "").strip()
418+
username = (user.get("name") or "").strip()
419+
if real_name:
420+
name = real_name
421+
elif display_name:
422+
name = display_name
423+
if _looks_like_handle(display_name):
424+
weak.add(user["id"])
425+
elif username:
426+
# a username slug ("morgan.lee") is a poor entity name
427+
name = username
428+
weak.add(user["id"])
429+
else:
430+
name = user["id"]
431+
weak.add(user["id"])
432+
mapping[user["id"]] = name
433+
return mapping, frozenset(weak)
375434

376435
def _inventory(
377436
self, reader: _DirReader | _ZipReader, users: dict[str, str]
@@ -392,9 +451,8 @@ def _inventory(
392451
if folder in seen:
393452
continue
394453
seen.add(folder)
395-
conversations.append(
396-
_Conversation(folder, self._label(entry, folder, kind, users), kind)
397-
)
454+
label, member_ids = self._label(entry, folder, kind, users)
455+
conversations.append(_Conversation(folder, label, kind, member_ids))
398456
if conversations:
399457
return conversations
400458
return self._folder_inventory(reader)
@@ -440,15 +498,19 @@ def _validated_folder(folder: Any) -> str:
440498
@staticmethod
441499
def _label(
442500
entry: dict[str, Any], folder: str, kind: ConversationType, users: dict[str, str]
443-
) -> str:
501+
) -> tuple[str, tuple[str, ...]]:
444502
"""Channels are labeled by name; DMs and group DMs by their members, since
445-
their folders are an opaque id or slug and raw ids degrade extraction."""
503+
their folders are an opaque id or slug and raw ids degrade extraction.
504+
505+
Returns the label and the roster ids it names, so the caller can report a
506+
member whose name is only a handle even if they never posted.
507+
"""
446508
if kind not in ("dm", "group_dm"):
447-
return folder
509+
return folder, ()
448510
members = [m for m in entry.get("members") or [] if isinstance(m, str)]
449511
if not members:
450-
return folder
451-
return ", ".join(users.get(member, member) for member in members)
512+
return folder, ()
513+
return ", ".join(users.get(member, member) for member in members), tuple(members)
452514

453515
def _select(self, inventory: list[_Conversation]) -> list[_Conversation]:
454516
"""conversation_types picks the types; channels= filters by name within them."""
@@ -505,7 +567,13 @@ def _load_conversation(
505567
continue
506568
seen_ts.add(message.ts)
507569
messages.append(message)
570+
# accepted, so the names its text carries really do reach the graph
571+
self._weak_names.update(self._pending_weak_names)
508572
messages.sort(key=lambda m: float(m.ts))
573+
if messages:
574+
# every episode below carries conversation.label, so the members it
575+
# names are now in the graph whether or not they authored anything
576+
self._note_label_names(conversation, users)
509577
if self.grouping == "message":
510578
for message in messages:
511579
yield self._episode([message], conversation)
@@ -524,6 +592,10 @@ def _load_conversation(
524592
def _parse(
525593
self, raw: dict[str, Any], conversation: _Conversation, users: dict[str, str]
526594
) -> SlackMessage | None:
595+
# @mentions are resolved while normalizing text below, which happens before
596+
# this message is known to be usable; buffer what that records so a message
597+
# dropped further down does not claim its mentions reached the graph
598+
self._pending_weak_names = {}
527599
if raw.get("subtype") in self.skip_subtypes:
528600
return None
529601
# bot_message is the subtype Slack gives an app post; most carry a bot_id
@@ -561,14 +633,34 @@ def _parse(
561633
channel=conversation.label,
562634
thread_ts=raw.get("thread_ts"),
563635
conversation_type=conversation.kind,
636+
user_id=raw.get("user") or None,
564637
)
565638

639+
def _note_label_names(self, conversation: _Conversation, users: dict[str, str]) -> None:
640+
"""Record weak names a DM label puts into the graph. Called only for a
641+
selected conversation that yielded episodes, so members of a conversation
642+
the run skipped are never reported."""
643+
for member in conversation.member_ids:
644+
if member in self._weak_name_ids:
645+
self._weak_names[member] = users[member]
646+
566647
def _resolve(self, user_id: str, users: dict[str, str]) -> str:
567-
"""Map a Slack user ID to a display name, recording IDs the roster misses."""
648+
"""Map a Slack user ID to a name, recording IDs the roster misses and the
649+
names that are not a person's full name.
650+
651+
The two are recorded at different times on purpose, because they claim
652+
different things: an unresolved id was "referenced in messages", which
653+
holds even for a message this run goes on to drop, while a weak name is
654+
reported as "named in ingested content", which does not. Weak names
655+
therefore go to a per-message buffer that _load_conversation promotes only
656+
once the message is accepted.
657+
"""
568658
name = users.get(user_id)
569659
if name is None:
570660
self._unresolved_users.add(user_id)
571661
return user_id
662+
if user_id in self._weak_name_ids:
663+
self._pending_weak_names[user_id] = name
572664
return name
573665

574666
def _normalize_text(self, text: str, users: dict[str, str]) -> str:

ingestion/tests/fixtures/slack_export/users.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"id": "U001",
44
"name": "avery-brown",
55
"profile": {
6-
"display_name": "Avery Brown",
6+
"display_name": "avery",
77
"real_name": "Avery Brown"
88
}
99
},

0 commit comments

Comments
 (0)