graphsight-langgraph 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,30 @@
1
+ """graphsight-langgraph — capture LangGraph runs as Graphsight traces.
2
+
3
+ from graphsight_langgraph import LangGraphTracer, to_tracestate
4
+
5
+ tracer = LangGraphTracer()
6
+ result = graph.invoke(inputs, config={"callbacks": [tracer]})
7
+ trace = tracer.finish(query="...", answer=result.get("answer"))
8
+ studio_json = to_tracestate(trace) # render in Graphsight Studio
9
+ """
10
+ from .mapper import to_tracestate
11
+ from .schema import (
12
+ SCHEMA_VERSION,
13
+ AgentTrace,
14
+ Retrieval,
15
+ RetrievedItem,
16
+ Span,
17
+ TraceEdge,
18
+ )
19
+ from .tracer import LangGraphTracer
20
+
21
+ __all__ = [
22
+ "SCHEMA_VERSION",
23
+ "AgentTrace",
24
+ "LangGraphTracer",
25
+ "Retrieval",
26
+ "RetrievedItem",
27
+ "Span",
28
+ "TraceEdge",
29
+ "to_tracestate",
30
+ ]
@@ -0,0 +1,286 @@
1
+ """graphsight-github-trace: GitHub repo -> traced agent run -> trace_state.json.
2
+
3
+ Fetches recent PRs/issues, runs a small traced LangGraph agent over them,
4
+ writes graphsight_out/. Needs the `example` extra for LangGraph.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ import os
11
+ import re
12
+ import sys
13
+ import urllib.error
14
+ import urllib.request
15
+ from pathlib import Path
16
+ from typing import Any, Optional
17
+
18
+ from langchain_core.callbacks import CallbackManagerForRetrieverRun
19
+ from langchain_core.documents import Document
20
+ from langchain_core.retrievers import BaseRetriever
21
+
22
+ from .mapper import to_tracestate
23
+ from .tracer import LangGraphTracer
24
+
25
+ _API = "https://api.github.com"
26
+ _RESOLVE_RE = re.compile(r"(?:fix(?:es|ed)?|close[sd]?|resolve[sd]?)\s+#(\d+)", re.IGNORECASE)
27
+ _WORD_RE = re.compile(r"[a-z0-9_#-]+")
28
+ _STOP = {
29
+ "the", "a", "an", "and", "or", "of", "in", "on", "to", "for", "is", "are",
30
+ "was", "were", "it", "this", "that", "with", "by", "at", "from", "what",
31
+ "who", "which", "how", "why", "when", "did", "do", "does", "recently",
32
+ "recent", "latest", "github", "user", "author",
33
+ }
34
+
35
+ # tie-break on equal lexical score: substantive artifacts over boilerplate
36
+ _KIND_PRIORITY = {"pull_request": 3, "ticket": 2, "repo": 1, "person": 0}
37
+
38
+
39
+ # github fetch, stdlib only
40
+ def _get(path: str, token: Optional[str]) -> Any:
41
+ req = urllib.request.Request(
42
+ f"{_API}{path}",
43
+ headers={
44
+ "Accept": "application/vnd.github+json",
45
+ "User-Agent": "graphsight-github-trace/0.1",
46
+ **({"Authorization": f"Bearer {token}"} if token else {}),
47
+ },
48
+ )
49
+ try:
50
+ with urllib.request.urlopen(req, timeout=30) as resp:
51
+ return json.loads(resp.read().decode("utf-8"))
52
+ except urllib.error.HTTPError as err:
53
+ if err.code in (401, 403):
54
+ raise SystemExit(
55
+ f"GitHub API {err.code} for {path}. Private repo or rate limit — "
56
+ "pass --token or set GITHUB_TOKEN."
57
+ ) from err
58
+ if err.code == 404:
59
+ raise SystemExit(f"GitHub API 404 for {path} — is the repo name right?") from err
60
+ raise SystemExit(f"GitHub API error {err.code} for {path}.") from err
61
+
62
+
63
+ def _excerpt(text: Optional[str], limit: int = 400) -> str:
64
+ return re.sub(r"\s+", " ", text or "").strip()[:limit]
65
+
66
+
67
+ # corpus
68
+ def build_corpus(repo: str, token: Optional[str], n_prs: int, n_issues: int) -> list[Document]:
69
+ owner_name = repo.split("/")[-1]
70
+ meta = _get(f"/repos/{repo}", token)
71
+ repo_id = f"repo_{owner_name}"
72
+ docs: list[Document] = [
73
+ Document(
74
+ page_content=_excerpt(
75
+ f"{repo}: {meta.get('description') or 'no description'}. "
76
+ f"Primary language {meta.get('language') or 'n/a'}, "
77
+ f"{meta.get('stargazers_count', 0)} stars, "
78
+ f"{meta.get('open_issues_count', 0)} open issues."
79
+ ),
80
+ metadata={
81
+ "id": repo_id, "label": owner_name, "kind": "repo",
82
+ "source": meta.get("html_url"),
83
+ },
84
+ )
85
+ ]
86
+
87
+ prs = _get(f"/repos/{repo}/pulls?state=all&sort=updated&direction=desc&per_page={n_prs}", token)
88
+ authored: dict[str, list[str]] = {}
89
+ for pr in prs:
90
+ num, login = pr["number"], (pr.get("user") or {}).get("login") or "unknown"
91
+ pr_id = f"pr_{num}"
92
+ edges = [{"source": pr_id, "target": repo_id, "relation": "TOUCHES", "weight": 0.7},
93
+ {"source": f"person_{login}", "target": pr_id, "relation": "AUTHORED", "weight": 0.9}]
94
+ for ref in _RESOLVE_RE.findall(pr.get("body") or ""):
95
+ edges.append({"source": pr_id, "target": f"tkt_{ref}", "relation": "RESOLVES", "weight": 0.85})
96
+ state = "merged" if pr.get("merged_at") else pr.get("state", "open")
97
+ docs.append(Document(
98
+ page_content=_excerpt(f"PR #{num} '{pr['title']}' by {login} ({state}). {pr.get('body') or ''}"),
99
+ metadata={"id": pr_id, "label": f"PR #{num}", "kind": "pull_request",
100
+ "source": pr.get("html_url"), "edges": edges},
101
+ ))
102
+ authored.setdefault(login, []).append(pr_id)
103
+
104
+ issues = _get(f"/repos/{repo}/issues?state=all&sort=updated&direction=desc&per_page={n_issues}", token)
105
+ for issue in issues:
106
+ if "pull_request" in issue: # the issues API also returns PRs; skip those
107
+ continue
108
+ num, login = issue["number"], (issue.get("user") or {}).get("login") or "unknown"
109
+ tkt_id = f"tkt_{num}"
110
+ docs.append(Document(
111
+ page_content=_excerpt(
112
+ f"Issue #{num} '{issue['title']}' by {login} ({issue.get('state', 'open')}). "
113
+ f"{issue.get('body') or ''}"
114
+ ),
115
+ metadata={"id": tkt_id, "label": f"Issue #{num}", "kind": "ticket",
116
+ "source": issue.get("html_url"),
117
+ "edges": [{"source": tkt_id, "target": repo_id,
118
+ "relation": "REPORTS_ON", "weight": 0.6}]},
119
+ ))
120
+ authored.setdefault(login, [])
121
+
122
+ for login, pr_ids in authored.items():
123
+ docs.append(Document(
124
+ page_content=f"GitHub user {login} — authored {len(pr_ids)} of the "
125
+ f"last {len(prs)} pull requests in {repo}.",
126
+ metadata={"id": f"person_{login}", "label": login, "kind": "person",
127
+ "source": f"https://github.com/{login}",
128
+ # carried on both endpoints; the tracer dedups per retrieval
129
+ "edges": [{"source": f"person_{login}", "target": pid,
130
+ "relation": "AUTHORED", "weight": 0.9} for pid in pr_ids]},
131
+ ))
132
+ return docs
133
+
134
+
135
+ # retrieval
136
+ def _tokens(text: str) -> set[str]:
137
+ return {w for w in _WORD_RE.findall(text.lower()) if w not in _STOP}
138
+
139
+
140
+ class LexicalGraphRetriever(BaseRetriever):
141
+ """Lexical seeds + 1-hop edge neighbors, capped at k.
142
+
143
+ Neighbors keep their own (often lower) lexical score. Edges are only
144
+ emitted when both endpoints made the cut.
145
+ """
146
+
147
+ corpus: list[Document]
148
+ k: int = 10
149
+
150
+ def _get_relevant_documents(
151
+ self, query: str, *, run_manager: CallbackManagerForRetrieverRun
152
+ ) -> list[Document]:
153
+ q = _tokens(query) or {"?"}
154
+ scored = sorted(
155
+ ((round(len(q & _tokens(d.page_content)) / len(q), 3), d) for d in self.corpus),
156
+ key=lambda pair: (pair[0], _KIND_PRIORITY.get(pair[1].metadata["kind"], 0)),
157
+ reverse=True,
158
+ )
159
+ by_id = {d.metadata["id"]: (s, d) for s, d in scored}
160
+
161
+ # lexical seeds first
162
+ kept: dict[str, tuple[float, Document]] = {
163
+ d.metadata["id"]: (s, d) for s, d in scored[: max(3, self.k // 2)]
164
+ }
165
+ # then their 1-hop neighbors
166
+ for _, doc in list(kept.values()):
167
+ for edge in doc.metadata.get("edges", []):
168
+ for nid in (edge["source"], edge["target"]):
169
+ if len(kept) >= self.k:
170
+ break
171
+ if nid not in kept and nid in by_id:
172
+ kept[nid] = by_id[nid]
173
+ # fill spare slots
174
+ for s, d in scored:
175
+ if len(kept) >= self.k:
176
+ break
177
+ kept.setdefault(d.metadata["id"], (s, d))
178
+
179
+ out = []
180
+ for score, doc in sorted(kept.values(), key=lambda pair: pair[0], reverse=True):
181
+ edges = [e for e in doc.metadata.get("edges", [])
182
+ if e["source"] in kept and e["target"] in kept]
183
+ out.append(Document(
184
+ page_content=doc.page_content,
185
+ metadata={**{k: v for k, v in doc.metadata.items() if k != "edges"},
186
+ "score": score, "edges": edges},
187
+ ))
188
+ return out
189
+
190
+
191
+ # the traced agent
192
+ def run_traced(corpus: list[Document], question: str, repo: str, k: int):
193
+ try:
194
+ from langgraph.graph import END, START, StateGraph
195
+ except ImportError:
196
+ raise SystemExit(
197
+ 'LangGraph is required for the demo agent: pip install "graphsight-langgraph[example]"'
198
+ )
199
+ from typing import TypedDict
200
+
201
+ retriever = LexicalGraphRetriever(corpus=corpus, k=k)
202
+
203
+ class State(TypedDict):
204
+ question: str
205
+ docs: list[Document]
206
+ answer: str
207
+
208
+ def retrieve(state: State, config) -> dict:
209
+ return {"docs": retriever.invoke(state["question"], config=config)}
210
+
211
+ def answer(state: State, config) -> dict:
212
+ docs = state["docs"]
213
+ top = docs[0]
214
+ kinds = {}
215
+ for d in docs:
216
+ kinds[d.metadata["kind"]] = kinds.get(d.metadata["kind"], 0) + 1
217
+ parts = ", ".join(f"{v} {k.replace('_', ' ')}(s)" for k, v in sorted(kinds.items()))
218
+ return {"answer": (
219
+ f"Top match in {repo}: {top.metadata['label']} — "
220
+ f"{top.page_content[:160]}… Retrieved {len(docs)} items ({parts}); "
221
+ "open the graph to see who and what they connect to."
222
+ )}
223
+
224
+ graph = (StateGraph(State)
225
+ .add_node("retrieve", retrieve)
226
+ .add_node("answer", answer)
227
+ .add_edge(START, "retrieve")
228
+ .add_edge("retrieve", "answer")
229
+ .add_edge("answer", END)
230
+ .compile())
231
+
232
+ tracer = LangGraphTracer()
233
+ result = graph.invoke({"question": question}, config={"callbacks": [tracer]})
234
+ return tracer.finish(query=question, answer=result["answer"])
235
+
236
+
237
+ # cli
238
+ def main(argv: Optional[list[str]] = None) -> None:
239
+ # windows consoles default to cp1252
240
+ for stream in (sys.stdout, sys.stderr):
241
+ try:
242
+ stream.reconfigure(encoding="utf-8") # type: ignore[union-attr]
243
+ except (AttributeError, OSError):
244
+ pass
245
+ parser = argparse.ArgumentParser(
246
+ prog="graphsight-github-trace",
247
+ description="GitHub repo → traced LangGraph run → Studio-ready trace_state.json",
248
+ )
249
+ parser.add_argument("repo", help="owner/name, e.g. langchain-ai/langgraph")
250
+ parser.add_argument("question", nargs="?", default=None,
251
+ help='the question to trace (default: "what changed recently and who drove it?")')
252
+ parser.add_argument("--token", default=os.environ.get("GITHUB_TOKEN"),
253
+ help="GitHub token (or set GITHUB_TOKEN); needed for private repos")
254
+ parser.add_argument("--prs", type=int, default=25, help="recent PRs to fetch (default 25)")
255
+ parser.add_argument("--issues", type=int, default=25, help="recent issues to fetch (default 25)")
256
+ parser.add_argument("--top", type=int, default=10, help="items to retrieve (default 10)")
257
+ parser.add_argument("--out", type=Path, default=Path("graphsight_out"), help="output directory")
258
+ args = parser.parse_args(argv)
259
+
260
+ if "/" not in args.repo:
261
+ parser.error("repo must be owner/name")
262
+ question = args.question or f"What changed recently in {args.repo}, and who drove it?"
263
+
264
+ print(f"fetching {args.repo} (last {args.prs} PRs, {args.issues} issues)…", file=sys.stderr)
265
+ corpus = build_corpus(args.repo, args.token, args.prs, args.issues)
266
+ print(f"corpus : {len(corpus)} documents", file=sys.stderr)
267
+
268
+ trace = run_traced(corpus, question, args.repo, args.top)
269
+
270
+ args.out.mkdir(parents=True, exist_ok=True)
271
+ (args.out / "agent_trace.json").write_text(
272
+ json.dumps(trace.to_dict(), indent=2), encoding="utf-8")
273
+ (args.out / "trace_state.json").write_text(
274
+ json.dumps(to_tracestate(trace), indent=2), encoding="utf-8")
275
+
276
+ items = sum(len(r.items) for r in trace.retrievals)
277
+ edges = sum(len(r.edges) for r in trace.retrievals)
278
+ arm = trace.retrievals[0].arm if trace.retrievals else "n/a"
279
+ print(f"answer : {trace.answer}")
280
+ print(f"retrieved: {items} items · {edges} edges · arm={arm} · {trace.latency_ms:.1f}ms")
281
+ print(f"wrote : {args.out / 'agent_trace.json'}")
282
+ print(f"wrote : {args.out / 'trace_state.json'} ← drop into the Studio at /memory/import")
283
+
284
+
285
+ if __name__ == "__main__":
286
+ main()
@@ -0,0 +1,138 @@
1
+ """Map an AgentTrace onto the Studio's TraceState (frontend/src/types/trace.ts).
2
+ Positions are {0,0} on purpose; the Studio re-layouts client-side."""
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from datetime import datetime, timezone
7
+ from typing import Any
8
+
9
+ from .schema import AgentTrace
10
+
11
+ # free-form kinds -> the Studio's entity types
12
+ _KIND_TO_ENTITY = {
13
+ "pr": "PR",
14
+ "pull_request": "PR",
15
+ "pullrequest": "PR",
16
+ "service": "Service",
17
+ "person": "Person",
18
+ "user": "Person",
19
+ "author": "Person",
20
+ "document": "Document",
21
+ "doc": "Document",
22
+ "chunk": "Document",
23
+ "repo": "Repo",
24
+ "repository": "Repo",
25
+ "branch": "Repo",
26
+ "library": "Library",
27
+ "package": "Library",
28
+ "ticket": "Ticket",
29
+ "issue": "Ticket",
30
+ "jira": "Ticket",
31
+ "team": "Team",
32
+ "tool": "Tool",
33
+ }
34
+
35
+ _MAX_CONTEXT_CHARS = 8000
36
+
37
+
38
+ def to_tracestate(trace: AgentTrace) -> dict[str, Any]:
39
+ """Return a TraceState-shaped dict ready for the Studio."""
40
+ has_edges = any(r.edges for r in trace.retrievals)
41
+
42
+ # nodes
43
+ nodes: list[dict[str, Any]] = []
44
+ seen_nodes: set[str] = set()
45
+ for retrieval in trace.retrievals:
46
+ for item in retrieval.items:
47
+ if item.id in seen_nodes:
48
+ continue
49
+ seen_nodes.add(item.id)
50
+ nodes.append(
51
+ {
52
+ "id": item.id,
53
+ "label": item.label,
54
+ "type": _KIND_TO_ENTITY.get(item.kind.lower().strip(), "Document"),
55
+ "active": True, # everything retrieved was selected
56
+ "position": {"x": 0, "y": 0}, # Studio re-layouts with dagre
57
+ "similarity": item.vector_score,
58
+ "score": item.score,
59
+ "meta": {
60
+ "subtitle": f"via {trace.framework} · {retrieval.arm} arm",
61
+ "snippet": (item.content or "")[:600] or None,
62
+ "scoreGraph": item.graph_score,
63
+ "sourceUrl": item.source_uri,
64
+ },
65
+ }
66
+ )
67
+
68
+ # edges (only between retrieved nodes)
69
+ edges: list[dict[str, Any]] = []
70
+ for retrieval in trace.retrievals:
71
+ for i, edge in enumerate(retrieval.edges):
72
+ if edge.source not in seen_nodes or edge.target not in seen_nodes:
73
+ continue
74
+ edges.append(
75
+ {
76
+ "id": f"e_{retrieval.span_id[:8]}_{i}",
77
+ "source": edge.source,
78
+ "target": edge.target,
79
+ "confidence": edge.weight if edge.weight is not None else 0.7,
80
+ "active": True,
81
+ "relation": edge.relation,
82
+ }
83
+ )
84
+
85
+ # execution steps from spans
86
+ ordered = sorted(trace.spans, key=lambda s: s.start_ms)
87
+ steps = []
88
+ for index, span in enumerate(ordered):
89
+ duration = (span.end_ms - span.start_ms) if span.end_ms is not None else None
90
+ step = {
91
+ "id": span.id,
92
+ "index": index,
93
+ "title": span.name,
94
+ "detail": f"{span.kind} · {span.status}",
95
+ "status": "complete" if span.status == "ok" else "pending",
96
+ "badge": span.kind,
97
+ "durationMs": round(duration, 1) if duration is not None else None,
98
+ }
99
+ if span.kind == "retriever":
100
+ arm = next((r.arm for r in trace.retrievals if r.span_id == span.id), None)
101
+ if arm in ("vector", "graph"):
102
+ step["arm"] = arm
103
+ steps.append(step)
104
+
105
+ # context for downstream answer generation
106
+ context = "\n\n".join(
107
+ item.content for r in trace.retrievals for item in r.items if item.content
108
+ )[:_MAX_CONTEXT_CHARS] or None
109
+
110
+ scores = [item.score for r in trace.retrievals for item in r.items if item.score is not None]
111
+
112
+ return {
113
+ "id": f"trace_lg_{int(time.time() * 1000)}",
114
+ "query": trace.query,
115
+ "computedAt": datetime.now(timezone.utc).isoformat(),
116
+ "weights": {
117
+ # honest split: this router didn't run, so weights describe the
118
+ # observed arms, not a fusion decision
119
+ "vector": 0.5 if has_edges else 1.0,
120
+ "graph": 0.5 if has_edges else 0.0,
121
+ "intent": "relational" if has_edges else "conceptual",
122
+ },
123
+ "confidence": {
124
+ "score": round(sum(scores) / len(scores), 3) if scores else 0.0,
125
+ "uncertainty": 0.0,
126
+ "rationale": (
127
+ f"External {trace.framework} trace — scores reported by the "
128
+ "retriever; Graphsight does not recompute them."
129
+ ),
130
+ },
131
+ "steps": steps,
132
+ "metrics": {
133
+ "queryTimeSec": round((trace.latency_ms or 0.0) / 1000.0, 3),
134
+ "nodesEvaluated": len(nodes),
135
+ },
136
+ "graph": {"nodes": nodes, "edges": edges},
137
+ "context": context,
138
+ }
@@ -0,0 +1,76 @@
1
+ """AgentTrace: the framework-neutral trace contract. Plain dataclasses,
2
+ zero deps, JSON-ready via asdict."""
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field, asdict
6
+ from typing import Any, Literal, Optional
7
+
8
+ SCHEMA_VERSION = "0.1"
9
+
10
+ SpanKind = Literal["node", "retriever", "llm", "tool"]
11
+ Arm = Literal["vector", "graph", "hybrid", "unknown"]
12
+
13
+
14
+ @dataclass
15
+ class Span:
16
+ """One unit of agent execution (a LangGraph node, a retriever call, ...)."""
17
+
18
+ id: str
19
+ name: str
20
+ kind: SpanKind
21
+ parent_id: Optional[str] = None
22
+ start_ms: float = 0.0
23
+ end_ms: Optional[float] = None
24
+ status: Literal["ok", "error", "running"] = "running"
25
+
26
+
27
+ @dataclass
28
+ class RetrievedItem:
29
+ """One retrieved chunk/entity with its provenance."""
30
+
31
+ id: str
32
+ label: str
33
+ kind: str = "document" # free-form; mapper folds it into the Studio's types
34
+ score: Optional[float] = None
35
+ vector_score: Optional[float] = None
36
+ graph_score: Optional[float] = None
37
+ content: Optional[str] = None
38
+ source_uri: Optional[str] = None
39
+ metadata: dict[str, Any] = field(default_factory=dict)
40
+
41
+
42
+ @dataclass
43
+ class TraceEdge:
44
+ """Optional relational structure between retrieved items (graph retrievers)."""
45
+
46
+ source: str
47
+ target: str
48
+ relation: Optional[str] = None
49
+ weight: Optional[float] = None
50
+
51
+
52
+ @dataclass
53
+ class Retrieval:
54
+ """One retrieval event inside the run."""
55
+
56
+ span_id: str
57
+ query: str
58
+ arm: Arm = "unknown"
59
+ items: list[RetrievedItem] = field(default_factory=list)
60
+ edges: list[TraceEdge] = field(default_factory=list)
61
+
62
+
63
+ @dataclass
64
+ class AgentTrace:
65
+ """The full trace of one agent run."""
66
+
67
+ query: str
68
+ framework: str = "langgraph"
69
+ schema_version: str = SCHEMA_VERSION
70
+ spans: list[Span] = field(default_factory=list)
71
+ retrievals: list[Retrieval] = field(default_factory=list)
72
+ answer: Optional[str] = None
73
+ latency_ms: Optional[float] = None
74
+
75
+ def to_dict(self) -> dict[str, Any]:
76
+ return asdict(self)
@@ -0,0 +1,242 @@
1
+ """Callback handler that captures a LangGraph run as an AgentTrace.
2
+
3
+ Pass it via config={"callbacks": [tracer]}, then call tracer.finish().
4
+ Remember to pass the node's config into sub-runnables or their events
5
+ won't reach the tracer (see README).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import time
11
+ from typing import Any, Optional
12
+ from uuid import UUID
13
+
14
+ from langchain_core.callbacks import BaseCallbackHandler
15
+ from langchain_core.documents import Document
16
+
17
+ from .schema import AgentTrace, Retrieval, RetrievedItem, Span, TraceEdge
18
+
19
+ _SCORE_KEYS = ("score", "relevance_score", "similarity", "_score", "vector_score")
20
+ # LangGraph internals that fire chain events but aren't user nodes
21
+ _NOISE_NAMES = {"LangGraph", "RunnableSequence", "RunnableCallable", "ChannelWrite", "__start__", "__end__"}
22
+
23
+
24
+ def _doc_id(doc: Document) -> str:
25
+ for key in ("id", "node_id", "doc_id"):
26
+ if doc.metadata.get(key):
27
+ return str(doc.metadata[key])
28
+ if getattr(doc, "id", None):
29
+ return str(doc.id)
30
+ return "doc_" + hashlib.sha1(doc.page_content.encode("utf-8")).hexdigest()[:10]
31
+
32
+
33
+ def _doc_score(doc: Document) -> Optional[float]:
34
+ for key in _SCORE_KEYS:
35
+ value = doc.metadata.get(key)
36
+ if isinstance(value, (int, float)):
37
+ return float(value)
38
+ return None
39
+
40
+
41
+ class LangGraphTracer(BaseCallbackHandler):
42
+ """Collects spans + retrievals from a LangGraph (or any LangChain) run."""
43
+
44
+ def __init__(self) -> None:
45
+ super().__init__()
46
+ self._t0 = time.monotonic()
47
+ self._spans: dict[str, Span] = {}
48
+ self._order: list[str] = []
49
+ self._retrievals: list[Retrieval] = []
50
+ self._first_query: Optional[str] = None
51
+
52
+ # span bookkeeping
53
+ def _now_ms(self) -> float:
54
+ return (time.monotonic() - self._t0) * 1000.0
55
+
56
+ def _open_span(
57
+ self,
58
+ run_id: UUID,
59
+ parent_run_id: Optional[UUID],
60
+ name: str,
61
+ kind: str,
62
+ ) -> Span:
63
+ span = Span(
64
+ id=str(run_id),
65
+ name=name,
66
+ kind=kind, # type: ignore[arg-type]
67
+ parent_id=str(parent_run_id) if parent_run_id else None,
68
+ start_ms=self._now_ms(),
69
+ )
70
+ self._spans[span.id] = span
71
+ self._order.append(span.id)
72
+ return span
73
+
74
+ def _close_span(self, run_id: UUID, status: str = "ok") -> None:
75
+ span = self._spans.get(str(run_id))
76
+ if span is not None:
77
+ span.end_ms = self._now_ms()
78
+ span.status = status # type: ignore[assignment]
79
+
80
+ @staticmethod
81
+ def _resolve_name(
82
+ serialized: Optional[dict[str, Any]],
83
+ metadata: Optional[dict[str, Any]],
84
+ kwargs: dict[str, Any],
85
+ fallback: str,
86
+ ) -> str:
87
+ return (
88
+ kwargs.get("name")
89
+ or (serialized or {}).get("name")
90
+ or (metadata or {}).get("langgraph_node")
91
+ or fallback
92
+ )
93
+
94
+ # chain events -> node spans
95
+ def on_chain_start(
96
+ self,
97
+ serialized: Optional[dict[str, Any]],
98
+ inputs: Any,
99
+ *,
100
+ run_id: UUID,
101
+ parent_run_id: Optional[UUID] = None,
102
+ tags: Optional[list[str]] = None,
103
+ metadata: Optional[dict[str, Any]] = None,
104
+ **kwargs: Any,
105
+ ) -> None:
106
+ name = self._resolve_name(serialized, metadata, kwargs, "chain")
107
+ node = (metadata or {}).get("langgraph_node")
108
+ # keep exactly one span per user node execution; skip framework noise
109
+ if name in _NOISE_NAMES or node is None or name != node:
110
+ return
111
+ self._open_span(run_id, parent_run_id, name, "node")
112
+
113
+ def on_chain_end(self, outputs: Any, *, run_id: UUID, **kwargs: Any) -> None:
114
+ self._close_span(run_id)
115
+
116
+ def on_chain_error(self, error: BaseException, *, run_id: UUID, **kwargs: Any) -> None:
117
+ self._close_span(run_id, status="error")
118
+
119
+ # retriever events -> spans + retrievals
120
+ def on_retriever_start(
121
+ self,
122
+ serialized: Optional[dict[str, Any]],
123
+ query: str,
124
+ *,
125
+ run_id: UUID,
126
+ parent_run_id: Optional[UUID] = None,
127
+ tags: Optional[list[str]] = None,
128
+ metadata: Optional[dict[str, Any]] = None,
129
+ **kwargs: Any,
130
+ ) -> None:
131
+ if self._first_query is None:
132
+ self._first_query = query
133
+ name = self._resolve_name(serialized, metadata, kwargs, "retriever")
134
+ span = self._open_span(run_id, parent_run_id, name, "retriever")
135
+ self._retrievals.append(Retrieval(span_id=span.id, query=query))
136
+
137
+ def on_retriever_end(
138
+ self, documents: list[Document], *, run_id: UUID, **kwargs: Any
139
+ ) -> None:
140
+ self._close_span(run_id)
141
+ retrieval = next(
142
+ (r for r in reversed(self._retrievals) if r.span_id == str(run_id)), None
143
+ )
144
+ if retrieval is None:
145
+ return
146
+ seen_edges: set[tuple[str, str, str]] = set()
147
+ for doc in documents:
148
+ item_id = _doc_id(doc)
149
+ retrieval.items.append(
150
+ RetrievedItem(
151
+ id=item_id,
152
+ label=str(doc.metadata.get("label") or doc.metadata.get("title") or item_id),
153
+ kind=str(doc.metadata.get("kind") or doc.metadata.get("type") or "document"),
154
+ score=_doc_score(doc),
155
+ vector_score=_doc_score(doc),
156
+ content=doc.page_content,
157
+ source_uri=doc.metadata.get("source") or doc.metadata.get("source_uri"),
158
+ metadata={
159
+ k: v
160
+ for k, v in doc.metadata.items()
161
+ if k not in ("edges",) and isinstance(v, (str, int, float, bool))
162
+ },
163
+ )
164
+ )
165
+ # optional relational structure carried on the document
166
+ for edge in doc.metadata.get("edges") or []:
167
+ key = (str(edge.get("source")), str(edge.get("target")), str(edge.get("relation")))
168
+ if key in seen_edges or not edge.get("source") or not edge.get("target"):
169
+ continue
170
+ seen_edges.add(key)
171
+ retrieval.edges.append(
172
+ TraceEdge(
173
+ source=str(edge["source"]),
174
+ target=str(edge["target"]),
175
+ relation=edge.get("relation"),
176
+ weight=edge.get("weight"),
177
+ )
178
+ )
179
+ retrieval.arm = "graph" if retrieval.edges else "vector"
180
+
181
+ def on_retriever_error(self, error: BaseException, *, run_id: UUID, **kwargs: Any) -> None:
182
+ self._close_span(run_id, status="error")
183
+
184
+ # llm / tool events -> plain spans
185
+ def on_llm_start(
186
+ self,
187
+ serialized: Optional[dict[str, Any]],
188
+ prompts: list[str],
189
+ *,
190
+ run_id: UUID,
191
+ parent_run_id: Optional[UUID] = None,
192
+ tags: Optional[list[str]] = None,
193
+ metadata: Optional[dict[str, Any]] = None,
194
+ **kwargs: Any,
195
+ ) -> None:
196
+ self._open_span(
197
+ run_id, parent_run_id, self._resolve_name(serialized, metadata, kwargs, "llm"), "llm"
198
+ )
199
+
200
+ def on_llm_end(self, response: Any, *, run_id: UUID, **kwargs: Any) -> None:
201
+ self._close_span(run_id)
202
+
203
+ def on_llm_error(self, error: BaseException, *, run_id: UUID, **kwargs: Any) -> None:
204
+ self._close_span(run_id, status="error")
205
+
206
+ def on_tool_start(
207
+ self,
208
+ serialized: Optional[dict[str, Any]],
209
+ input_str: str,
210
+ *,
211
+ run_id: UUID,
212
+ parent_run_id: Optional[UUID] = None,
213
+ tags: Optional[list[str]] = None,
214
+ metadata: Optional[dict[str, Any]] = None,
215
+ **kwargs: Any,
216
+ ) -> None:
217
+ self._open_span(
218
+ run_id, parent_run_id, self._resolve_name(serialized, metadata, kwargs, "tool"), "tool"
219
+ )
220
+
221
+ def on_tool_end(self, output: Any, *, run_id: UUID, **kwargs: Any) -> None:
222
+ self._close_span(run_id)
223
+
224
+ def on_tool_error(self, error: BaseException, *, run_id: UUID, **kwargs: Any) -> None:
225
+ self._close_span(run_id, status="error")
226
+
227
+ # result
228
+ def finish(self, query: Optional[str] = None, answer: Optional[str] = None) -> AgentTrace:
229
+ """Assemble the AgentTrace after the run completes."""
230
+ spans = [self._spans[sid] for sid in self._order]
231
+ for span in spans: # close anything left dangling
232
+ if span.end_ms is None:
233
+ span.end_ms = self._now_ms()
234
+ span.status = "ok" if span.status == "running" else span.status
235
+ latency = max((s.end_ms or 0.0) for s in spans) if spans else None
236
+ return AgentTrace(
237
+ query=query or self._first_query or "",
238
+ spans=spans,
239
+ retrievals=self._retrievals,
240
+ answer=answer,
241
+ latency_ms=latency,
242
+ )
@@ -0,0 +1,232 @@
1
+ Metadata-Version: 2.4
2
+ Name: graphsight-langgraph
3
+ Version: 0.1.0
4
+ Summary: Capture LangGraph agent runs as Graphsight traces — see exactly what your agent retrieved and why.
5
+ Author: Arush Karnatak
6
+ License: MIT
7
+ Project-URL: Repository, https://github.com/Kcodess2807/graphsight
8
+ Project-URL: Documentation, https://github.com/Kcodess2807/graphsight/tree/main/graphsight-langgraph
9
+ Keywords: langgraph,langchain,tracing,observability,rag,retrieval,agents
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development :: Debuggers
19
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: langchain-core>=0.3
24
+ Provides-Extra: example
25
+ Requires-Dist: langgraph>=0.2; extra == "example"
26
+ Dynamic: license-file
27
+
28
+ # graphsight-langgraph
29
+
30
+ **See exactly why your LangGraph agent picked the context it picked.**
31
+
32
+ One callback handler captures a [LangGraph](https://github.com/langchain-ai/langgraph)
33
+ run — every node, every retriever call, per-document scores, and (for
34
+ graph-aware retrievers) the relational paths between retrieved entities — as a
35
+ framework-neutral `AgentTrace`. One function maps it to a JSON file the
36
+ [Graphsight Studio](../frontend) renders with **zero backend**.
37
+
38
+ ```
39
+ your LangGraph agent ──▶ LangGraphTracer ──▶ AgentTrace (v0.1) ──▶ to_tracestate() ──▶ Studio /memory/import
40
+ (callbacks) neutral JSON Studio JSON full canvas render
41
+ ```
42
+
43
+ This package is standalone. It does **not** require the TraceRAG engine,
44
+ database, or SaaS stack — its only dependency is `langchain-core`.
45
+
46
+ ## Install
47
+
48
+ ```bash
49
+ pip install -e . # the tracer (depends only on langchain-core)
50
+ pip install -e ".[example]" # + langgraph, to run the demo agent
51
+ ```
52
+
53
+ Python ≥ 3.10. Verified against `langchain-core 1.5.0` + current `langgraph`.
54
+
55
+ ## Quickstart
56
+
57
+ ```python
58
+ from graphsight_langgraph import LangGraphTracer, to_tracestate
59
+
60
+ tracer = LangGraphTracer()
61
+ result = graph.invoke(inputs, config={"callbacks": [tracer]})
62
+
63
+ trace = tracer.finish(
64
+ query="why is checkout failing?",
65
+ answer=result["answer"],
66
+ )
67
+
68
+ trace.to_dict() # framework-neutral AgentTrace (schema v0.1)
69
+ to_tracestate(trace) # Studio-ready JSON → drop into /memory/import
70
+ ```
71
+
72
+ That's the whole API surface: construct, pass as a callback, `finish()`, map.
73
+
74
+ ### ⚠ The one gotcha: config propagation
75
+
76
+ LangChain only propagates callbacks into runnables that receive the run's
77
+ config. Inside a LangGraph node, **pass the node's config through** to
78
+ sub-runnables, or the tracer will see the node but not what happened inside it:
79
+
80
+ ```python
81
+ def retrieve(state, config): # ① accept config
82
+ docs = retriever.invoke(state["q"], config=config) # ② pass it through
83
+ return {"docs": docs}
84
+ ```
85
+
86
+ If your trace shows node spans but no retrievals, this is why.
87
+
88
+ ## Try it in 60 seconds (offline, no API keys)
89
+
90
+ ```bash
91
+ python example/demo_agent.py
92
+ ```
93
+
94
+ This builds and runs a real two-node LangGraph agent (`retrieve → answer`)
95
+ over an in-memory "codebase memory" corpus — a PR, a service, a person, and a
96
+ ticket, connected by edges — and traces it:
97
+
98
+ ```
99
+ answer : The regression traces to PR #4821 in checkout-service, authored by Priya N. …
100
+ spans : 3 (retrieve, CodebaseMemoryRetriever, answer)
101
+ retrieved: 4 items · 4 edges · arm=graph
102
+ latency : 3.9ms
103
+ ```
104
+
105
+ Two files land in `example/out/`:
106
+
107
+ | File | What it is |
108
+ |---|---|
109
+ | `agent_trace.json` | the neutral `AgentTrace` — what any consumer would ingest |
110
+ | `trace_state.json` | Studio-ready — drag-and-drop (or paste) into `http://localhost:5173/memory/import` |
111
+
112
+ In the Studio you'll see the full canvas: four typed entity nodes with score
113
+ chips, the traced relational path (person → PR → service, PR → ticket), the
114
+ execution timeline with per-span timings, and the retriever's arm.
115
+
116
+ Tip: with [`graphsight`](../graphsight) installed, skip the manual
117
+ drop — `graphsight example/out/trace_state.json` serves the UI locally
118
+ and opens the trace directly.
119
+
120
+ ## Trace your own GitHub repo
121
+
122
+ The package ships a dead-simple ingest CLI — one command from repo to trace:
123
+
124
+ ```bash
125
+ graphsight-github-trace langchain-ai/langgraph "who fixed the recent streaming bugs?"
126
+ graphsight graphsight_out/trace_state.json
127
+ ```
128
+
129
+ It fetches recent PRs + issues (public repos need no token; `--token` /
130
+ `GITHUB_TOKEN` for private), builds a corpus with relational edges
131
+ (`person AUTHORED pr`, `pr RESOLVES issue`, `pr TOUCHES repo`), and runs a
132
+ traced two-node LangGraph agent over it. Retrieval is two-arm in miniature:
133
+ lexical seeds + 1-hop graph expansion along the edges — simple on purpose,
134
+ and the scores shown are exactly the lexical scores computed, nothing
135
+ dressed up as semantic. Requires the `example` extra (`langgraph`).
136
+
137
+ ## What gets captured
138
+
139
+ | Source in the run | Captured as |
140
+ |---|---|
141
+ | Each LangGraph node execution | `Span(kind="node")` with monotonic-clock timing |
142
+ | Framework internals (`RunnableSequence`, `ChannelWrite`, `__start__`, …) | **filtered out** — one span per user node, no noise |
143
+ | `on_retriever_end` documents | one `RetrievedItem` per doc: id, label, kind, score, content, source |
144
+ | `Document.metadata` score keys (`score`, `relevance_score`, `similarity`, …) | item scores |
145
+ | `Document.metadata["edges"]` = `[{source, target, relation, weight}]` | relational edges (deduplicated) |
146
+ | LLM / tool calls | plain spans in the execution timeline |
147
+ | Edges present in a retrieval? | `arm = "graph"`, else `"vector"` — auto-detected |
148
+
149
+ ### Honest degradation — stated plainly
150
+
151
+ - **Vector-only retriever** (no edges in metadata) → the Studio renders a flat
152
+ scored-retrieval view instead of relational path highlighting. Still useful;
153
+ just not graph-aware.
154
+ - **No score keys** → scores stay `None` and no score chips render.
155
+ **Nothing is ever fabricated.**
156
+ - The emitted `confidence.rationale` explicitly says scores came from *your*
157
+ retriever and Graphsight does not recompute them — an imported trace never
158
+ masquerades as an engine-computed one.
159
+
160
+ ## Schema (v0.1)
161
+
162
+ `AgentTrace` is the stable contract; future adapters (LlamaIndex, raw OTel)
163
+ emit the same shape.
164
+
165
+ ```jsonc
166
+ {
167
+ "schema_version": "0.1",
168
+ "framework": "langgraph",
169
+ "query": "…",
170
+ "spans": [
171
+ { "id": "…", "name": "retrieve", "kind": "node", // node | retriever | llm | tool | chain
172
+ "parent_id": null, "start_ms": 0.0, "end_ms": 0.3, "status": "ok" }
173
+ ],
174
+ "retrievals": [
175
+ {
176
+ "span_id": "…", "query": "…",
177
+ "arm": "graph", // "vector" | "graph" (auto: edges present?)
178
+ "items": [
179
+ { "id": "pr_4821", "label": "PR #4821", "kind": "pull_request",
180
+ "score": 0.94, "vector_score": null, "graph_score": null,
181
+ "content": "…", "source_uri": "https://…", "metadata": {} }
182
+ ],
183
+ "edges": [ // optional — the graph-aware view
184
+ { "source": "pr_4821", "target": "svc_checkout", "relation": "TOUCHES", "weight": 0.9 }
185
+ ]
186
+ }
187
+ ],
188
+ "answer": "…",
189
+ "latency_ms": 3.9
190
+ }
191
+ ```
192
+
193
+ `to_tracestate()` maps this onto the Studio's `TraceState`
194
+ ([frontend/src/types/trace.ts](../frontend/src/types/trace.ts)). Free-form
195
+ `kind` strings are normalized to Studio entity types
196
+ (`pull_request → PR`, `service → Service`, `person/author → Person`,
197
+ `ticket/issue/jira → Ticket`, … fallback `Document`). Node positions are
198
+ recomputed client-side by the Studio's layout engine, so emitters never deal
199
+ with coordinates.
200
+
201
+ ## Package layout
202
+
203
+ ```
204
+ graphsight-langgraph/
205
+ ├── graphsight_langgraph/
206
+ │ ├── schema.py # AgentTrace v0.1 — zero-dependency dataclasses
207
+ │ ├── tracer.py # LangGraphTracer (BaseCallbackHandler)
208
+ │ ├── mapper.py # to_tracestate() — AgentTrace → Studio TraceState
209
+ │ └── github_ingest.py # `graphsight-github-trace` CLI (repo → trace)
210
+ ├── example/
211
+ │ └── demo_agent.py # offline end-to-end demo (writes example/out/*.json)
212
+ └── pyproject.toml
213
+ ```
214
+
215
+ ## Status & roadmap
216
+
217
+ **v0.1** — verified end-to-end against `langchain-core 1.5.0` + current
218
+ `langgraph`: spans, scored items, edges, and arm detection all captured from a
219
+ real run and rendered in the Studio.
220
+
221
+ Known limits, honestly:
222
+
223
+ - Score-key coverage is the pragmatic short list above; real-world retrievers
224
+ vary, and unrecognized keys degrade to `None` (visible, not wrong).
225
+ - Edges are read from a documented metadata convention
226
+ (`metadata["edges"]`) — retrievers must opt in to the graph-aware view.
227
+ - Async (`ainvoke` / `astream`) uses the same callback events but hasn't been
228
+ exercised in the verification run yet.
229
+
230
+ Planned next, in order: **LlamaIndex adapter** emitting the same
231
+ `AgentTrace`, then a raw **OTel span** ingestor. The schema is the contract;
232
+ the adapters are thin.
@@ -0,0 +1,11 @@
1
+ graphsight_langgraph/__init__.py,sha256=aDkf8Yw9kXbQDyuPgn4hI1IJo2ptk6mwHU1GU2OZzdk,738
2
+ graphsight_langgraph/github_ingest.py,sha256=xoI4ORIIYyxeWh-cwfIHTlJ_7-XxnUi_VkX8i9MmRkI,12049
3
+ graphsight_langgraph/mapper.py,sha256=rawV9V0rZHc5qgh4VVd5KBQ2blb7s7l19yrSOoGu5wo,4947
4
+ graphsight_langgraph/schema.py,sha256=k8c5hVG28Z5J14y3wReiZQ_8q4ktw8FP4eh5uho8gTY,2012
5
+ graphsight_langgraph/tracer.py,sha256=As0QWJ0GlmARAzWhP0YRnrqi314oqXMnQXJ6az3n0lc,8882
6
+ graphsight_langgraph-0.1.0.dist-info/licenses/LICENSE,sha256=BBF3PPfyR6ahuORavA-9uyS5VNJijJP-EibBsfWofJc,1071
7
+ graphsight_langgraph-0.1.0.dist-info/METADATA,sha256=CfiLKC7ONYfJFM1LdoPLqldMC-5X_8RFIKeT1lW0X58,9613
8
+ graphsight_langgraph-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
9
+ graphsight_langgraph-0.1.0.dist-info/entry_points.txt,sha256=bbm3x_Uqh5YHukz6I85wprPwTmpos4IEyVtZo4PFnLo,84
10
+ graphsight_langgraph-0.1.0.dist-info/top_level.txt,sha256=TTw8Df4JLlu0DCHjnAEXznT61NtnA-1mH51ISR6Xq1M,21
11
+ graphsight_langgraph-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ graphsight-github-trace = graphsight_langgraph.github_ingest:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Arush Karnatak
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ graphsight_langgraph