diff --git a/ui/server.py b/ui/server.py index 3d41b0a..5dde893 100644 --- a/ui/server.py +++ b/ui/server.py @@ -1449,6 +1449,99 @@ async def rate_experience(request: Request): "rating": target.get("rating"), "starred": target.get("starred", False)} +@app.get("/api/experience/graph") +async def get_experience_graph(): + """Build an Obsidian-style graph of experience entries + shared tag nodes. + + Nodes: + - type="entry": one per experience entry (size grows with quality/rating) + - type="phase" | "problem" | "model_type" | "problem_type": tag nodes + aggregating entries that share that value. + Edges: entry ↔ tag membership (single undirected edge per pair). + Only tag nodes with >=2 members are emitted, to keep the graph readable. + """ + if not EXPERIENCE_LOG.exists(): + return {"nodes": [], "edges": [], "stats": {"entries": 0}} + try: + data = json.loads(EXPERIENCE_LOG.read_text(encoding="utf-8")) + except Exception: + return {"nodes": [], "edges": [], "stats": {"entries": 0}} + + entries: list[dict] = data.get("entries", []) + TAG_FIELDS = ("phase", "problem", "model_type", "problem_type") + tag_members: dict[tuple[str, str], list[str]] = {} + + entry_nodes: list[dict] = [] + for e in entries: + eid = str(e.get("id") or "") + if not eid: + continue + phase = str(e.get("phase") or "") + problem = str(e.get("problem") or "") + model_type = str(e.get("model_type") or "") + problem_type = str(e.get("problem_type") or "") + try: + quality = float(e.get("quality_score") or 0.0) + except (TypeError, ValueError): + quality = 0.0 + try: + rating = int(e.get("rating") or 0) + except (TypeError, ValueError): + rating = 0 + starred = bool(e.get("starred")) + + size = 8 + int(quality / 10) + rating * 2 + (4 if starred else 0) + label = f"{phase} · {problem or '?'}" if phase else eid[:12] + + entry_nodes.append({ + "id": eid, + "label": label, + "type": "entry", + "phase": phase, + "problem": problem, + "model_type": model_type, + "problem_type": problem_type, + "quality_score": quality, + "rating": rating, + "starred": starred, + "size": size, + "title": (e.get("phase_name") or phase) + (f" · Q{quality:.0f}" if quality else ""), + }) + + for field in TAG_FIELDS: + val = str(e.get(field) or "").strip() + if not val: + continue + tag_members.setdefault((field, val), []).append(eid) + + tag_nodes: list[dict] = [] + edges: list[dict] = [] + for (field, val), members in tag_members.items(): + if len(members) < 2: + continue + tag_id = f"tag::{field}::{val}" + tag_nodes.append({ + "id": tag_id, + "label": f"{val}", + "type": field, + "size": 10 + min(len(members), 20), + "member_count": len(members), + "title": f"{field}: {val} ({len(members)} entries)", + }) + for eid in members: + edges.append({"source": eid, "target": tag_id, "field": field}) + + return { + "nodes": entry_nodes + tag_nodes, + "edges": edges, + "stats": { + "entries": len(entry_nodes), + "tags": len(tag_nodes), + "edges": len(edges), + }, + } + + # ─────────────────────────────────────────────────────── entry ── if __name__ == "__main__": diff --git a/ui/static/index.html b/ui/static/index.html index 00dc4b1..1a1d8b7 100644 --- a/ui/static/index.html +++ b/ui/static/index.html @@ -1373,16 +1373,35 @@

- +
+ + + +
- -
- @@ -2403,9 +2422,117 @@

${escapeHtml(f.filename)} vis-network 加载失败(请检查网络):${e.message}`; + return; + } + let data; + try { + data = await api('/api/experience/graph'); + } catch(e) { + canvas.innerHTML = `
加载失败: ${e.message}
`; + return; + } + if (!data.nodes || !data.nodes.length) { + canvas.innerHTML = '
暂无经验数据 · 运行一次完整流水线后再来查看
'; + stats.textContent = ''; + return; + } + stats.textContent = `条目 ${data.stats.entries} · 标签 ${data.stats.tags} · 关系 ${data.stats.edges}`; + + const nodes = data.nodes.map(n => ({ + id: n.id, + label: n.label, + title: n.title || n.label, + color: { background: EXP_GRAPH_COLORS[n.type] || '#999', border: '#333' }, + size: n.size || 10, + font: { size: n.type === 'entry' ? 10 : 12, color: '#2a2a2a' }, + shape: n.type === 'entry' ? 'dot' : 'diamond', + _type: n.type, + _meta: n, + })); + const edges = data.edges.map(e => ({ + from: e.source, + to: e.target, + color: { color: '#cfc4b0', opacity: 0.6 }, + width: 1, + })); + + if (_expNetwork) { _expNetwork.destroy(); _expNetwork = null; } + _expNetwork = new vis.Network( + canvas, + { nodes, edges }, + { + physics: { + enabled: true, + solver: 'forceAtlas2Based', + forceAtlas2Based: { gravitationalConstant: -40, springLength: 100, springConstant: 0.08 }, + stabilization: { iterations: 180 }, + }, + interaction: { hover: true, tooltipDelay: 120 }, + nodes: { shadow: false, borderWidth: 1 }, + edges: { smooth: { type: 'continuous' } }, + }, + ); + _expNetwork.on('click', (params) => { + if (!params.nodes.length) return; + const node = nodes.find(n => n.id === params.nodes[0]); + if (node && node._type === 'entry') { + setExpView('cards'); + setTimeout(() => { + const el = document.querySelector(`.exp-card[data-entry-id="${node.id}"]`); + if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.style.outline = '2px solid var(--accent)'; setTimeout(() => el.style.outline = '', 1500); } + }, 100); + } + }); } function renderExperienceCards(entries, total) { @@ -2478,7 +2605,7 @@

${escapeHtml(f.filename)} +
${e.phase} ${e.phase_name||''} ${e.problem || '(未知题目)'}