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.
- graphsight_langgraph/__init__.py +30 -0
- graphsight_langgraph/github_ingest.py +286 -0
- graphsight_langgraph/mapper.py +138 -0
- graphsight_langgraph/schema.py +76 -0
- graphsight_langgraph/tracer.py +242 -0
- graphsight_langgraph-0.1.0.dist-info/METADATA +232 -0
- graphsight_langgraph-0.1.0.dist-info/RECORD +11 -0
- graphsight_langgraph-0.1.0.dist-info/WHEEL +5 -0
- graphsight_langgraph-0.1.0.dist-info/entry_points.txt +2 -0
- graphsight_langgraph-0.1.0.dist-info/licenses/LICENSE +21 -0
- graphsight_langgraph-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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,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
|