Add /graph — a knowledge graph over a query's result set - #37
Merged
Merged
Conversation
A list of search results says what matched. It does not say what the material is, how it hangs together, or who owns it. /graph answers that: documents, their authors, their datasources and their containers, with an edge for every relationship and the evidence that produced it. Glean's Client API has no graph endpoint, so the graph is synthesised from the search response — which already carries author, datasource, container and snippets. Three consequences, all stated in the docs rather than glossed: it is the graph of one query's result set and not a crawl of the index, it works identically in mock, local and live because all three return that shape, and nothing leaves the process. Four node kinds (doc, person, source, container) and four edge kinds. Three are structural. The fourth, shares_term, is the one worth having: title and snippet vocabulary weighted by rarity within the result set, so terms carried by most of the results — usually the query itself — are dropped before scoring, and what is left is what actually separates one pair of documents from the rest. Every edge names its shared words, so "shares capacity, model, fy26 (4.7)" can be judged instead of believed. Same principle as /flow's links. Two renderers: a terminal summary (counts by kind, hubs by degree, connected clusters, strongest content links) and --html, a self-contained force-directed page under the same rule as /flow timeline — no CDN, no framework, no network. Deterministic ring seeding, so the same graph opens the same way; the view frames the graph while it relaxes and yields the moment the reader pans. Two bugs the tests found on the way: - A term carried by every document scores log(1) = 0, which drew zero-weight edges between documents sharing nothing but the query. Those terms are dropped and a non-positive score no longer draws an edge. - The detail panel built markup from document titles and edge evidence — tenant content — through innerHTML. Everything interpolated is escaped now, and a document link is only rendered when its url is really http(s), so a javascript: url in a connector's metadata cannot become a live link. Tests: 1,109 -> 1,148. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A list of search results says what matched. It doesn't say what the material is, how it hangs together, or who owns it.
/graphanswers that.Why it's built locally
The Client API has no graph endpoint. What it does return on every result is the author, datasource, container and snippets — enough to build the graph client-side. Three consequences, stated in the docs rather than glossed over:
--page-sizeis the size of the graph.The model
docauthored_bydoc→personpersonmetadata.authorin_sourcedoc→sourcesourcemetadata.datasourcein_containerdoc→containercontainermetadata.containershares_termdoc↔doc, weighted by rarityshares_termis the one worth having. Title and snippet vocabulary is weighted by how rare each term is within the result set, so terms carried by most of the results — usually the query itself — are dropped before scoring. What's left is what actually separates one pair of documents from the rest, and every edge names its shared words:So
shares capacity, model, fy26 (4.7)can be judged rather than believed — the same principle/flowlinks on.Two renderers
Terminal — counts by kind, hubs by degree, connected clusters with the node anchoring each, and the strongest content links with their evidence.
--html— a self-contained force-directed page under the same rule as/flow timeline: no CDN, no framework, no network. Deterministic ring seeding so the same graph opens the same way, nodes coloured by kind and sized by degree, click a node for every edge it has with evidence and score. The view frames the graph while it relaxes and yields the moment you pan.prefers-reduced-motiondraws the settled layout directly.Two bugs the tests found
log(1) = 0, which drew edges between documents sharing nothing but the query. Those terms are dropped, and a non-positive score no longer draws an edge.innerHTML. Everything interpolated is escaped now, and a document link is only rendered when its url is reallyhttp(s), so ajavascript:url in a connector's metadata can't become a live link.Tests
1,109 → 1,148, in
tests/test_graph.py: author dedup across documents, structural vs term edges, the rarity filter,--min-shared, empty and malformed results, summary ordering, cluster detection, terminal truncation at width, HTML self-containment, deterministic layout, the escaping above, and the command itself in mock mode.Docs: docs/GRAPH.md for the model and scoring, an entry in docs/COMMANDS.md, a
DOCSentry so/help graphand the NL planner both pick it up automatically, and two README rows.🤖 Generated with Claude Code