ideagraph-live 0.5.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.
- ideagraph/__init__.py +1 -0
- ideagraph/__main__.py +350 -0
- ideagraph/brain.py +503 -0
- ideagraph/brain_engine.py +351 -0
- ideagraph/decay.py +65 -0
- ideagraph/demo.py +164 -0
- ideagraph/embedder.py +79 -0
- ideagraph/engine.py +58 -0
- ideagraph/evals.py +556 -0
- ideagraph/gaps.py +206 -0
- ideagraph/hygiene.py +188 -0
- ideagraph/intent.py +202 -0
- ideagraph/merge.py +211 -0
- ideagraph/model.py +56 -0
- ideagraph/reranker.py +67 -0
- ideagraph/retrieval.py +153 -0
- ideagraph/server.py +211 -0
- ideagraph/similarity.py +42 -0
- ideagraph/store.py +63 -0
- ideagraph/suggester.py +63 -0
- ideagraph/web/app.js +574 -0
- ideagraph/web/index.html +169 -0
- ideagraph/web/review.html +104 -0
- ideagraph/web/review.js +210 -0
- ideagraph_live-0.5.0.dist-info/METADATA +176 -0
- ideagraph_live-0.5.0.dist-info/RECORD +30 -0
- ideagraph_live-0.5.0.dist-info/WHEEL +5 -0
- ideagraph_live-0.5.0.dist-info/entry_points.txt +2 -0
- ideagraph_live-0.5.0.dist-info/licenses/LICENSE +21 -0
- ideagraph_live-0.5.0.dist-info/top_level.txt +1 -0
ideagraph/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""IdeaGraph Live Engine."""
|
ideagraph/__main__.py
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""CLI for the brain: init, ingest, pending, accept, reject, link, search.
|
|
2
|
+
|
|
3
|
+
Examples:
|
|
4
|
+
python -m ideagraph init [--remote <brain-repo-url>] [--demo]
|
|
5
|
+
python -m ideagraph ingest "New idea ..." [--source agent/bot] [--allow-dup]
|
|
6
|
+
cat note.md | python -m ideagraph ingest -
|
|
7
|
+
python -m ideagraph pending
|
|
8
|
+
python -m ideagraph accept <edge_id>
|
|
9
|
+
python -m ideagraph reject <edge_id>
|
|
10
|
+
python -m ideagraph link <node_a> <node_b> [--kind same_as]
|
|
11
|
+
python -m ideagraph search "attention"
|
|
12
|
+
python -m ideagraph gaps [--taxonomy tax.json] [--min 10] [--json]
|
|
13
|
+
python -m ideagraph merge <survivor_id> <deletee_id> # consolidate a near-dup
|
|
14
|
+
python -m ideagraph near-dup [--lo 0.78] [--hi 0.92] # report near-duplicate pairs
|
|
15
|
+
python -m ideagraph status [--json] # connectivity/hygiene report
|
|
16
|
+
|
|
17
|
+
Env like the server: IG_BRAIN_PATH, IG_BRAIN_REMOTE, IG_BRAIN_MODE,
|
|
18
|
+
IDEAGRAPH_EMBEDDER (st|hash), IDEAGRAPH_EMBEDDER_MODEL.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
|
|
26
|
+
from .brain import Brain
|
|
27
|
+
from .brain_engine import BrainEngine
|
|
28
|
+
from .embedder import get_embedder
|
|
29
|
+
from .gaps import analyze_coverage, find_gaps, render, load_taxonomy
|
|
30
|
+
from .hygiene import near_dup_pairs, connectivity, status_counts, render_near_dup, render_status
|
|
31
|
+
from .merge import merge_nodes
|
|
32
|
+
from .retrieval import retrieve
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def make_engine() -> BrainEngine:
|
|
36
|
+
# Audit #33: expanduser muss AUCH auf einen explizit gesetzten Env-Wert
|
|
37
|
+
# actually applied (IG_BRAIN_PATH=~/x used to create a literal ./~).
|
|
38
|
+
brain_path = os.path.expanduser(
|
|
39
|
+
os.environ.get("IG_BRAIN_PATH", os.path.expanduser("~/ideagraph-brain")))
|
|
40
|
+
brain = Brain(
|
|
41
|
+
path=brain_path,
|
|
42
|
+
# No private/personal default remote: only needed for `git clone`
|
|
43
|
+
# on first setup. Existing clones use their own
|
|
44
|
+
# origin-Repo (pull/push funktionieren ohne Remote-Angabe).
|
|
45
|
+
remote=os.environ.get("IG_BRAIN_REMOTE", "") or None,
|
|
46
|
+
mode=os.environ.get("IG_BRAIN_MODE", "git"),
|
|
47
|
+
)
|
|
48
|
+
_emb = os.environ.get("IDEAGRAPH_EMBEDDER", "st")
|
|
49
|
+
_model = os.environ.get("IDEAGRAPH_EMBEDDER_MODEL") # audit #60
|
|
50
|
+
return BrainEngine(brain, get_embedder(_emb, _model))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _short(text: str, n: int = 70) -> str:
|
|
54
|
+
text = text.replace("\n", " ")
|
|
55
|
+
return text[: n - 1] + "…" if len(text) > n else text
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def cmd_ingest(engine: BrainEngine, args: list[str]) -> None:
|
|
59
|
+
source = "human"
|
|
60
|
+
allow_dup = False
|
|
61
|
+
rest: list[str] = []
|
|
62
|
+
i = 0
|
|
63
|
+
while i < len(args):
|
|
64
|
+
if args[i] == "--source":
|
|
65
|
+
# Audit #26: fehlender Wert nach --source ist ein Usage-Fehler,
|
|
66
|
+
# kein IndexError-Traceback.
|
|
67
|
+
if i + 1 >= len(args):
|
|
68
|
+
print("Usage: ig ingest \"text\" --source <source>")
|
|
69
|
+
sys.exit(1)
|
|
70
|
+
source = args[i + 1]
|
|
71
|
+
i += 2
|
|
72
|
+
elif args[i] == "--allow-dup":
|
|
73
|
+
allow_dup = True
|
|
74
|
+
i += 1
|
|
75
|
+
else:
|
|
76
|
+
rest.append(args[i])
|
|
77
|
+
i += 1
|
|
78
|
+
# Audit #32: '-' ist der stdin-Marker — auch in gemischten Args. Vorher
|
|
79
|
+
# 'ig ingest - extra' used to ingest the literal text "- extra"
|
|
80
|
+
# (exit 0, node created). A '-' token means stdin; an extra
|
|
81
|
+
# Text daneben ist ein Fehler.
|
|
82
|
+
if "-" in rest:
|
|
83
|
+
if len(rest) > 1:
|
|
84
|
+
print("Usage: '-' (stdin) cannot be combined with text arguments.")
|
|
85
|
+
sys.exit(1)
|
|
86
|
+
text = sys.stdin.read()
|
|
87
|
+
else:
|
|
88
|
+
text = " ".join(rest)
|
|
89
|
+
if not text.strip():
|
|
90
|
+
print("Nothing to ingest. Usage: ig ingest \"text\" | ig ingest - < file")
|
|
91
|
+
sys.exit(1)
|
|
92
|
+
node, edges, dup = engine.ingest(text, source=source, allow_duplicates=allow_dup)
|
|
93
|
+
if dup:
|
|
94
|
+
print(f"Duplicate → merged into {node.id}: {_short(node.text)}")
|
|
95
|
+
else:
|
|
96
|
+
print(f"Node {node.id}: {_short(node.text)}")
|
|
97
|
+
for e in edges:
|
|
98
|
+
print(f" Suggestion: --[{e.kind}]--> {e.target} ({e.id})")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def cmd_pending(engine: BrainEngine, args: list[str]) -> None:
|
|
102
|
+
edges = [e for e in engine.brain.read_edges() if e.pending]
|
|
103
|
+
texts = {n.id: n.text for n in engine.brain.read_nodes()}
|
|
104
|
+
if not edges:
|
|
105
|
+
print("No pending suggestions.")
|
|
106
|
+
return
|
|
107
|
+
for e in edges:
|
|
108
|
+
print(f"{e.id} [{e.kind}] {_short(texts.get(e.source, e.source), 40)}"
|
|
109
|
+
f" ↔ {_short(texts.get(e.target, e.target), 40)}")
|
|
110
|
+
print(f"\n{len(edges)} pending · akzeptieren: ig accept {edges[0].id}")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _first_or_usage(args: list[str], cmd: str) -> str:
|
|
114
|
+
"""Audit #26: fehlendes Positionsargument → Usage-Zeile statt IndexError."""
|
|
115
|
+
if not args:
|
|
116
|
+
print(f"Usage: ig {cmd} <edge_id>")
|
|
117
|
+
sys.exit(1)
|
|
118
|
+
return args[0]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _resolve_cmd(engine: BrainEngine, edge_id: str, accept: bool) -> None:
|
|
122
|
+
edge = engine.resolve(edge_id, accept)
|
|
123
|
+
if edge is None:
|
|
124
|
+
print(f"Edge {edge_id} not found or not pending.")
|
|
125
|
+
sys.exit(1)
|
|
126
|
+
print(f"{'accepted' if accept else 'rejected'}: {edge_id} [{edge.kind}]")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def cmd_link(engine: BrainEngine, args: list[str]) -> None:
|
|
130
|
+
kind = "same_as"
|
|
131
|
+
if "--kind" in args:
|
|
132
|
+
i = args.index("--kind")
|
|
133
|
+
# Audit #26: fehlender Wert nach --kind → Usage-Fehler statt IndexError.
|
|
134
|
+
if i + 1 >= len(args):
|
|
135
|
+
print("Usage: ig link <node_a> <node_b> [--kind same_as]")
|
|
136
|
+
sys.exit(1)
|
|
137
|
+
kind = args[i + 1]
|
|
138
|
+
args = args[:i] + args[i + 2:]
|
|
139
|
+
if len(args) != 2:
|
|
140
|
+
print("Usage: ig link <node_a> <node_b> [--kind same_as]")
|
|
141
|
+
sys.exit(1)
|
|
142
|
+
try:
|
|
143
|
+
edge = engine.link(args[0], args[1], kind)
|
|
144
|
+
except ValueError as exc:
|
|
145
|
+
print(f"Error: {exc}")
|
|
146
|
+
sys.exit(1)
|
|
147
|
+
print(f"verlinkt: {edge.source} --[{edge.kind}]--> {edge.target}")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def cmd_init(engine: BrainEngine, args: list[str]) -> None:
|
|
151
|
+
remote = None
|
|
152
|
+
demo = "--demo" in args
|
|
153
|
+
if "--remote" in args:
|
|
154
|
+
i = args.index("--remote")
|
|
155
|
+
remote = args[i + 1] if i + 1 < len(args) else None
|
|
156
|
+
brain = engine.brain
|
|
157
|
+
if demo:
|
|
158
|
+
from .demo import build_demo_brain
|
|
159
|
+
try:
|
|
160
|
+
stats = build_demo_brain(str(brain.path))
|
|
161
|
+
except FileExistsError:
|
|
162
|
+
# Audit #58: friendly message instead of a raw traceback — a
|
|
163
|
+
# non-empty directory is never overwritten.
|
|
164
|
+
print(f"Error: {brain.path} already exists and is not empty.")
|
|
165
|
+
print("The demo brain is never written into an existing directory.")
|
|
166
|
+
print("Choose a different path: IG_BRAIN_PATH=<path> ig init --demo")
|
|
167
|
+
sys.exit(1)
|
|
168
|
+
print(f"✓ Demo brain initialized: {brain.path}")
|
|
169
|
+
print(f" {stats['nodes']} nodes · {stats['edges']} edges "
|
|
170
|
+
f"({stats['pending']} pending for HITL review)")
|
|
171
|
+
print(" Includes: all edge types, 1 orphan island (demos `ig status`),")
|
|
172
|
+
print(" 1 near-dup pair (demos `ig near-dup` + `ig merge`), 1 same_as pair.")
|
|
173
|
+
print("Try it out:")
|
|
174
|
+
print(" ig status # island + hygiene report")
|
|
175
|
+
print(" ig near-dup # find the demo near-dup pair")
|
|
176
|
+
print(" ig pending # review the 2 pending suggestions")
|
|
177
|
+
print(" ig search \"RAG\" # hybrid search (works instantly)")
|
|
178
|
+
print(" uvicorn ideagraph.server:app --port 8000 # → http://localhost:8000")
|
|
179
|
+
return
|
|
180
|
+
if (brain.path / "INDEX.md").exists() or (brain.path / "nodes").exists():
|
|
181
|
+
# same #58 principle for plain init: never clobber an existing brain
|
|
182
|
+
print(f"Error: {brain.path} already contains a brain.")
|
|
183
|
+
print("Choose a different path: IG_BRAIN_PATH=<path> ig init")
|
|
184
|
+
sys.exit(1)
|
|
185
|
+
brain.init(remote=remote, commit=True)
|
|
186
|
+
print(f"✓ Brain repo initialized: {brain.path}")
|
|
187
|
+
mode_line = f" Mode: {brain.mode}"
|
|
188
|
+
mode_line += (f" · Remote: {remote}" if remote else " (local, no remote)")
|
|
189
|
+
print(mode_line)
|
|
190
|
+
print(" Structure: nodes/ · edges.jsonl · vectors.jsonl · INDEX.md")
|
|
191
|
+
print("Get started:")
|
|
192
|
+
print(' ig ingest "First idea ..." # CLI ingest')
|
|
193
|
+
print(" uvicorn ideagraph.server:app --port 8000 # → http://localhost:8000")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def cmd_gaps(engine: BrainEngine, args: list[str]) -> None:
|
|
197
|
+
taxonomy = None
|
|
198
|
+
threshold = 10
|
|
199
|
+
as_json = False
|
|
200
|
+
i = 0
|
|
201
|
+
while i < len(args):
|
|
202
|
+
if args[i] == "--taxonomy" and i + 1 < len(args):
|
|
203
|
+
taxonomy = load_taxonomy(args[i + 1])
|
|
204
|
+
i += 2
|
|
205
|
+
elif args[i] == "--min" and i + 1 < len(args):
|
|
206
|
+
# Audit #26: nicht-numerische --min-Werte → Usage-Fehler statt ValueError-Traceback.
|
|
207
|
+
try:
|
|
208
|
+
threshold = int(args[i + 1])
|
|
209
|
+
except ValueError:
|
|
210
|
+
print(f"Usage: --min expects a number, got: {args[i + 1]!r}")
|
|
211
|
+
sys.exit(1)
|
|
212
|
+
i += 2
|
|
213
|
+
elif args[i] == "--json":
|
|
214
|
+
as_json = True
|
|
215
|
+
i += 1
|
|
216
|
+
else:
|
|
217
|
+
i += 1
|
|
218
|
+
cov = analyze_coverage(engine.brain, taxonomy)
|
|
219
|
+
if as_json:
|
|
220
|
+
import json as _json
|
|
221
|
+
print(_json.dumps({
|
|
222
|
+
"total": cov.total,
|
|
223
|
+
"areas": [{"name": a.name, "count": a.count} for a in cov.areas],
|
|
224
|
+
"gaps": [a.name for a in find_gaps(cov, threshold)],
|
|
225
|
+
"unclassified": cov.unclassified,
|
|
226
|
+
}, ensure_ascii=False, indent=2))
|
|
227
|
+
else:
|
|
228
|
+
print(render(cov, threshold))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def cmd_merge(engine: BrainEngine, args: list[str]) -> None:
|
|
232
|
+
if len(args) != 2:
|
|
233
|
+
print("Usage: ig merge <survivor_id> <deletee_id> (consolidates deletee into survivor)")
|
|
234
|
+
sys.exit(1)
|
|
235
|
+
survivor, deletee = args[0], args[1]
|
|
236
|
+
try:
|
|
237
|
+
r = merge_nodes(engine.brain, survivor, deletee, embedder=engine.embedder)
|
|
238
|
+
except ValueError as exc:
|
|
239
|
+
print(f"Error: {exc}")
|
|
240
|
+
sys.exit(1)
|
|
241
|
+
print(f"merge: {r.deletee} consolidated into {r.survivor}")
|
|
242
|
+
print(f" edges redirected: {r.edges_redirected} · removed: {r.edges_removed}")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def cmd_near_dup(engine: BrainEngine, args: list[str]) -> None:
|
|
246
|
+
lo, hi, max_pairs, as_json = 0.78, 0.92, None, False
|
|
247
|
+
i = 0
|
|
248
|
+
while i < len(args):
|
|
249
|
+
if args[i] == "--lo" and i + 1 < len(args):
|
|
250
|
+
try:
|
|
251
|
+
lo = float(args[i + 1])
|
|
252
|
+
except ValueError:
|
|
253
|
+
print(f"Usage: --lo expects a number, got: {args[i + 1]!r}")
|
|
254
|
+
sys.exit(1)
|
|
255
|
+
i += 2
|
|
256
|
+
elif args[i] == "--hi" and i + 1 < len(args):
|
|
257
|
+
try:
|
|
258
|
+
hi = float(args[i + 1])
|
|
259
|
+
except ValueError:
|
|
260
|
+
print(f"Usage: --hi expects a number, got: {args[i + 1]!r}")
|
|
261
|
+
sys.exit(1)
|
|
262
|
+
i += 2
|
|
263
|
+
elif args[i] == "--max" and i + 1 < len(args):
|
|
264
|
+
try:
|
|
265
|
+
max_pairs = int(args[i + 1])
|
|
266
|
+
except ValueError:
|
|
267
|
+
print(f"Usage: --max expects a number, got: {args[i + 1]!r}")
|
|
268
|
+
sys.exit(1)
|
|
269
|
+
i += 2
|
|
270
|
+
elif args[i] == "--json":
|
|
271
|
+
as_json = True; i += 1
|
|
272
|
+
else:
|
|
273
|
+
i += 1
|
|
274
|
+
# Audit #26 (Semantik): --lo >= --hi ist eine leere/invalide Band-Angabe;
|
|
275
|
+
# --max 0 means "0 pairs" (a limit), not "unlimited".
|
|
276
|
+
if lo >= hi:
|
|
277
|
+
print(f"Usage: --lo ({lo}) must be smaller than --hi ({hi}).")
|
|
278
|
+
sys.exit(1)
|
|
279
|
+
if max_pairs is not None and max_pairs < 0:
|
|
280
|
+
print("Usage: --max expects a non-negative number.")
|
|
281
|
+
sys.exit(1)
|
|
282
|
+
pairs = near_dup_pairs(engine.brain, lo=lo, hi=hi, max_pairs=max_pairs)
|
|
283
|
+
if as_json:
|
|
284
|
+
import json as _json
|
|
285
|
+
print(_json.dumps(
|
|
286
|
+
[{"score": p.score, "a": p.a, "b": p.b, "a_text": p.a_text, "b_text": p.b_text}
|
|
287
|
+
for p in pairs], ensure_ascii=False, indent=2))
|
|
288
|
+
else:
|
|
289
|
+
print(render_near_dup(pairs))
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def cmd_status(engine: BrainEngine, args: list[str]) -> None:
|
|
293
|
+
as_json = "--json" in args
|
|
294
|
+
if as_json:
|
|
295
|
+
import json as _json
|
|
296
|
+
c = connectivity(engine.brain)
|
|
297
|
+
print(_json.dumps({
|
|
298
|
+
"total": c.total, "edges": c.edges, "max_degree": c.max_degree,
|
|
299
|
+
"mean_degree": round(c.mean_degree, 2),
|
|
300
|
+
"orphans": len(c.orphans), "islands": len(c.islands), "weak": len(c.weak),
|
|
301
|
+
"status": dict(status_counts(engine.brain)),
|
|
302
|
+
}, ensure_ascii=False, indent=2))
|
|
303
|
+
else:
|
|
304
|
+
print(render_status(engine.brain))
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def cmd_search(engine: BrainEngine, args: list[str]) -> None:
|
|
308
|
+
if not args:
|
|
309
|
+
print("Usage: ig search <term>")
|
|
310
|
+
sys.exit(1)
|
|
311
|
+
q = " ".join(args)
|
|
312
|
+
id2node = {n.id: n for n in engine.brain.read_nodes()}
|
|
313
|
+
hits = retrieve(engine, q, k=5)
|
|
314
|
+
for nid, score in hits:
|
|
315
|
+
n = id2node.get(nid)
|
|
316
|
+
if n is not None:
|
|
317
|
+
print(f"{nid} {score:.3f} {_short(n.text)}")
|
|
318
|
+
print(f"\n{len(hits)} hits (hybrid dense+BM25)")
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
COMMANDS = {
|
|
322
|
+
"init": cmd_init,
|
|
323
|
+
"ingest": cmd_ingest,
|
|
324
|
+
"pending": lambda e, a: cmd_pending(e, a),
|
|
325
|
+
"accept": lambda e, a: _resolve_cmd(e, _first_or_usage(a, "accept"), True),
|
|
326
|
+
"reject": lambda e, a: _resolve_cmd(e, _first_or_usage(a, "reject"), False),
|
|
327
|
+
"link": cmd_link,
|
|
328
|
+
"search": cmd_search,
|
|
329
|
+
"gaps": cmd_gaps,
|
|
330
|
+
"merge": cmd_merge,
|
|
331
|
+
"near-dup": cmd_near_dup,
|
|
332
|
+
"status": cmd_status,
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def main() -> None:
|
|
337
|
+
args = sys.argv[1:]
|
|
338
|
+
if not args or args[0] in ("-h", "--help"):
|
|
339
|
+
print(__doc__)
|
|
340
|
+
sys.exit(0)
|
|
341
|
+
cmd, rest = args[0], args[1:]
|
|
342
|
+
fn = COMMANDS.get(cmd)
|
|
343
|
+
if fn is None:
|
|
344
|
+
print(f"Unknown command: {cmd}. Available: {', '.join(COMMANDS)}")
|
|
345
|
+
sys.exit(1)
|
|
346
|
+
fn(make_engine(), rest)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
if __name__ == "__main__":
|
|
350
|
+
main()
|