ragfabric 0.3.1__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.
- ragfabric-0.3.1.dist-info/METADATA +37 -0
- ragfabric-0.3.1.dist-info/RECORD +16 -0
- ragfabric-0.3.1.dist-info/WHEEL +4 -0
- ragfabric-0.3.1.dist-info/entry_points.txt +2 -0
- ragfabric_cli/__init__.py +1 -0
- ragfabric_cli/commands/__init__.py +1 -0
- ragfabric_cli/commands/access.py +137 -0
- ragfabric_cli/commands/ask.py +197 -0
- ragfabric_cli/commands/common.py +40 -0
- ragfabric_cli/commands/graph.py +125 -0
- ragfabric_cli/commands/ingest.py +53 -0
- ragfabric_cli/commands/reconcile.py +60 -0
- ragfabric_cli/commands/reindex.py +69 -0
- ragfabric_cli/commands/users.py +55 -0
- ragfabric_cli/commands/worker.py +54 -0
- ragfabric_cli/main.py +206 -0
|
@@ -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,16 @@
|
|
|
1
|
+
ragfabric_cli/__init__.py,sha256=8B-VD41-WFEgo-LVSQI5LcLE5UtZ6v38cbFdnwZFjDw,30
|
|
2
|
+
ragfabric_cli/main.py,sha256=_JNdgukCMZY9VKinmC_kwLCwu6sT0C1HGsXdlP-opiU,8071
|
|
3
|
+
ragfabric_cli/commands/__init__.py,sha256=3qHg7CY7ESOstohCc0rYKfSESzpAuiMHEMLm8QPoiHg,41
|
|
4
|
+
ragfabric_cli/commands/access.py,sha256=NvqyyAZBi2DRAV9O-pnCoE-KCJ6E7dCJUhfOMKOLGbg,4563
|
|
5
|
+
ragfabric_cli/commands/ask.py,sha256=ctKRNzBSssmeqMeRjbIIYnAGSultK4DU0ix-KImoZ5k,8494
|
|
6
|
+
ragfabric_cli/commands/common.py,sha256=zR958BOF4RKqCc1k3xmJUq42FjTNfkmjkSczyjueqHg,1074
|
|
7
|
+
ragfabric_cli/commands/graph.py,sha256=vIUbVF9TV2Ki-08R6CZSjv6q789DpfRpG-35bmuUsvo,5152
|
|
8
|
+
ragfabric_cli/commands/ingest.py,sha256=35MXrSJJhj-vSbsD_Rpn2qpSBUCG4_KMptI-BzmBc0c,1845
|
|
9
|
+
ragfabric_cli/commands/reconcile.py,sha256=tXvtoEdORD55DpO97vr_Gy8yhJL6eobv5ISFIQv6je4,2737
|
|
10
|
+
ragfabric_cli/commands/reindex.py,sha256=v7EDk5A-lzDepTuLvfU204ut9dtuUXx2_-7PSLInjWg,2636
|
|
11
|
+
ragfabric_cli/commands/users.py,sha256=J-a4xQ8u0nIGcLKpEL560G6iXXr0X1pNV7sipH1suIk,1735
|
|
12
|
+
ragfabric_cli/commands/worker.py,sha256=IJ2DQvSvaZJlesld4AF6DvVECC0UFMUZVDqK0Lm_flg,1932
|
|
13
|
+
ragfabric-0.3.1.dist-info/METADATA,sha256=-qLEqDqtoD8b9wpbA1TP7Y6YjYSEQ5LvW0DnhubFPu8,1686
|
|
14
|
+
ragfabric-0.3.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
15
|
+
ragfabric-0.3.1.dist-info/entry_points.txt,sha256=Gw-r4m1ceIpGfI4sDCoDpwto3BHIyhYsBpzePF7FGdk,53
|
|
16
|
+
ragfabric-0.3.1.dist-info/RECORD,,
|
|
@@ -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)}")
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from ragfabric_cli.commands.common import collection_by_name, session, user_by_email
|
|
8
|
+
from ragfabric_core.ingest.parser import SUPPORTED_FORMATS
|
|
9
|
+
from ragfabric_core.ingest.pipeline import ingest_document
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _files(path: Path, recursive: bool) -> list[Path]:
|
|
13
|
+
if path.is_file():
|
|
14
|
+
return [path]
|
|
15
|
+
pattern = "**/*" if recursive else "*"
|
|
16
|
+
return sorted(
|
|
17
|
+
p
|
|
18
|
+
for p in path.glob(pattern)
|
|
19
|
+
if p.is_file() and p.suffix.lower().lstrip(".") in SUPPORTED_FORMATS
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def ingest(
|
|
24
|
+
path: Path = typer.Argument(..., exists=True),
|
|
25
|
+
collection: str | None = typer.Option(None, "--collection"),
|
|
26
|
+
recursive: bool = typer.Option(False, "--recursive"),
|
|
27
|
+
owner: str | None = typer.Option(None, "--owner", help="Email of the owning user."),
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Ingest one file or every supported file in a directory."""
|
|
30
|
+
files = _files(path, recursive)
|
|
31
|
+
if not files:
|
|
32
|
+
typer.echo("no supported files found")
|
|
33
|
+
raise typer.Exit(code=1)
|
|
34
|
+
failed = 0
|
|
35
|
+
with session() as db:
|
|
36
|
+
collection_id = collection_by_name(db, collection, create=True).id if collection else None
|
|
37
|
+
owner_id = user_by_email(db, owner).id if owner else None
|
|
38
|
+
db.commit()
|
|
39
|
+
for file in files:
|
|
40
|
+
doc = ingest_document(
|
|
41
|
+
db,
|
|
42
|
+
filename=file.name,
|
|
43
|
+
data=file.read_bytes(),
|
|
44
|
+
collection_id=collection_id,
|
|
45
|
+
owner_id=owner_id,
|
|
46
|
+
)
|
|
47
|
+
typer.echo(
|
|
48
|
+
f"{file.name}: {doc.status} ({doc.num_chunks} chunks){' ' + doc.error if doc.error else ''}"
|
|
49
|
+
)
|
|
50
|
+
failed += doc.status == "failed"
|
|
51
|
+
typer.echo(f"{len(files) - failed} ingested, {failed} failed")
|
|
52
|
+
if failed:
|
|
53
|
+
raise typer.Exit(code=1)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""ragfabric reconcile: retry documents stuck mid fan-out.
|
|
2
|
+
|
|
3
|
+
index_document writes the vector store and the lexical store as two separate
|
|
4
|
+
commits (workers/handlers.py), so a hard crash between them leaves a document
|
|
5
|
+
stuck in "indexing" forever with no exception for anything to have caught.
|
|
6
|
+
This command is the operator-triggered retry: it finds every document stuck
|
|
7
|
+
in "indexing" past the grace period and re-runs indexing for it, which is
|
|
8
|
+
safe because index_document is idempotent (see workers/handlers.py).
|
|
9
|
+
|
|
10
|
+
Known limitation: "stuck" is judged purely by age, with no lock or worker
|
|
11
|
+
heartbeat behind it. A document that is merely slow (a large file, or a
|
|
12
|
+
rate-limited embedding provider) rather than crashed can be re-run by this
|
|
13
|
+
command WHILE a live worker is still processing it. The result still
|
|
14
|
+
converges, since the underlying writes are idempotent, but the embedding
|
|
15
|
+
provider gets called a second time, which costs money and can itself trigger
|
|
16
|
+
the same rate limiting that made the document slow. The safe procedure is to
|
|
17
|
+
stop the worker(s) before running this command.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import typer
|
|
23
|
+
|
|
24
|
+
from ragfabric_cli.commands.common import session
|
|
25
|
+
from ragfabric_core.providers.registry import build_embedding_provider
|
|
26
|
+
from ragfabric_core.runtime import get_config, get_session_factory
|
|
27
|
+
from ragfabric_core.stores.registry import build_lexical_store, build_vector_store
|
|
28
|
+
from ragfabric_core.workers.handlers import reconcile_stuck_indexing
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def reconcile(
|
|
32
|
+
older_than_seconds: int = typer.Option(
|
|
33
|
+
300,
|
|
34
|
+
"--older-than",
|
|
35
|
+
min=0,
|
|
36
|
+
help="Only retry documents that have been stuck in 'indexing' for at least this long.",
|
|
37
|
+
),
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Retry any document left stuck in 'indexing' by a crashed fan out.
|
|
40
|
+
|
|
41
|
+
Limitation: a document merely running slowly, not crashed, can be re-run
|
|
42
|
+
here while a worker is still indexing it. Writes converge (idempotent),
|
|
43
|
+
but the embedding provider is called twice, which costs money and can
|
|
44
|
+
trigger the same rate limit that slowed the document down. For that
|
|
45
|
+
reason, stop the worker(s) before running this command.
|
|
46
|
+
"""
|
|
47
|
+
cfg = get_config()
|
|
48
|
+
sf = get_session_factory()
|
|
49
|
+
provider = build_embedding_provider(cfg.embeddings)
|
|
50
|
+
vector_store = build_vector_store(cfg.vector_store, sf, embedding_model=provider.model)
|
|
51
|
+
lexical_store = build_lexical_store(cfg.lexical_store, sf)
|
|
52
|
+
with session() as db:
|
|
53
|
+
count = reconcile_stuck_indexing(
|
|
54
|
+
db,
|
|
55
|
+
embedding_provider=provider,
|
|
56
|
+
vector_store=vector_store,
|
|
57
|
+
lexical_store=lexical_store,
|
|
58
|
+
older_than_seconds=older_than_seconds,
|
|
59
|
+
)
|
|
60
|
+
typer.echo(f"reconciled {count} document(s) stuck in indexing")
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""ragfabric reindex: re-embed the corpus under the active embedding model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from ragfabric_cli.commands.common import session
|
|
8
|
+
from ragfabric_core.ingest.reindex import reindex_all
|
|
9
|
+
from ragfabric_core.providers.registry import build_embedding_provider
|
|
10
|
+
from ragfabric_core.runtime import get_config, get_session_factory
|
|
11
|
+
from ragfabric_core.stores.registry import build_lexical_store, build_vector_store
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def reindex(
|
|
15
|
+
batch_size: int = typer.Option(64, "--batch-size", min=1, help="Chunks per embedding call."),
|
|
16
|
+
document_id: int | None = typer.Option(None, "--document-id", help="Limit to one document."),
|
|
17
|
+
lexical_only: bool = typer.Option(
|
|
18
|
+
False,
|
|
19
|
+
"--lexical-only",
|
|
20
|
+
help="Rebuild only the lexical index. No embedding calls, no vector writes.",
|
|
21
|
+
),
|
|
22
|
+
yes: bool = typer.Option(False, "--yes", help="Do not ask for confirmation."),
|
|
23
|
+
) -> None:
|
|
24
|
+
"""Re-embed every chunk with the configured embedding model.
|
|
25
|
+
|
|
26
|
+
With --lexical-only, rebuild the lexical index instead. That is what a
|
|
27
|
+
corpus indexed before BM25 needs: those chunk_search rows carry doc_len = 0
|
|
28
|
+
and a tsv with no term frequency, so BM25 excludes them, and re-embedding
|
|
29
|
+
the whole corpus to fix a lexical column would cost provider money for
|
|
30
|
+
nothing.
|
|
31
|
+
"""
|
|
32
|
+
cfg = get_config()
|
|
33
|
+
sf = get_session_factory()
|
|
34
|
+
provider = None if lexical_only else build_embedding_provider(cfg.embeddings)
|
|
35
|
+
if lexical_only:
|
|
36
|
+
typer.echo("This rebuilds the lexical index only. No vectors are touched.")
|
|
37
|
+
else:
|
|
38
|
+
typer.echo(
|
|
39
|
+
f"This replaces every stored vector using {provider.model} ({provider.dim} dims)."
|
|
40
|
+
)
|
|
41
|
+
if not yes and not typer.confirm("Continue?"):
|
|
42
|
+
typer.echo("aborted")
|
|
43
|
+
raise typer.Exit(1)
|
|
44
|
+
|
|
45
|
+
vector_store = (
|
|
46
|
+
None
|
|
47
|
+
if lexical_only
|
|
48
|
+
else build_vector_store(cfg.vector_store, sf, embedding_model=provider.model)
|
|
49
|
+
)
|
|
50
|
+
lexical_store = build_lexical_store(cfg.lexical_store, sf)
|
|
51
|
+
|
|
52
|
+
def progress(done: int, total: int) -> None:
|
|
53
|
+
typer.echo(f" {done}/{total}")
|
|
54
|
+
|
|
55
|
+
with session() as db:
|
|
56
|
+
count = reindex_all(
|
|
57
|
+
db,
|
|
58
|
+
embedding_provider=provider,
|
|
59
|
+
vector_store=vector_store,
|
|
60
|
+
lexical_store=lexical_store,
|
|
61
|
+
lexical_only=lexical_only,
|
|
62
|
+
batch_size=batch_size,
|
|
63
|
+
document_id=document_id,
|
|
64
|
+
on_progress=progress,
|
|
65
|
+
)
|
|
66
|
+
if lexical_only:
|
|
67
|
+
typer.echo(f"rebuilt the lexical index for {count} chunks")
|
|
68
|
+
else:
|
|
69
|
+
typer.echo(f"re-embedded {count} chunks with {provider.model}")
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from ragfabric_cli.commands.common import session, user_by_email
|
|
6
|
+
from ragfabric_core.models.user import Role, User
|
|
7
|
+
from ragfabric_core.security import hash_password
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(help="Users.")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@app.command("create")
|
|
13
|
+
def create(
|
|
14
|
+
email: str = typer.Option(...),
|
|
15
|
+
password: str = typer.Option(..., prompt=False),
|
|
16
|
+
role: str = typer.Option("user"),
|
|
17
|
+
) -> None:
|
|
18
|
+
if role not in {Role.ADMIN.value, Role.USER.value}:
|
|
19
|
+
typer.echo("role must be user or admin")
|
|
20
|
+
raise typer.Exit(code=1)
|
|
21
|
+
with session() as db:
|
|
22
|
+
if db.query(User).filter(User.email == email.lower()).first() is not None:
|
|
23
|
+
typer.echo(f"user already exists: {email}")
|
|
24
|
+
raise typer.Exit(code=1)
|
|
25
|
+
db.add(User(email=email.lower(), hashed_password=hash_password(password), role=role))
|
|
26
|
+
db.commit()
|
|
27
|
+
typer.echo(f"created {email} ({role})")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.command("list")
|
|
31
|
+
def list_users() -> None:
|
|
32
|
+
with session() as db:
|
|
33
|
+
for u in db.query(User).order_by(User.id).all():
|
|
34
|
+
typer.echo(f"{u.id}\t{u.email}\t{u.role}\t{'active' if u.is_active else 'inactive'}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app.command("set-role")
|
|
38
|
+
def set_role(email: str, role: str) -> None:
|
|
39
|
+
if role not in {Role.ADMIN.value, Role.USER.value}:
|
|
40
|
+
typer.echo("role must be user or admin")
|
|
41
|
+
raise typer.Exit(code=1)
|
|
42
|
+
with session() as db:
|
|
43
|
+
user = user_by_email(db, email)
|
|
44
|
+
user.role = role
|
|
45
|
+
db.commit()
|
|
46
|
+
typer.echo(f"{email} is now {role}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.command("deactivate")
|
|
50
|
+
def deactivate(email: str) -> None:
|
|
51
|
+
with session() as db:
|
|
52
|
+
user = user_by_email(db, email)
|
|
53
|
+
user.is_active = False
|
|
54
|
+
db.commit()
|
|
55
|
+
typer.echo(f"{email} deactivated")
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import threading
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from ragfabric_core.providers.registry import build_embedding_provider, build_llm_provider
|
|
9
|
+
from ragfabric_core.queue.registry import build_queue
|
|
10
|
+
from ragfabric_core.runtime import get_config, get_session_factory
|
|
11
|
+
from ragfabric_core.stores.registry import build_lexical_store, build_vector_store
|
|
12
|
+
from ragfabric_core.workers.runner import Worker, default_handlers, install_sigterm_handler
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _stores():
|
|
16
|
+
cfg = get_config()
|
|
17
|
+
sf = get_session_factory()
|
|
18
|
+
provider = build_embedding_provider(cfg.embeddings)
|
|
19
|
+
return (
|
|
20
|
+
build_vector_store(cfg.vector_store, sf, embedding_model=provider.model),
|
|
21
|
+
build_lexical_store(cfg.lexical_store, sf),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def worker(
|
|
26
|
+
once: bool = typer.Option(False, "--once", help="Process at most one job and exit."),
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Run the indexing worker against the configured queue."""
|
|
29
|
+
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
|
30
|
+
cfg = get_config()
|
|
31
|
+
queue = build_queue(cfg)
|
|
32
|
+
if queue is None:
|
|
33
|
+
typer.echo(
|
|
34
|
+
"ingestion.indexing is inline; set `indexing: queue` in ragfabric.yaml to run a worker"
|
|
35
|
+
)
|
|
36
|
+
raise typer.Exit(code=1)
|
|
37
|
+
sf = get_session_factory()
|
|
38
|
+
vector_store, lexical_store = _stores()
|
|
39
|
+
handlers = default_handlers(
|
|
40
|
+
embedding_provider=build_embedding_provider(cfg.embeddings),
|
|
41
|
+
vector_store=vector_store,
|
|
42
|
+
lexical_store=lexical_store,
|
|
43
|
+
graph_settings=cfg.graph_store,
|
|
44
|
+
llm=build_llm_provider(cfg.llm) if cfg.graph_store.enabled else None,
|
|
45
|
+
)
|
|
46
|
+
w = Worker(queue, sf, handlers)
|
|
47
|
+
if once:
|
|
48
|
+
typer.echo("processed 1 job" if w.run_once(timeout_seconds=1.0) else "queue empty")
|
|
49
|
+
return
|
|
50
|
+
stop = threading.Event()
|
|
51
|
+
install_sigterm_handler(stop)
|
|
52
|
+
typer.echo("worker started; waiting for jobs")
|
|
53
|
+
w.run_forever(stop)
|
|
54
|
+
typer.echo("worker stopped")
|
ragfabric_cli/main.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""The ragfabric command.
|
|
2
|
+
|
|
3
|
+
Thin by design: every command calls a function in ragfabric_core or starts
|
|
4
|
+
ragfabric_server. No logic lives here that the API or the tests cannot reach
|
|
5
|
+
another way.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from pydantic import ValidationError
|
|
15
|
+
|
|
16
|
+
from ragfabric_cli.commands import access as access_commands
|
|
17
|
+
from ragfabric_cli.commands import graph as graph_commands
|
|
18
|
+
from ragfabric_cli.commands import ingest as ingest_commands
|
|
19
|
+
from ragfabric_cli.commands import users as users_commands
|
|
20
|
+
from ragfabric_cli.commands.ask import ask as ask_command
|
|
21
|
+
from ragfabric_cli.commands.reconcile import reconcile as reconcile_command
|
|
22
|
+
from ragfabric_cli.commands.reindex import reindex as reindex_command
|
|
23
|
+
from ragfabric_cli.commands.worker import worker as worker_command
|
|
24
|
+
from ragfabric_core import __version__
|
|
25
|
+
from ragfabric_core.config_file import load_config, resolve_config_path
|
|
26
|
+
from ragfabric_core.db import migrate
|
|
27
|
+
from ragfabric_core.providers.base import ProviderError
|
|
28
|
+
from ragfabric_core.providers.registry import build_embedding_provider, build_llm_provider
|
|
29
|
+
|
|
30
|
+
app = typer.Typer(
|
|
31
|
+
help="RagFabric: self hosted, measurement first RAG platform.", no_args_is_help=True
|
|
32
|
+
)
|
|
33
|
+
db_app = typer.Typer(help="Database migrations.")
|
|
34
|
+
config_app = typer.Typer(help="Configuration.")
|
|
35
|
+
app.add_typer(db_app, name="db")
|
|
36
|
+
app.add_typer(config_app, name="config")
|
|
37
|
+
app.add_typer(users_commands.app, name="users")
|
|
38
|
+
app.add_typer(access_commands.groups_app, name="groups")
|
|
39
|
+
app.add_typer(access_commands.grants_app, name="grants")
|
|
40
|
+
app.add_typer(access_commands.keys_app, name="keys")
|
|
41
|
+
app.add_typer(graph_commands.graph_app, name="graph")
|
|
42
|
+
app.command("ingest")(ingest_commands.ingest)
|
|
43
|
+
app.command("worker")(worker_command)
|
|
44
|
+
app.command("reindex")(reindex_command)
|
|
45
|
+
app.command("reconcile")(reconcile_command)
|
|
46
|
+
app.command("ask")(ask_command)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _database_url() -> str:
|
|
50
|
+
from ragfabric_core.config import get_settings
|
|
51
|
+
|
|
52
|
+
return os.environ.get("DATABASE_URL") or get_settings().database_url
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.command()
|
|
56
|
+
def version() -> None:
|
|
57
|
+
"""Print the installed version."""
|
|
58
|
+
typer.echo(f"ragfabric {__version__}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@db_app.command()
|
|
62
|
+
def upgrade(revision: str = typer.Option("head", help="Target revision.")) -> None:
|
|
63
|
+
"""Apply migrations up to REVISION (default head)."""
|
|
64
|
+
migrate.upgrade(_database_url(), revision)
|
|
65
|
+
typer.echo(f"database upgraded to {revision}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@db_app.command()
|
|
69
|
+
def downgrade(revision: str = typer.Option("base", help="Target revision.")) -> None:
|
|
70
|
+
"""Roll migrations back to REVISION (default base, which removes every table)."""
|
|
71
|
+
migrate.downgrade(_database_url(), revision)
|
|
72
|
+
typer.echo(f"database downgraded to {revision}")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@config_app.command("validate")
|
|
76
|
+
def config_validate(
|
|
77
|
+
path: Path | None = typer.Option(None, "--path", help="ragfabric.yaml to validate."),
|
|
78
|
+
check_providers: bool = typer.Option(
|
|
79
|
+
False, "--check-providers", help="Also make one live call per provider."
|
|
80
|
+
),
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Load the configuration and report the active implementation for each interface."""
|
|
83
|
+
resolved = resolve_config_path(path)
|
|
84
|
+
typer.echo(f"config file: {resolved if resolved else 'none (defaults)'}")
|
|
85
|
+
try:
|
|
86
|
+
cfg = load_config(path)
|
|
87
|
+
except ValidationError as exc:
|
|
88
|
+
typer.echo("configuration is invalid:")
|
|
89
|
+
for err in exc.errors():
|
|
90
|
+
typer.echo(f" {'.'.join(str(p) for p in err['loc'])}: {err['msg']}")
|
|
91
|
+
raise typer.Exit(code=1) from None
|
|
92
|
+
|
|
93
|
+
problems: list[str] = []
|
|
94
|
+
try:
|
|
95
|
+
llm = build_llm_provider(cfg.llm)
|
|
96
|
+
typer.echo(f"llm: {cfg.llm.provider} ({llm.default_model})")
|
|
97
|
+
except ProviderError as exc:
|
|
98
|
+
problems.append(str(exc))
|
|
99
|
+
try:
|
|
100
|
+
emb = build_embedding_provider(cfg.embeddings)
|
|
101
|
+
typer.echo(f"embeddings: {cfg.embeddings.provider} ({emb.model})")
|
|
102
|
+
except ProviderError as exc:
|
|
103
|
+
problems.append(str(exc))
|
|
104
|
+
typer.echo(f"reranker: {cfg.reranker.kind}")
|
|
105
|
+
typer.echo(f"vector_store: {cfg.vector_store.kind}")
|
|
106
|
+
typer.echo(f"lexical_store: {cfg.lexical_store.kind}")
|
|
107
|
+
vectorless = cfg.strategies.vectorless
|
|
108
|
+
typer.echo(
|
|
109
|
+
f"vectorless: top_k {vectorless.top_k}, k1 {vectorless.k1}, b {vectorless.b}, "
|
|
110
|
+
f"fusion_k {vectorless.fusion_k}, "
|
|
111
|
+
f"boosts phrase {vectorless.phrase_boost} identifier {vectorless.identifier_boost}"
|
|
112
|
+
)
|
|
113
|
+
agentic = cfg.strategies.agentic
|
|
114
|
+
typer.echo(
|
|
115
|
+
f"agentic: max_iterations {agentic.max_iterations}, "
|
|
116
|
+
f"max_llm_calls {agentic.max_llm_calls}, "
|
|
117
|
+
f"max_cost_usd {agentic.max_cost_usd}, max_latency_ms {agentic.max_latency_ms}, "
|
|
118
|
+
f"assess {agentic.assess_strictness}, tools {' '.join(agentic.tools)}"
|
|
119
|
+
)
|
|
120
|
+
graph_store = cfg.graph_store
|
|
121
|
+
typer.echo(
|
|
122
|
+
f"graph_store: {graph_store.kind} ({'enabled' if graph_store.enabled else 'disabled'}), "
|
|
123
|
+
f"extraction_model {graph_store.extraction_model or 'provider default'}, "
|
|
124
|
+
f"confidence_floor {graph_store.confidence_floor}, "
|
|
125
|
+
f"similarity_threshold {graph_store.similarity_threshold}, "
|
|
126
|
+
f"entity_types {' '.join(graph_store.entity_types)}, "
|
|
127
|
+
f"relation_types {' '.join(graph_store.relation_types)}"
|
|
128
|
+
)
|
|
129
|
+
graph = cfg.strategies.graph
|
|
130
|
+
typer.echo(f"graph: max_hops {graph.max_hops}, node_budget {graph.node_budget}")
|
|
131
|
+
typer.echo(f"cache: {cfg.cache.kind}")
|
|
132
|
+
typer.echo(f"router: {cfg.router.mode} (min_confidence {cfg.router.min_confidence})")
|
|
133
|
+
|
|
134
|
+
if check_providers and not problems:
|
|
135
|
+
from ragfabric_core.providers.base import Message
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
out = llm.complete(
|
|
139
|
+
[Message(role="user", content="Reply with the single word: pong")], max_tokens=8
|
|
140
|
+
)
|
|
141
|
+
typer.echo(
|
|
142
|
+
f"llm live check: ok ({out.input_tokens} in, {out.output_tokens} out, {out.latency_ms} ms)"
|
|
143
|
+
)
|
|
144
|
+
vec = emb.embed(["ping"])
|
|
145
|
+
typer.echo(f"embeddings live check: ok (dim {len(vec.vectors[0])})")
|
|
146
|
+
except ProviderError as exc:
|
|
147
|
+
problems.append(str(exc))
|
|
148
|
+
|
|
149
|
+
if problems:
|
|
150
|
+
typer.echo("problems:")
|
|
151
|
+
for p in problems:
|
|
152
|
+
typer.echo(f" {p}")
|
|
153
|
+
raise typer.Exit(code=1)
|
|
154
|
+
typer.echo("configuration ok")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@app.command()
|
|
158
|
+
def init(
|
|
159
|
+
force: bool = typer.Option(
|
|
160
|
+
False, "--force", help="Overwrite existing .env and ragfabric.yaml."
|
|
161
|
+
),
|
|
162
|
+
) -> None:
|
|
163
|
+
"""Create .env and ragfabric.yaml from the examples, then validate."""
|
|
164
|
+
import shutil
|
|
165
|
+
from pathlib import Path
|
|
166
|
+
|
|
167
|
+
for example, target in ((".env.example", ".env"), ("ragfabric.example.yaml", "ragfabric.yaml")):
|
|
168
|
+
src, dst = Path(example), Path(target)
|
|
169
|
+
if not src.exists():
|
|
170
|
+
typer.echo(f"{example} not found in the current directory")
|
|
171
|
+
raise typer.Exit(code=1)
|
|
172
|
+
if dst.exists() and not force:
|
|
173
|
+
typer.echo(f"{target} already exists (use --force to overwrite)")
|
|
174
|
+
continue
|
|
175
|
+
shutil.copyfile(src, dst)
|
|
176
|
+
typer.echo(f"wrote {target}")
|
|
177
|
+
from ragfabric_core.runtime import reset_config
|
|
178
|
+
|
|
179
|
+
reset_config()
|
|
180
|
+
config_validate(path=None, check_providers=False)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@app.command()
|
|
184
|
+
def serve(
|
|
185
|
+
host: str = typer.Option(
|
|
186
|
+
"127.0.0.1",
|
|
187
|
+
help=(
|
|
188
|
+
"Bind address. Defaults to localhost only; pass --host 0.0.0.0 "
|
|
189
|
+
"explicitly to expose this on every network interface."
|
|
190
|
+
),
|
|
191
|
+
),
|
|
192
|
+
port: int = typer.Option(8000, help="Port."),
|
|
193
|
+
reload: bool = typer.Option(False, help="Auto reload (development only)."),
|
|
194
|
+
) -> None:
|
|
195
|
+
"""Run the HTTP API with uvicorn.
|
|
196
|
+
|
|
197
|
+
Behaviour change: this used to default to 0.0.0.0, which exposed a
|
|
198
|
+
development server on every network interface unless a caller happened
|
|
199
|
+
to override it. It now defaults to 127.0.0.1 (localhost only); naming
|
|
200
|
+
--host 0.0.0.0 explicitly still works exactly as before for a deployment
|
|
201
|
+
that means to bind every interface (e.g. inside a container behind its
|
|
202
|
+
own network boundary).
|
|
203
|
+
"""
|
|
204
|
+
import uvicorn
|
|
205
|
+
|
|
206
|
+
uvicorn.run("ragfabric_server.main:app", host=host, port=port, reload=reload)
|