This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: discourse graph canvas on strata page

Layered graph modeled on Discourse Graphs (discoursegraphs.com):
4 node types — QUE (question), CLM (claim), EVD (evidence),
SRC (source) — arranged in horizontal layers top to bottom.
Edges typed as supports/opposes/informs/addresses with color
coding and curved bezier paths.

Computation: LLM classifies vertices and identifies discourse
relations during strata analysis. Data uploaded via API as
discourse_graph field in strata JSON.

👾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta Code <noreply@letta.com>

author
nandi
co-author
Letta Code
date (May 13, 2026, 5:59 AM UTC) commit fd65afe5 parent 566eccbf
+338 -2
+49 -2
scripts/strata-analyze.py
··· 107 107 108 108 109 109 def analyze_with_letta(context): 110 - """Ask letta to produce strata prose with semantic arrows.""" 111 - prompt = """You are a knowledge synthesis engine. Given a knowledge graph island (connected component), produce a strata analysis as structured prose. 110 + """Ask letta to produce strata prose with semantic arrows + discourse graph.""" 111 + prompt = """You are a knowledge synthesis engine. Given a knowledge graph island (connected component), produce a strata analysis as structured prose and a discourse graph. 112 112 113 113 Rules: 114 114 - Use arrows to show semantic relationships: → (forward), ← (backward), ↔ (bidirectional), ⇏ (blocked/failed) ··· 137 137 [Synthesis] 138 138 2-3 sentence synthesis paragraph 139 139 140 + [DiscourseGraph] 141 + Classify each vertex as a discourse node type: 142 + - QUE: poses a question, identifies a knowledge gap, or asks "what about..." 143 + - CLM: makes a claim, argument, or takes a position 144 + - EVD: provides empirical evidence, data, or concrete observations 145 + - SRC: is a source document, reference, or background material 146 + 147 + Then identify discourse relations between classified nodes: 148 + - informs: EVD → QUE (evidence informs a question) 149 + - supports: EVD → CLM (evidence supports a claim) 150 + - opposes: EVD → CLM (evidence opposes/contradicts a claim) 151 + - addresses: CLM → QUE (claim addresses a question) 152 + 153 + Format — one per line: 154 + NODE: title_or_short_id : TYPE 155 + REL: source → relation → target 156 + 140 157 Island data: 141 158 """ + json.dumps(context, indent=2) 142 159 ··· 173 190 "tensions": [], 174 191 "open_questions": [], 175 192 "synthesis": "", 193 + "discourse_graph": {"nodes": [], "edges": []}, 176 194 } 177 195 178 196 current_section = None ··· 196 214 elif line.startswith("[Synthesis]"): 197 215 current_section = "synthesis" 198 216 continue 217 + elif line.startswith("[DiscourseGraph]"): 218 + current_section = "discourse" 219 + continue 199 220 200 221 if current_section == "themes": 201 222 result["themes"].extend([t.strip() for t in line.split(",") if t.strip()]) ··· 207 228 result["open_questions"].append(line.lstrip("? ").strip()) 208 229 elif current_section == "synthesis": 209 230 result["synthesis"] += line + " " 231 + elif current_section == "discourse": 232 + if line.startswith("NODE:"): 233 + # NODE: title : TYPE 234 + parts = line[5:].rsplit(":", 2) 235 + if len(parts) >= 2: 236 + title = parts[-2].strip() 237 + ntype = parts[-1].strip().upper() 238 + if ntype in ("QUE", "CLM", "EVD", "SRC"): 239 + result["discourse_graph"]["nodes"].append({ 240 + "id": title, 241 + "type": ntype, 242 + }) 243 + elif line.startswith("REL:"): 244 + # REL: source → relation → target 245 + rel_text = line[4:].strip() 246 + parts = [p.strip() for p in rel_text.split("→")] 247 + if len(parts) == 3: 248 + relation = parts[1].strip().lower() 249 + if relation in ("informs", "supports", "opposes", "addresses"): 250 + result["discourse_graph"]["edges"].append({ 251 + "source": parts[0], 252 + "relation": relation, 253 + "target": parts[2], 254 + }) 210 255 211 256 result["synthesis"] = result["synthesis"].strip() 212 257 return result ··· 266 311 print(f" Themes: {', '.join(strata_data['themes'][:5])}") 267 312 print(f" Relationships: {len(strata_data['relationships'])}") 268 313 print(f" Tensions: {len(strata_data['tensions'])}") 314 + dg = strata_data.get("discourse_graph", {}) 315 + print(f" Discourse nodes: {len(dg.get('nodes', []))}, edges: {len(dg.get('edges', []))}") 269 316 270 317 if put_analysis(island_id, strata_data): 271 318 print(" Uploaded.")
+280
src/frontend/app.ts
··· 41 41 tensions: string[]; 42 42 open_questions: string[]; 43 43 synthesis: string; 44 + discourse_graph?: DiscourseGraphData; 45 + } 46 + 47 + interface DiscourseNode { 48 + id: string; 49 + type: "QUE" | "CLM" | "EVD" | "SRC"; 50 + } 51 + 52 + interface DiscourseEdge { 53 + source: string; 54 + target: string; 55 + relation: "supports" | "opposes" | "informs" | "addresses"; 56 + } 57 + 58 + interface DiscourseGraphData { 59 + nodes: DiscourseNode[]; 60 + edges: DiscourseEdge[]; 44 61 } 45 62 46 63 // ── State ────────────────────────────────────────────────────────────────── ··· 369 386 }; 370 387 } 371 388 389 + // ── Discourse Graph renderer ───────────────────────────────────────────── 390 + // 391 + // Layered layout: Questions (top) → Claims → Evidence → Sources (bottom) 392 + // Edges colored by relation: supports=green, opposes=red, informs=blue, addresses=yellow 393 + 394 + const DG_NODE_COLORS: Record<string, string> = { 395 + QUE: "#d29922", 396 + CLM: "#bc8cff", 397 + EVD: "#3fb950", 398 + SRC: "#58a6ff", 399 + }; 400 + 401 + const DG_NODE_LABELS: Record<string, string> = { 402 + QUE: "Question", 403 + CLM: "Claim", 404 + EVD: "Evidence", 405 + SRC: "Source", 406 + }; 407 + 408 + const DG_REL_COLORS: Record<string, string> = { 409 + supports: "#3fb950", 410 + opposes: "#f85149", 411 + informs: "#58a6ff", 412 + addresses: "#d29922", 413 + }; 414 + 415 + // Layer order (top to bottom) 416 + const DG_LAYERS = ["QUE", "CLM", "EVD", "SRC"]; 417 + 418 + class DiscourseGraphRenderer { 419 + private canvas: HTMLCanvasElement; 420 + private ctx: CanvasRenderingContext2D; 421 + private width: number; 422 + private height: number; 423 + private nodes: Array<{ id: string; type: string; x: number; y: number; label: string }> = []; 424 + private edges: Array<{ source: string; target: string; relation: string; sx: number; sy: number; tx: number; ty: number }> = []; 425 + private hoverNode: string | null = null; 426 + private nodeMap = new Map<string, { id: string; type: string; x: number; y: number; label: string }>(); 427 + 428 + constructor(canvas: HTMLCanvasElement, data: DiscourseGraphData) { 429 + this.canvas = canvas; 430 + this.ctx = canvas.getContext("2d")!; 431 + const rect = canvas.getBoundingClientRect(); 432 + this.width = rect.width; 433 + this.height = rect.height; 434 + canvas.width = rect.width * devicePixelRatio; 435 + canvas.height = rect.height * devicePixelRatio; 436 + this.ctx.scale(devicePixelRatio, devicePixelRatio); 437 + 438 + // Group nodes by layer 439 + const layers: Record<string, string[]> = {}; 440 + for (const layer of DG_LAYERS) layers[layer] = []; 441 + 442 + for (const n of data.nodes) { 443 + const type = n.type.toUpperCase(); 444 + if (DG_LAYERS.includes(type)) { 445 + layers[type].push(n.id); 446 + } else { 447 + layers["SRC"].push(n.id); // fallback to source 448 + } 449 + } 450 + 451 + // Position nodes in layers 452 + const padX = 80; 453 + const padY = 40; 454 + const usableW = this.width - padX * 2; 455 + const usableH = this.height - padY * 2; 456 + const activeLayers = DG_LAYERS.filter(l => layers[l].length > 0); 457 + const layerSpacing = activeLayers.length > 1 ? usableH / (activeLayers.length - 1) : 0; 458 + 459 + for (let li = 0; li < activeLayers.length; li++) { 460 + const layerType = activeLayers[li]; 461 + const layerNodes = layers[layerType]; 462 + const y = padY + li * layerSpacing; 463 + const nodeSpacing = layerNodes.length > 1 ? usableW / (layerNodes.length - 1) : 0; 464 + const startX = layerNodes.length > 1 ? padX : this.width / 2; 465 + 466 + for (let ni = 0; ni < layerNodes.length; ni++) { 467 + const id = layerNodes[ni]; 468 + const x = startX + ni * nodeSpacing; 469 + const label = id.length > 30 ? id.slice(0, 27) + "..." : id; 470 + const node = { id, type: layerType, x, y, label }; 471 + this.nodes.push(node); 472 + this.nodeMap.set(id, node); 473 + } 474 + } 475 + 476 + // Build edge coordinates 477 + for (const e of data.edges) { 478 + const sNode = this.nodeMap.get(e.source); 479 + const tNode = this.nodeMap.get(e.target); 480 + if (!sNode || !tNode) continue; 481 + this.edges.push({ 482 + source: e.source, 483 + target: e.target, 484 + relation: e.relation, 485 + sx: sNode.x, 486 + sy: sNode.y, 487 + tx: tNode.x, 488 + ty: tNode.y, 489 + }); 490 + } 491 + 492 + canvas.addEventListener("mousemove", this.onMouseMove); 493 + canvas.addEventListener("mouseleave", this.onMouseLeave); 494 + 495 + this.draw(); 496 + } 497 + 498 + destroy() { 499 + this.canvas.removeEventListener("mousemove", this.onMouseMove); 500 + this.canvas.removeEventListener("mouseleave", this.onMouseLeave); 501 + } 502 + 503 + draw() { 504 + const ctx = this.ctx; 505 + ctx.clearRect(0, 0, this.width, this.height); 506 + 507 + // Draw layer labels on left 508 + const activeLayers = DG_LAYERS.filter(l => this.nodes.some(n => n.type === l)); 509 + const padY = 40; 510 + const usableH = this.height - padY * 2; 511 + const layerSpacing = activeLayers.length > 1 ? usableH / (activeLayers.length - 1) : 0; 512 + ctx.font = "10px -apple-system, sans-serif"; 513 + ctx.textAlign = "right"; 514 + for (let li = 0; li < activeLayers.length; li++) { 515 + const y = padY + li * layerSpacing; 516 + ctx.fillStyle = DG_NODE_COLORS[activeLayers[li]]; 517 + ctx.fillText(DG_NODE_LABELS[activeLayers[li]], 60, y + 4); 518 + } 519 + 520 + // Draw edges 521 + for (const e of this.edges) { 522 + const isHovered = this.hoverNode === e.source || this.hoverNode === e.target; 523 + const color = DG_REL_COLORS[e.relation] || "#8b949e"; 524 + 525 + // Draw curved edge 526 + const midY = (e.sy + e.ty) / 2; 527 + ctx.beginPath(); 528 + ctx.moveTo(e.sx, e.sy); 529 + ctx.bezierCurveTo(e.sx, midY, e.tx, midY, e.tx, e.ty); 530 + ctx.strokeStyle = isHovered ? color : `${color}66`; 531 + ctx.lineWidth = isHovered ? 2.5 : 1.5; 532 + ctx.stroke(); 533 + 534 + // Arrow at target 535 + const t = 0.95; 536 + const t1 = 1 - t; 537 + const ax = t1 * t1 * t1 * e.sx + 3 * t1 * t1 * t * e.sx + 3 * t1 * t * t * e.tx + t * t * t * e.tx; 538 + const ay = t1 * t1 * t1 * e.sy + 3 * t1 * t1 * t * midY + 3 * t1 * t * t * midY + t * t * t * e.ty; 539 + const bx = t1 * t1 * t1 * e.sx + 3 * t1 * t1 * t * e.sx + 3 * t1 * t * t * e.tx + t * t * t * e.tx; 540 + const by = t1 * t1 * t1 * e.sy + 3 * t1 * t1 * t * midY + 3 * t1 * t * t * midY + t * t * t * e.ty; 541 + const angle = Math.atan2(e.ty - by, e.tx - bx); 542 + const arrowLen = 8; 543 + ctx.beginPath(); 544 + ctx.moveTo(e.tx, e.ty); 545 + ctx.lineTo(e.tx - arrowLen * Math.cos(angle - 0.4), e.ty - arrowLen * Math.sin(angle - 0.4)); 546 + ctx.lineTo(e.tx - arrowLen * Math.cos(angle + 0.4), e.ty - arrowLen * Math.sin(angle + 0.4)); 547 + ctx.closePath(); 548 + ctx.fillStyle = isHovered ? color : `${color}66`; 549 + ctx.fill(); 550 + 551 + // Relation label on hover 552 + if (isHovered) { 553 + const labelX = (e.sx + e.tx) / 2; 554 + const labelY = midY; 555 + ctx.font = "9px -apple-system, sans-serif"; 556 + ctx.textAlign = "center"; 557 + ctx.fillStyle = color; 558 + ctx.fillText(e.relation, labelX, labelY - 6); 559 + } 560 + } 561 + 562 + // Draw nodes 563 + for (const n of this.nodes) { 564 + const isHovered = this.hoverNode === n.id; 565 + const color = DG_NODE_COLORS[n.type] || DG_NODE_COLORS.SRC; 566 + const radius = isHovered ? 10 : 7; 567 + 568 + // Node circle 569 + ctx.beginPath(); 570 + ctx.arc(n.x, n.y, radius, 0, Math.PI * 2); 571 + ctx.fillStyle = isHovered ? color : `${color}cc`; 572 + ctx.fill(); 573 + ctx.strokeStyle = isHovered ? "#fff" : color; 574 + ctx.lineWidth = isHovered ? 2 : 1; 575 + ctx.stroke(); 576 + 577 + // Type badge inside 578 + ctx.font = "bold 7px -apple-system, sans-serif"; 579 + ctx.textAlign = "center"; 580 + ctx.fillStyle = isHovered ? "#000" : "#fff"; 581 + ctx.fillText(n.type, n.x, n.y + 2.5); 582 + 583 + // Label below 584 + ctx.font = `${isHovered ? "10" : "9"}px -apple-system, sans-serif`; 585 + ctx.textAlign = "center"; 586 + ctx.fillStyle = isHovered ? "#e6edf3" : "#8b949e"; 587 + ctx.fillText(n.label, n.x, n.y + radius + 12); 588 + } 589 + 590 + // Legend 591 + const lx = this.width - 120; 592 + const ly = 16; 593 + ctx.font = "9px -apple-system, sans-serif"; 594 + ctx.textAlign = "left"; 595 + for (const [rel, color] of Object.entries(DG_REL_COLORS)) { 596 + ctx.beginPath(); 597 + ctx.moveTo(lx, ly + Object.keys(DG_REL_COLORS).indexOf(rel) * 16 + 5); 598 + ctx.lineTo(lx + 16, ly + Object.keys(DG_REL_COLORS).indexOf(rel) * 16 + 5); 599 + ctx.strokeStyle = color; 600 + ctx.lineWidth = 2; 601 + ctx.stroke(); 602 + ctx.fillStyle = "#8b949e"; 603 + ctx.fillText(rel, lx + 22, ly + Object.keys(DG_REL_COLORS).indexOf(rel) * 16 + 8); 604 + } 605 + } 606 + 607 + private getMousePos = (e: MouseEvent) => { 608 + const rect = this.canvas.getBoundingClientRect(); 609 + return { x: e.clientX - rect.left, y: e.clientY - rect.top }; 610 + }; 611 + 612 + private findNode(x: number, y: number): string | null { 613 + for (const n of this.nodes) { 614 + const dx = n.x - x; 615 + const dy = n.y - y; 616 + if (dx * dx + dy * dy < 196) return n.id; 617 + } 618 + return null; 619 + } 620 + 621 + private onMouseMove = (e: MouseEvent) => { 622 + const pos = this.getMousePos(e); 623 + const prev = this.hoverNode; 624 + this.hoverNode = this.findNode(pos.x, pos.y); 625 + if (this.hoverNode !== prev) this.draw(); 626 + this.canvas.style.cursor = this.hoverNode ? "pointer" : "default"; 627 + }; 628 + 629 + private onMouseLeave = () => { 630 + this.hoverNode = null; 631 + this.canvas.style.cursor = "default"; 632 + this.draw(); 633 + }; 634 + } 635 + 372 636 // ── Rendering helpers ────────────────────────────────────────────────────── 373 637 374 638 function escHtml(s: string): string { ··· 665 929 ? `<div class="strata-section"><h3>Synthesis</h3><div class="synthesis-text">${escHtml(s.synthesis)}</div></div>` 666 930 : ""; 667 931 932 + // Discourse graph 933 + const dg = s.discourse_graph; 934 + const discourseHtml = (dg && dg.nodes.length > 0) 935 + ? `<div class="strata-section"><h3>Discourse Graph</h3><canvas class="discourse-graph-canvas" id="discourse-graph" width="900" height="400"></canvas></div>` 936 + : ""; 937 + 668 938 content.innerHTML = ` 669 939 <div class="strata-page"> 670 940 <a href="/" class="back-link">&larr; Explore</a> ··· 672 942 <p class="island-meta">${island.vertices.length} vertices &middot; ${island.edges.length} edges</p> 673 943 674 944 <canvas class="island-graph-large" id="strata-graph" width="900" height="500"></canvas> 945 + 946 + ${discourseHtml} 675 947 676 948 <div class="strata-section"><h3>Synthesis</h3><div class="synthesis-text">${escHtml(s.synthesis)}</div></div> 677 949 ··· 708 980 const canvas = document.getElementById("strata-graph") as HTMLCanvasElement; 709 981 if (canvas) { 710 982 new ForceGraph(canvas, island); 983 + } 984 + 985 + // Initialize the discourse graph 986 + if (dg && dg.nodes.length > 0) { 987 + const dgCanvas = document.getElementById("discourse-graph") as HTMLCanvasElement; 988 + if (dgCanvas) { 989 + new DiscourseGraphRenderer(dgCanvas, dg); 990 + } 711 991 } 712 992 } 713 993
+9
src/landing-page.ts
··· 1018 1018 font-size: 0.625rem; 1019 1019 flex-shrink: 0; 1020 1020 } 1021 + .discourse-graph-canvas { 1022 + width: 100%; 1023 + height: 350px; 1024 + border-radius: 8px; 1025 + background: var(--bg); 1026 + border: 1px solid var(--border); 1027 + cursor: default; 1028 + margin-bottom: 1rem; 1029 + } 1021 1030 1022 1031 /* ── Island card footer ──────────────────────────────────────── */ 1023 1032 .island-card-footer {