ragfabric 0.3.1__tar.gz

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.
@@ -0,0 +1,49 @@
1
+ # Environment
2
+ .env
3
+ .env.*
4
+ !.env.example
5
+
6
+ # local configuration (copy of ragfabric.example.yaml)
7
+ ragfabric.yaml
8
+
9
+ # OS
10
+ .DS_Store
11
+
12
+ # Python
13
+ __pycache__/
14
+ *.pyc
15
+ *.pyo
16
+ .venv/
17
+ venv/
18
+ .pytest_cache/
19
+ *.egg-info/
20
+
21
+ # Local data / databases
22
+ *.db
23
+ backend/data/uploads/
24
+
25
+ # Node / Angular
26
+ node_modules/
27
+ dist/
28
+ .angular/
29
+ npm-debug.log*
30
+
31
+ # Editors
32
+ .idea/
33
+ .vscode/
34
+
35
+ # Frontend test artifacts
36
+ coverage/
37
+ out-tsc/
38
+
39
+ # Local planning notes and scratch (never committed)
40
+ MEMORY*.md
41
+ *.local.md
42
+ .notes/
43
+ .scratch/
44
+
45
+ # Secrets and keys (belt and braces; .env already excluded above)
46
+ *.pem
47
+ *.key
48
+ *.p12
49
+ secrets/
@@ -0,0 +1,37 @@
1
+ Metadata-Version: 2.5
2
+ Name: ragfabric
3
+ Version: 0.3.1
4
+ Summary: RagFabric: self hosted, measurement first RAG platform. Installs the engine, the API and the ragfabric command.
5
+ Project-URL: Homepage, https://github.com/ranjan-del/ragfabric
6
+ Project-URL: Repository, https://github.com/ranjan-del/ragfabric
7
+ Project-URL: Documentation, https://github.com/ranjan-del/ragfabric/tree/main/docs
8
+ Project-URL: Changelog, https://github.com/ranjan-del/ragfabric/blob/main/CHANGELOG.md
9
+ Project-URL: Issues, https://github.com/ranjan-del/ragfabric/issues
10
+ License-Expression: Apache-2.0
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
18
+ Requires-Python: <3.14,>=3.13
19
+ Requires-Dist: ragfabric-core==0.3.1
20
+ Requires-Dist: ragfabric-sdk==0.3.1
21
+ Requires-Dist: ragfabric-server==0.3.1
22
+ Requires-Dist: typer>=0.27
23
+ Provides-Extra: anthropic
24
+ Requires-Dist: ragfabric-core[anthropic]==0.3.1; extra == 'anthropic'
25
+ Provides-Extra: bm25
26
+ Requires-Dist: ragfabric-core[bm25]==0.3.1; extra == 'bm25'
27
+ Provides-Extra: chroma
28
+ Requires-Dist: ragfabric-core[chroma]==0.3.1; extra == 'chroma'
29
+ Provides-Extra: openai
30
+ Requires-Dist: ragfabric-core[openai]==0.3.1; extra == 'openai'
31
+ Provides-Extra: rerank
32
+ Requires-Dist: ragfabric-core[rerank]==0.3.1; extra == 'rerank'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # ragfabric
36
+
37
+ `pip install ragfabric` installs the engine, the HTTP API and the `ragfabric` command. See the repository README.
@@ -0,0 +1,3 @@
1
+ # ragfabric
2
+
3
+ `pip install ragfabric` installs the engine, the HTTP API and the `ragfabric` command. See the repository README.
@@ -0,0 +1,46 @@
1
+ [project]
2
+ name = "ragfabric"
3
+ version = "0.3.1"
4
+ description = "RagFabric: self hosted, measurement first RAG platform. Installs the engine, the API and the ragfabric command."
5
+ readme = "README.md"
6
+ license = "Apache-2.0"
7
+ requires-python = ">=3.13,<3.14"
8
+ classifiers = [
9
+ "Development Status :: 3 - Alpha",
10
+ "Intended Audience :: Developers",
11
+ "Operating System :: OS Independent",
12
+ "Programming Language :: Python :: 3",
13
+ "Programming Language :: Python :: 3.13",
14
+ "Environment :: Console",
15
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
16
+ ]
17
+ dependencies = [
18
+ "ragfabric-core==0.3.1",
19
+ "ragfabric-server==0.3.1",
20
+ "ragfabric-sdk==0.3.1",
21
+ "typer>=0.27",
22
+ ]
23
+
24
+ [project.optional-dependencies]
25
+ openai = ["ragfabric-core[openai]==0.3.1"]
26
+ anthropic = ["ragfabric-core[anthropic]==0.3.1"]
27
+ chroma = ["ragfabric-core[chroma]==0.3.1"]
28
+ bm25 = ["ragfabric-core[bm25]==0.3.1"]
29
+ rerank = ["ragfabric-core[rerank]==0.3.1"]
30
+
31
+ [project.scripts]
32
+ ragfabric = "ragfabric_cli.main:app"
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/ranjan-del/ragfabric"
36
+ Repository = "https://github.com/ranjan-del/ragfabric"
37
+ Documentation = "https://github.com/ranjan-del/ragfabric/tree/main/docs"
38
+ Changelog = "https://github.com/ranjan-del/ragfabric/blob/main/CHANGELOG.md"
39
+ Issues = "https://github.com/ranjan-del/ragfabric/issues"
40
+
41
+ [build-system]
42
+ requires = ["hatchling>=1.27"]
43
+ build-backend = "hatchling.build"
44
+
45
+ [tool.hatch.build.targets.wheel]
46
+ packages = ["src/ragfabric_cli"]
@@ -0,0 +1 @@
1
+ """RagFabric command line."""
@@ -0,0 +1 @@
1
+ """Sub commands of the ragfabric CLI."""
@@ -0,0 +1,137 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+
5
+ from ragfabric_cli.commands.common import collection_by_name, session, user_by_email
6
+ from ragfabric_core.auth import service
7
+ from ragfabric_core.auth.api_keys import create_api_key
8
+ from ragfabric_core.models.access import ApiKey, CollectionGrant, Group
9
+ from ragfabric_core.models.document import Collection
10
+
11
+ groups_app = typer.Typer(help="Groups and membership.")
12
+ grants_app = typer.Typer(help="Collection grants.")
13
+ keys_app = typer.Typer(help="API keys.")
14
+
15
+
16
+ def _group(db, name: str) -> Group:
17
+ group = db.query(Group).filter(Group.name == name).first()
18
+ if group is None:
19
+ typer.echo(f"group not found: {name}")
20
+ raise typer.Exit(code=1)
21
+ return group
22
+
23
+
24
+ @groups_app.command("create")
25
+ def create_group(name: str, description: str = typer.Option("")) -> None:
26
+ with session() as db:
27
+ if db.query(Group).filter(Group.name == name).first() is not None:
28
+ typer.echo(f"group already exists: {name}")
29
+ raise typer.Exit(code=1)
30
+ service.create_group(db, name, description)
31
+ db.commit()
32
+ typer.echo(f"created group {name}")
33
+
34
+
35
+ @groups_app.command("add-member")
36
+ def add_member(group_name: str, email: str) -> None:
37
+ with session() as db:
38
+ group = _group(db, group_name)
39
+ user = user_by_email(db, email)
40
+ service.add_member(db, group.id, user.id)
41
+ db.commit()
42
+ typer.echo(f"added {email} to {group_name}")
43
+
44
+
45
+ @groups_app.command("list")
46
+ def list_groups() -> None:
47
+ with session() as db:
48
+ for g in db.query(Group).order_by(Group.name).all():
49
+ members = service_members(db, g.id)
50
+ typer.echo(f"{g.id}\t{g.name}\t{members} member(s)")
51
+
52
+
53
+ def service_members(db, group_id: int) -> int:
54
+ from ragfabric_core.models.access import GroupMember
55
+
56
+ return db.query(GroupMember).filter(GroupMember.group_id == group_id).count()
57
+
58
+
59
+ @grants_app.command("add")
60
+ def add_grant(
61
+ group: str = typer.Option(...),
62
+ collection: str = typer.Option(...),
63
+ permission: str = typer.Option("read"),
64
+ ) -> None:
65
+ with session() as db:
66
+ g = _group(db, group)
67
+ c = collection_by_name(db, collection)
68
+ try:
69
+ service.grant_collection(db, g.id, c.id, permission)
70
+ except ValueError as exc:
71
+ typer.echo(str(exc))
72
+ raise typer.Exit(code=1) from None
73
+ db.commit()
74
+ typer.echo(f"granted {permission} on {collection} to {group}")
75
+
76
+
77
+ @grants_app.command("list")
78
+ def list_grants() -> None:
79
+ with session() as db:
80
+ rows = (
81
+ db.query(CollectionGrant, Group.name, Collection.name)
82
+ .join(Group, Group.id == CollectionGrant.group_id)
83
+ .join(Collection, Collection.id == CollectionGrant.collection_id)
84
+ .all()
85
+ )
86
+ for grant, group_name, collection_name in rows:
87
+ typer.echo(f"{grant.id}\t{group_name}\t{collection_name}\t{grant.permission}")
88
+
89
+
90
+ @keys_app.command("create")
91
+ def create_key(
92
+ name: str = typer.Option(...),
93
+ user: str = typer.Option(...),
94
+ collection: list[str] = typer.Option([], "--collection"),
95
+ strategy: list[str] = typer.Option([], "--strategy"),
96
+ rate_limit: int | None = typer.Option(
97
+ None,
98
+ "--rate-limit",
99
+ help="Requests/minute; defaults to limits.rate_limit_per_minute in ragfabric.yaml.",
100
+ ),
101
+ ) -> None:
102
+ with session() as db:
103
+ owner = user_by_email(db, user)
104
+ collection_ids = [collection_by_name(db, c).id for c in collection]
105
+ key, plaintext = create_api_key(
106
+ db,
107
+ name=name,
108
+ user_id=owner.id,
109
+ collection_ids=collection_ids,
110
+ strategies=strategy,
111
+ rate_limit_per_minute=rate_limit,
112
+ )
113
+ db.commit()
114
+ typer.echo(f"created key {key.id} ({name}) for {user}")
115
+ typer.echo("store this now, it is not shown again:")
116
+ typer.echo(plaintext)
117
+
118
+
119
+ @keys_app.command("list")
120
+ def list_keys() -> None:
121
+ with session() as db:
122
+ for k in db.query(ApiKey).order_by(ApiKey.id).all():
123
+ typer.echo(
124
+ f"{k.id}\t{k.name}\t{k.key_prefix}...\t{'active' if k.is_active else 'revoked'}\tlimit {k.rate_limit_per_minute}/min"
125
+ )
126
+
127
+
128
+ @keys_app.command("revoke")
129
+ def revoke_key(key_id: int) -> None:
130
+ with session() as db:
131
+ key = db.get(ApiKey, key_id)
132
+ if key is None:
133
+ typer.echo(f"key not found: {key_id}")
134
+ raise typer.Exit(code=1)
135
+ key.is_active = False
136
+ db.commit()
137
+ typer.echo(f"revoked key {key_id}")
@@ -0,0 +1,197 @@
1
+ """ragfabric ask: ask a running RagFabric server a question.
2
+
3
+ Goes through the SDK over HTTP rather than calling core in process: the
4
+ normal case is a remote server, and a second in process code path would
5
+ drift from what API users experience. The cost is that this command needs a
6
+ running server to talk to; the help text says so.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from enum import StrEnum
13
+
14
+ import typer
15
+
16
+ from ragfabric_sdk import Client
17
+ from ragfabric_sdk.errors import RagFabricError
18
+
19
+ DEFAULT_URL = "http://localhost:8000"
20
+
21
+
22
+ class Strategy(StrEnum):
23
+ """Retrieval strategies this command can ask the server for.
24
+
25
+ An Enum rather than a free string so Typer refuses an unknown name while
26
+ parsing, before the command body runs. A typo then costs nothing: no
27
+ request leaves the machine, no embedding call is spent, and the error
28
+ names the valid choices instead of arriving as a 422 from the server.
29
+ The members mirror the server's own Literal in schemas/search.py; the
30
+ server stays the authority and still validates what it is sent.
31
+ """
32
+
33
+ traditional = "traditional"
34
+ vectorless = "vectorless"
35
+ agentic = "agentic"
36
+ graph = "graph"
37
+
38
+
39
+ def ask(
40
+ question: str = typer.Argument(..., help="The question to ask."),
41
+ url: str = typer.Option(
42
+ None, "--url", help=f"Server URL (env RAGFABRIC_URL, default {DEFAULT_URL})."
43
+ ),
44
+ token: str = typer.Option(None, "--token", help="JWT bearer token (env RAGFABRIC_TOKEN)."),
45
+ api_key: str = typer.Option(None, "--api-key", help="rf_ API key (env RAGFABRIC_API_KEY)."),
46
+ top_k: int = typer.Option(8, "--top-k", min=1, max=50, help="Chunks to retrieve."),
47
+ threshold: float = typer.Option(
48
+ 0.0, "--threshold", min=0.0, max=1.0, help="Similarity threshold."
49
+ ),
50
+ collection: int = typer.Option(None, "--collection", help="Collection id to search."),
51
+ strategy: Strategy = typer.Option(
52
+ Strategy.traditional,
53
+ "--strategy",
54
+ help=(
55
+ "Retrieval strategy. traditional embeds the question and searches the "
56
+ "vector index; vectorless ranks with BM25 fused with ts_rank_cd and "
57
+ "never calls an embedding model; agentic splits the question into "
58
+ "parts, retrieves per part, and repairs or abandons the parts it "
59
+ "cannot answer, reporting which those were; graph walks the knowledge "
60
+ "graph from the entities the question names and prints the relationships "
61
+ "it walked and any relationship claims the citation contract dropped."
62
+ ),
63
+ ),
64
+ no_stream: bool = typer.Option(
65
+ False, "--no-stream", help="Wait for the whole answer instead of streaming tokens."
66
+ ),
67
+ as_json: bool = typer.Option(
68
+ False, "--json", help="Print the whole answer payload as JSON (implies --no-stream)."
69
+ ),
70
+ ) -> None:
71
+ """Ask a question and print the cited answer.
72
+
73
+ Needs a running RagFabric server: this command talks to it over HTTP
74
+ through the RagFabric SDK, it does not answer the question locally.
75
+ Streamed stdout can contain a stale, superseded draft ahead of the
76
+ corrected answer (bytes already printed cannot be recalled); a machine
77
+ consumer should use --json or --no-stream instead of parsing the stream.
78
+ """
79
+ url = url or os.environ.get("RAGFABRIC_URL") or DEFAULT_URL
80
+ token = token or os.environ.get("RAGFABRIC_TOKEN")
81
+ api_key = api_key or os.environ.get("RAGFABRIC_API_KEY")
82
+ if not token and not api_key:
83
+ typer.echo(
84
+ "no credentials: pass --token or --api-key, or set RAGFABRIC_TOKEN "
85
+ "or RAGFABRIC_API_KEY",
86
+ err=True,
87
+ )
88
+ raise typer.Exit(2)
89
+
90
+ params: dict[str, object] = {
91
+ "top_k": top_k,
92
+ "similarity_threshold": threshold,
93
+ "strategy": strategy.value,
94
+ }
95
+ if collection is not None:
96
+ params["collection_id"] = collection
97
+
98
+ client = Client(url, token=token, api_key=api_key)
99
+ try:
100
+ if as_json or no_stream:
101
+ answer = client.ask(question, **params)
102
+ if as_json:
103
+ typer.echo(answer.model_dump_json(indent=2))
104
+ else:
105
+ typer.echo(answer.answer)
106
+ typer.echo("")
107
+ _print_sources([citation.model_dump() for citation in answer.citations])
108
+ _print_graph(
109
+ answer.subgraph.model_dump() if answer.subgraph is not None else None,
110
+ [claim.model_dump() for claim in answer.dropped_relationship_claims],
111
+ )
112
+ return
113
+
114
+ citations: list[dict] = []
115
+ subgraph: dict | None = None
116
+ dropped_relationship_claims: list[dict] = []
117
+ run_id = None
118
+ latency_ms = None
119
+ for event in client.ask_stream(question, **params):
120
+ if event.event == "retrieval":
121
+ subgraph = event.data.get("subgraph")
122
+ elif event.event == "token":
123
+ typer.echo(event.data.get("text", ""), nl=False)
124
+ elif event.event == "superseded":
125
+ # The caller already saw the rejected tokens printed above,
126
+ # and there is no way to un-print a terminal: say so on
127
+ # stderr (never mixed into a piped stdout file) and then put
128
+ # the full corrected answer on stdout so a script reading
129
+ # stdout still ends up with the complete, correct text, even
130
+ # though it is preceded by the stale draft.
131
+ typer.echo(
132
+ "\nnotice: the streamed answer above failed the citation "
133
+ "contract and was corrected; the corrected answer follows.",
134
+ err=True,
135
+ )
136
+ typer.echo("\n")
137
+ typer.echo(event.data.get("text", ""), nl=False)
138
+ dropped_relationship_claims = event.data.get("dropped_relationship_claims", [])
139
+ elif event.event == "citations":
140
+ citations = event.data.get("citations", [])
141
+ elif event.event == "done":
142
+ run_id = event.data.get("run_id")
143
+ latency_ms = event.data.get("latency_ms")
144
+ typer.echo("")
145
+ _print_sources(citations)
146
+ _print_graph(subgraph, dropped_relationship_claims)
147
+ if run_id is not None:
148
+ typer.echo(f"run {run_id} in {latency_ms}ms")
149
+ except RagFabricError as exc:
150
+ typer.echo(f"error: {exc}", err=True)
151
+ raise typer.Exit(1) from exc
152
+ except Exception as exc: # connection refused, DNS failure, timeout
153
+ typer.echo(f"could not reach {url}: {exc}", err=True)
154
+ raise typer.Exit(1) from exc
155
+ finally:
156
+ client.close()
157
+
158
+
159
+ def _print_sources(citations: list[dict]) -> None:
160
+ used = [citation for citation in citations if citation.get("used")]
161
+ if not used:
162
+ return
163
+ typer.echo("sources:")
164
+ for citation in used:
165
+ name = citation.get("filename") or f"document {citation.get('document_id')}"
166
+ page = f" p{citation['page']}" if citation.get("page") else ""
167
+ typer.echo(f" {citation['marker']} {name}{page}")
168
+
169
+
170
+ def _print_graph(subgraph: dict | None, dropped: list[dict]) -> None:
171
+ """The relationships the graph strategy walked, and the claims about them it dropped.
172
+
173
+ Printed as ``name RELATION name`` in the direction walked, the same reading
174
+ the answer's ``[E k]`` markers were numbered against. Nothing is printed
175
+ for a strategy that walks no graph.
176
+ """
177
+ if subgraph is not None:
178
+ names = {node["id"]: node["name"] for node in subgraph.get("nodes", [])}
179
+ edges = subgraph.get("edges", [])
180
+ if edges:
181
+ typer.echo("graph:")
182
+ for number, edge in enumerate(edges, start=1):
183
+ start, end = edge["source_id"], edge["target_id"]
184
+ if edge.get("reversed"):
185
+ start, end = end, start
186
+ typer.echo(
187
+ f" [E {number}] {names.get(start, start)} {edge['walked_as']} "
188
+ f"{names.get(end, end)}"
189
+ )
190
+ elif subgraph.get("empty_reason"):
191
+ typer.echo(f"graph: nothing walked ({subgraph['empty_reason']})")
192
+ if subgraph.get("truncated"):
193
+ typer.echo("graph: the walk was cut by the node budget")
194
+ if dropped:
195
+ typer.echo("dropped relationship claims:")
196
+ for claim in dropped:
197
+ typer.echo(f" {claim['reason']}: {claim['text']}")
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import contextmanager
4
+
5
+ import typer
6
+ from sqlalchemy.orm import Session
7
+
8
+ from ragfabric_core.models.document import Collection
9
+ from ragfabric_core.models.user import User
10
+
11
+
12
+ @contextmanager
13
+ def session():
14
+ from ragfabric_core.runtime import get_session_factory
15
+
16
+ db: Session = get_session_factory()()
17
+ try:
18
+ yield db
19
+ finally:
20
+ db.close()
21
+
22
+
23
+ def user_by_email(db: Session, email: str) -> User:
24
+ user = db.query(User).filter(User.email == email.lower()).first()
25
+ if user is None:
26
+ typer.echo(f"user not found: {email}")
27
+ raise typer.Exit(code=1)
28
+ return user
29
+
30
+
31
+ def collection_by_name(db: Session, name: str, create: bool = False) -> Collection:
32
+ collection = db.query(Collection).filter(Collection.name == name).first()
33
+ if collection is None:
34
+ if not create:
35
+ typer.echo(f"collection not found: {name}")
36
+ raise typer.Exit(code=1)
37
+ collection = Collection(name=name)
38
+ db.add(collection)
39
+ db.flush()
40
+ return collection
@@ -0,0 +1,125 @@
1
+ """ragfabric graph merges: inspect and undo recorded entity merges (ruling R37).
2
+
3
+ An operator command, like ``users``, ``groups``, ``grants`` and ``keys``: it
4
+ opens the configured database directly and acts on it, so running it at all
5
+ requires the database credentials only an administrator holds. There is no
6
+ HTTP endpoint for merges in this phase; the Phase 9 console owns that.
7
+
8
+ Every merge entity resolution makes is recorded as an ``EntityMerge`` row with
9
+ the evidence that justified it (Task 5). Without a way to read and reverse
10
+ those rows the record would be write-only, so ``list`` and ``show`` read them
11
+ and ``undo`` calls ``graph.resolve.unmerge`` and commits only if it succeeds.
12
+ Unmerge is globally last-in, first-out (ruling R30): an older merge cannot be
13
+ undone while a newer one is in place, and ``undo`` says which ones block it
14
+ and exits non-zero rather than attempting a partial repair.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ from typing import Any
21
+
22
+ import typer
23
+
24
+ from ragfabric_cli.commands.common import session
25
+ from ragfabric_core.graph.resolve import UnmergeBlocked, UnmergeResult, unmerge
26
+ from ragfabric_core.models.graph import Entity, EntityMerge
27
+
28
+ graph_app = typer.Typer(help="Knowledge graph administration.")
29
+ merges_app = typer.Typer(help="Recorded entity merges: list, show and undo.")
30
+ graph_app.add_typer(merges_app, name="merges")
31
+
32
+ _RESULT_FIELDS = (
33
+ "restored_entity_id",
34
+ "survivor_entity_id",
35
+ "moved_relationship_ids",
36
+ "shared_relationship_ids",
37
+ "restored_relationship_ids",
38
+ "unrestored_relationship_ids",
39
+ "changed_chunk_ids",
40
+ "restored_without_sources",
41
+ )
42
+
43
+
44
+ def _survivor_name(db, entity_id: int) -> str:
45
+ entity = db.get(Entity, entity_id)
46
+ return entity.name if entity is not None else "?"
47
+
48
+
49
+ @merges_app.command("list")
50
+ def list_merges() -> None:
51
+ """Every recorded merge, oldest first. Undo them newest first."""
52
+ with session() as db:
53
+ records = db.query(EntityMerge).order_by(EntityMerge.id).all()
54
+ if not records:
55
+ typer.echo("no recorded merges")
56
+ return
57
+ for record in records:
58
+ typer.echo(
59
+ f"{record.id}\t{record.method}\t{record.merged_name} ({record.merged_entity_type})"
60
+ f" -> {record.surviving_entity_id} {_survivor_name(db, record.surviving_entity_id)}"
61
+ f"\t{len(record.merged_source_chunk_ids)} chunk(s)\t{record.created_at:%Y-%m-%d}"
62
+ )
63
+
64
+
65
+ def _record(db, merge_id: int) -> EntityMerge:
66
+ record = db.get(EntityMerge, merge_id)
67
+ if record is None:
68
+ typer.echo(f"merge not found: {merge_id}", err=True)
69
+ raise typer.Exit(code=1)
70
+ return record
71
+
72
+
73
+ @merges_app.command("show")
74
+ def show_merge(merge_id: int) -> None:
75
+ """One merge: what was merged into what, by which method, and the evidence for it."""
76
+ with session() as db:
77
+ record = _record(db, merge_id)
78
+ evidence: dict[str, Any] = dict(record.evidence)
79
+ restore: dict[str, Any] = evidence.pop("restore", {}) or {}
80
+ typer.echo(f"merge {record.id} ({record.method}, {record.created_at:%Y-%m-%d %H:%M})")
81
+ typer.echo(f"merged: {record.merged_name} ({record.merged_entity_type})")
82
+ typer.echo(
83
+ f"into: {record.surviving_entity_id} {_survivor_name(db, record.surviving_entity_id)}"
84
+ )
85
+ typer.echo(f"model: {record.model or 'none'}")
86
+ typer.echo(f"merged aliases: {', '.join(record.merged_aliases) or 'none'}")
87
+ typer.echo(f"source chunks: {sorted(record.merged_source_chunk_ids)}")
88
+ typer.echo("evidence:")
89
+ typer.echo(json.dumps(evidence, indent=2, sort_keys=True, default=str))
90
+ # The restore payload is what makes the unmerge exact, not justification;
91
+ # summarised here so an operator can see what an undo would touch.
92
+ typer.echo(
93
+ f"undo would restore: {len(restore.get('relationships', []))} recorded "
94
+ f"relationship(s), {len(restore.get('dropped_self_loops', []))} dropped self "
95
+ f"loop(s), aliases appended {restore.get('aliases_appended', [])}"
96
+ )
97
+
98
+
99
+ @merges_app.command("undo")
100
+ def undo_merge(merge_id: int) -> None:
101
+ """Reverse one merge. Refused while any newer merge is still in place."""
102
+ with session() as db:
103
+ try:
104
+ result = unmerge(db, merge_id)
105
+ except UnmergeBlocked as exc:
106
+ db.rollback()
107
+ blocking = " ".join(str(i) for i in exc.blocking_merge_ids)
108
+ typer.echo(
109
+ f"cannot undo merge {merge_id}: blocked by later merges: {blocking} "
110
+ "(undo those first, newest first)",
111
+ err=True,
112
+ )
113
+ raise typer.Exit(code=1) from None
114
+ except (LookupError, ValueError) as exc:
115
+ db.rollback()
116
+ typer.echo(f"cannot undo merge {merge_id}: {exc}", err=True)
117
+ raise typer.Exit(code=1) from None
118
+ db.commit()
119
+ typer.echo(f"undid merge {merge_id}")
120
+ _print_result(result)
121
+
122
+
123
+ def _print_result(result: UnmergeResult) -> None:
124
+ for field in _RESULT_FIELDS:
125
+ typer.echo(f"{field}: {getattr(result, field)}")