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 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()