sharepoint-knowledge 0.1.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.
- sharepoint_knowledge/__init__.py +3 -0
- sharepoint_knowledge/auth_sp.py +118 -0
- sharepoint_knowledge/cli_sp.py +191 -0
- sharepoint_knowledge/client_sp.py +69 -0
- sharepoint_knowledge/crawl_sp.py +277 -0
- sharepoint_knowledge/server_sp.py +334 -0
- sharepoint_knowledge/settings_sp.py +80 -0
- sharepoint_knowledge-0.1.0.dist-info/METADATA +40 -0
- sharepoint_knowledge-0.1.0.dist-info/RECORD +12 -0
- sharepoint_knowledge-0.1.0.dist-info/WHEEL +5 -0
- sharepoint_knowledge-0.1.0.dist-info/entry_points.txt +2 -0
- sharepoint_knowledge-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Entra auth for sharepoint-knowledge — device-code flow + silent refresh.
|
|
2
|
+
|
|
3
|
+
The MSAL token cache is stored as a credential blob through the shared
|
|
4
|
+
keyring/file machinery (same posture as the Confluence PAT). The access
|
|
5
|
+
token itself never enters chat, logs, or command-line arguments.
|
|
6
|
+
|
|
7
|
+
SP_KNOWLEDGE_TOKEN env var short-circuits everything (tests/CI).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from confluence_knowledge.auth import get_credential, store_credential
|
|
17
|
+
from confluence_knowledge.settings import SetupError
|
|
18
|
+
|
|
19
|
+
SCOPES = ["https://graph.microsoft.com/Sites.Read.All", "offline_access"]
|
|
20
|
+
ENV_TOKEN = "SP_KNOWLEDGE_TOKEN"
|
|
21
|
+
AUTH_FILE = ".sp_auth.json"
|
|
22
|
+
INDEX_DIR = ".sp_knowledge_index"
|
|
23
|
+
SERVICE = "sharepoint-knowledge"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _cache_ref(profile) -> str:
|
|
27
|
+
return f"cache:{profile.credential_ref}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _load_msal():
|
|
31
|
+
try:
|
|
32
|
+
import msal
|
|
33
|
+
except ImportError as e:
|
|
34
|
+
raise SetupError(
|
|
35
|
+
"msal isn't installed — reinstall with `pip install "
|
|
36
|
+
"sharepoint-knowledge[models]`") from e
|
|
37
|
+
return msal
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _load_cache(workdir: Path, profile):
|
|
41
|
+
msal = _load_msal()
|
|
42
|
+
blob = get_credential(workdir, _cache_ref(profile), env=ENV_TOKEN,
|
|
43
|
+
service=SERVICE, auth_file=AUTH_FILE,
|
|
44
|
+
home_dir=".sharepoint-knowledge")
|
|
45
|
+
if blob:
|
|
46
|
+
try:
|
|
47
|
+
cache = msal.SerializableTokenCache()
|
|
48
|
+
cache.deserialize(json.loads(blob))
|
|
49
|
+
return cache
|
|
50
|
+
except Exception: # noqa: BLE001 — corrupt cache → re-sign-in
|
|
51
|
+
return None
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _save_cache(workdir: Path, home_dir: Path, profile, cache) -> str:
|
|
56
|
+
return store_credential(workdir, home_dir, _cache_ref(profile),
|
|
57
|
+
json.dumps(cache.serialize()),
|
|
58
|
+
service=SERVICE, auth_file=AUTH_FILE,
|
|
59
|
+
index_dir=INDEX_DIR)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _app(profile, cache=None):
|
|
63
|
+
msal = _load_msal()
|
|
64
|
+
kwargs = {"token_cache": cache} if cache else {}
|
|
65
|
+
return msal.PublicClientApplication(
|
|
66
|
+
profile.client_id,
|
|
67
|
+
authority=f"{_get_auth_base()}/{profile.tenant_id}",
|
|
68
|
+
**kwargs)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _get_auth_base() -> str:
|
|
72
|
+
"""Authority base override for e2e (fake token endpoint)."""
|
|
73
|
+
return os.environ.get("SP_KNOWLEDGE_AUTH_BASE",
|
|
74
|
+
"https://login.microsoftonline.com")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def acquire_token(profile, workdir: Path, home_dir: Path) -> str:
|
|
78
|
+
"""Silent first (cached refresh token); device flow only on expiry —
|
|
79
|
+
so normal use needs no re-sign-in after setup."""
|
|
80
|
+
if os.environ.get(ENV_TOKEN):
|
|
81
|
+
return os.environ[ENV_TOKEN]
|
|
82
|
+
cache = _load_cache(workdir, profile)
|
|
83
|
+
app = _app(profile, cache)
|
|
84
|
+
accounts = app.get_accounts()
|
|
85
|
+
if accounts:
|
|
86
|
+
r = app.acquire_token_silent(SCOPES, account=accounts[0])
|
|
87
|
+
if r and "access_token" in r:
|
|
88
|
+
if cache and cache.has_state_changed:
|
|
89
|
+
_save_cache(workdir, home_dir, profile, cache)
|
|
90
|
+
return r["access_token"]
|
|
91
|
+
raise SetupError(
|
|
92
|
+
"SharePoint sign-in expired — run `sharepoint-knowledge setup` to "
|
|
93
|
+
"re-authenticate (device code, one browser step)")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def device_sign_in(profile, workdir: Path, home_dir: Path) -> dict:
|
|
97
|
+
"""Interactive device-code sign-in — used by `setup`. Prints the
|
|
98
|
+
message Microsoft returns (URL + code); blocks until approved or it
|
|
99
|
+
times out. Returns the stored-cache location description."""
|
|
100
|
+
msal = _load_msal()
|
|
101
|
+
cache = msal.SerializableTokenCache()
|
|
102
|
+
app = _app(profile, cache)
|
|
103
|
+
flow = app.initiate_device_flow(scopes=SCOPES)
|
|
104
|
+
if "user_code" not in flow:
|
|
105
|
+
raise SetupError(
|
|
106
|
+
"couldn't start device sign-in: "
|
|
107
|
+
f"{flow.get('error_description', flow)}")
|
|
108
|
+
print("\n" + flow["message"] + "\n") # "go to microsoft.com/devicelogin…"
|
|
109
|
+
r = app.acquire_token_by_device_flow(flow)
|
|
110
|
+
if "access_token" not in r:
|
|
111
|
+
raise SetupError(
|
|
112
|
+
"sign-in failed: "
|
|
113
|
+
f"{r.get('error_description', r.get('error', r))}")
|
|
114
|
+
where = _save_cache(workdir, home_dir, profile, cache)
|
|
115
|
+
account = r.get("id_token_claims", {})
|
|
116
|
+
return {"where": where,
|
|
117
|
+
"user": (account.get("preferred_username")
|
|
118
|
+
or account.get("email") or "signed in")}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""sharepoint-knowledge CLI — setup (Entra device sign-in), doctor,
|
|
2
|
+
prepare-models, register, update, serve, clean.
|
|
3
|
+
|
|
4
|
+
Same shape as the other knowledge CLIs. `setup` prints the exact Entra
|
|
5
|
+
app-registration steps for IT, collects tenant/client/site, then runs the
|
|
6
|
+
one-time device-code sign-in.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from confluence_knowledge.registration import register as _register
|
|
18
|
+
from confluence_knowledge.retrieval import prepare_models
|
|
19
|
+
from confluence_knowledge.settings import SetupError, ensure_gitignore
|
|
20
|
+
|
|
21
|
+
from .settings_sp import (
|
|
22
|
+
APP_NAME,
|
|
23
|
+
AUTH_ENV,
|
|
24
|
+
AUTH_FILE_NAME,
|
|
25
|
+
INDEX_DIR_NAME,
|
|
26
|
+
PROFILE_NAME,
|
|
27
|
+
SPProfile,
|
|
28
|
+
home,
|
|
29
|
+
workdir,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
TOKEN_STEPS = """\
|
|
33
|
+
Entra app registration — once per tenant, done by IT (or you, if allowed):
|
|
34
|
+
1. Entra portal (entra.microsoft.com) → Applications → App registrations
|
|
35
|
+
→ New registration
|
|
36
|
+
2. Name: e.g. "sharepoint-knowledge-agent" — Accounts in this org only
|
|
37
|
+
3. Authentication → Enable "Allow public client flows" → YES (required
|
|
38
|
+
for device-code sign-in — no client secret is used or needed)
|
|
39
|
+
4. API permissions → Add → Microsoft Graph → Delegated → Sites.Read.All
|
|
40
|
+
(+ offline_access is implied by device flow)
|
|
41
|
+
5. Grant admin consent for the tenant (the delegated scope normally
|
|
42
|
+
requires it) — one click on the Permissions page
|
|
43
|
+
6. Copy: Directory (tenant) ID and Application (client) ID — needed below
|
|
44
|
+
|
|
45
|
+
This app requests only delegated read access — it can read exactly what
|
|
46
|
+
the signed-in user can already read, nothing more.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _setup() -> int:
|
|
51
|
+
print("SharePoint Knowledge — first-run setup\n")
|
|
52
|
+
print(TOKEN_STEPS)
|
|
53
|
+
|
|
54
|
+
tenant = input("Tenant ID (GUID): ").strip()
|
|
55
|
+
client = input("Client ID (application ID, GUID): ").strip()
|
|
56
|
+
site = input("SharePoint site URL (e.g. https://contoso.sharepoint.com"
|
|
57
|
+
"/sites/team): ").strip().rstrip("/")
|
|
58
|
+
if not (tenant and client and site):
|
|
59
|
+
print("tenant ID, client ID, and site URL are all required",
|
|
60
|
+
file=sys.stderr)
|
|
61
|
+
return 1
|
|
62
|
+
|
|
63
|
+
profile = SPProfile(tenant_id=tenant, client_id=client,
|
|
64
|
+
site_url=site, credential_ref="sp-token")
|
|
65
|
+
profile.save()
|
|
66
|
+
print(f"\nprofile saved to {home() / PROFILE_NAME}\n")
|
|
67
|
+
|
|
68
|
+
print("Sign-in (device code — one browser step). The code you get "
|
|
69
|
+
"never leaves the OS credential store.", flush=True)
|
|
70
|
+
if os.environ.get(AUTH_ENV):
|
|
71
|
+
print("SP_KNOWLEDGE_TOKEN is set — using it; skipping sign-in")
|
|
72
|
+
else:
|
|
73
|
+
from .auth_sp import device_sign_in
|
|
74
|
+
try:
|
|
75
|
+
r = device_sign_in(profile, workdir(), home())
|
|
76
|
+
print(f"signed in as {r['user']} — token cache stored at "
|
|
77
|
+
f"{r['where']}")
|
|
78
|
+
except SetupError as e:
|
|
79
|
+
print(f"sign-in failed: {e}", file=sys.stderr)
|
|
80
|
+
return 1
|
|
81
|
+
|
|
82
|
+
# make sure the workdir's gitignore hides the index + auth file
|
|
83
|
+
workdir().mkdir(parents=True, exist_ok=True)
|
|
84
|
+
ensure_gitignore(workdir(), [AUTH_FILE_NAME,
|
|
85
|
+
f"{INDEX_DIR_NAME}/"])
|
|
86
|
+
|
|
87
|
+
if input("\nDownload the two local models now (~175 MB, one-time)? "
|
|
88
|
+
"[Y/n] ").strip().lower() not in ("n", "no"):
|
|
89
|
+
for line in prepare_models(home()):
|
|
90
|
+
print(line)
|
|
91
|
+
|
|
92
|
+
print("\nsetup complete — run `sharepoint-knowledge doctor` to verify")
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _doctor() -> int:
|
|
97
|
+
from confluence_knowledge.update import check
|
|
98
|
+
|
|
99
|
+
from .server_sp import status
|
|
100
|
+
s = status()
|
|
101
|
+
print(json.dumps(s, indent=2))
|
|
102
|
+
upd = check("sharepoint-knowledge")
|
|
103
|
+
if upd["installed"]:
|
|
104
|
+
line = f"version: {upd['installed']}"
|
|
105
|
+
if upd["latest"]:
|
|
106
|
+
line += f" (latest: {upd['latest']})"
|
|
107
|
+
elif upd["latest"] is None:
|
|
108
|
+
line += " (latest: unchecked — offline or internal mirror)"
|
|
109
|
+
print(line)
|
|
110
|
+
if upd["update_available"]:
|
|
111
|
+
print("update available: run `sharepoint-knowledge update` "
|
|
112
|
+
"(or pip install -U sharepoint-knowledge[models])")
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def main() -> None:
|
|
117
|
+
p = argparse.ArgumentParser(prog="sharepoint-knowledge",
|
|
118
|
+
description="Local SharePoint indexer + "
|
|
119
|
+
"grounded-Q&A MCP service")
|
|
120
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
121
|
+
sub.add_parser("setup", help="configure Entra sign-in + site")
|
|
122
|
+
sub.add_parser("doctor", help="report configuration + model readiness")
|
|
123
|
+
sub.add_parser("prepare-models", help="download the pinned local models")
|
|
124
|
+
sub.add_parser("update", help="upgrade this package via pip -U")
|
|
125
|
+
reg = sub.add_parser("register",
|
|
126
|
+
help="write the IDE MCP config automatically")
|
|
127
|
+
reg.add_argument("--ide", choices=["auto", "all", "jetbrains", "vscode"],
|
|
128
|
+
default="auto")
|
|
129
|
+
reg.add_argument("--workspace", default=None,
|
|
130
|
+
help="repo dir for the workspace .vscode/mcp.json")
|
|
131
|
+
reg.add_argument("--remove", action="store_true",
|
|
132
|
+
help="remove our server entry instead of adding it")
|
|
133
|
+
sub.add_parser("serve", help="run the stdio MCP server")
|
|
134
|
+
sub.add_parser("clean", help="delete all indexed data")
|
|
135
|
+
|
|
136
|
+
args = p.parse_args()
|
|
137
|
+
if args.cmd == "setup":
|
|
138
|
+
sys.exit(_setup())
|
|
139
|
+
if args.cmd == "doctor":
|
|
140
|
+
sys.exit(_doctor())
|
|
141
|
+
if args.cmd == "prepare-models":
|
|
142
|
+
for line in prepare_models(home()):
|
|
143
|
+
print(line)
|
|
144
|
+
return
|
|
145
|
+
if args.cmd == "update":
|
|
146
|
+
import subprocess
|
|
147
|
+
cmd = [sys.executable, "-m", "pip", "install", "-U",
|
|
148
|
+
"sharepoint-knowledge[models]"]
|
|
149
|
+
if os.name == "nt":
|
|
150
|
+
print("on Windows, upgrade via the venv python directly:\n "
|
|
151
|
+
+ " ".join(f'"{c}"' if " " in c else c for c in cmd))
|
|
152
|
+
return
|
|
153
|
+
r = subprocess.run(cmd, check=False)
|
|
154
|
+
if r.returncode == 0:
|
|
155
|
+
print("\nupdated — models are pinned and unchanged; "
|
|
156
|
+
"run `sharepoint-knowledge doctor` to confirm")
|
|
157
|
+
sys.exit(r.returncode)
|
|
158
|
+
if args.cmd == "register":
|
|
159
|
+
results = _register(
|
|
160
|
+
ide=args.ide,
|
|
161
|
+
workspace=Path(args.workspace) if args.workspace else None,
|
|
162
|
+
remove=args.remove,
|
|
163
|
+
server_id=APP_NAME,
|
|
164
|
+
module="sharepoint_knowledge.server_sp",
|
|
165
|
+
env_var="SP_KNOWLEDGE_WORKDIR")
|
|
166
|
+
for path, action in results:
|
|
167
|
+
print(f"{action}: {path}")
|
|
168
|
+
if args.remove:
|
|
169
|
+
print("removed — reload the IDE to drop the server")
|
|
170
|
+
else:
|
|
171
|
+
print("registered — reload the IDE so the tools appear")
|
|
172
|
+
return
|
|
173
|
+
if args.cmd == "serve":
|
|
174
|
+
from .server_sp import main as serve
|
|
175
|
+
serve()
|
|
176
|
+
return
|
|
177
|
+
if args.cmd == "clean":
|
|
178
|
+
import shutil
|
|
179
|
+
|
|
180
|
+
from .settings_sp import index_root
|
|
181
|
+
d = index_root()
|
|
182
|
+
if d.exists():
|
|
183
|
+
shutil.rmtree(d)
|
|
184
|
+
print(f"deleted {d}")
|
|
185
|
+
else:
|
|
186
|
+
print("nothing to clean")
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
if __name__ == "__main__":
|
|
191
|
+
main()
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""GET-only Microsoft Graph client for sharepoint-knowledge.
|
|
2
|
+
|
|
3
|
+
Reuses the allowlist + GET-only machinery of the Confluence client: every
|
|
4
|
+
request passes through the same `_validate_path` — the only Graph routes
|
|
5
|
+
allowed are the read ones the crawler needs. File downloads use the
|
|
6
|
+
pre-authenticated `@microsoft.graph.downloadUrl` fetched WITHOUT the Bearer
|
|
7
|
+
header — the token never leaves graph.microsoft.com.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from urllib.parse import urlparse
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
from confluence_knowledge.client import (
|
|
17
|
+
AccessError,
|
|
18
|
+
ConfluenceClient,
|
|
19
|
+
EndpointRule,
|
|
20
|
+
SecurityException,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
GRAPH_RULES = [
|
|
24
|
+
EndpointRule(re.compile(r"sites/[^/]+:.*"), "site by hostname:path"),
|
|
25
|
+
EndpointRule(re.compile(r"sites/[^/]+$"), "site info"),
|
|
26
|
+
EndpointRule(re.compile(r"sites/[^/]+/lists/?$"), "site lists"),
|
|
27
|
+
EndpointRule(re.compile(r"sites/[^/]+/lists/[^/]+/items/?$"), "list items"),
|
|
28
|
+
EndpointRule(re.compile(r"sites/[^/]+/lists/[^/]+/items/[^/]+/?$"), "list item"),
|
|
29
|
+
EndpointRule(re.compile(r"sites/[^/]+/pages/?$"), "site pages"),
|
|
30
|
+
EndpointRule(re.compile(r"sites/[^/]+/pages/[^/]+/?$"), "site page"),
|
|
31
|
+
EndpointRule(re.compile(r"sites/[^/]+/drives/?$"), "document libraries"),
|
|
32
|
+
EndpointRule(re.compile(r"sites/[^/]+/drive/?$"), "default drive"),
|
|
33
|
+
EndpointRule(re.compile(r"sites/[^/]+/drive/root:.+"), "item by path"),
|
|
34
|
+
EndpointRule(re.compile(r"drives/[^/]+/root/children/?$"), "library root"),
|
|
35
|
+
EndpointRule(re.compile(r"drives/[^/]+/items/[^/]+/children/?$"), "folder children"),
|
|
36
|
+
EndpointRule(re.compile(r"drives/[^/]+/items/[^/]+/?$"), "item metadata"),
|
|
37
|
+
EndpointRule(re.compile(r"me/?$"), "identity"),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def make_client(base_url: str, token: str) -> ConfluenceClient:
|
|
42
|
+
"""Graph client — Bearer stays on the API host only."""
|
|
43
|
+
return ConfluenceClient(base_url, {"Authorization": f"Bearer {token}"},
|
|
44
|
+
GRAPH_RULES)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def download_file(url: str, max_bytes: int = 25 * 1024 * 1024) -> bytes:
|
|
48
|
+
"""Fetch a pre-authenticated downloadUrl — NO Authorization header.
|
|
49
|
+
|
|
50
|
+
The URL embeds a short-lived download token; sending our Bearer would
|
|
51
|
+
leak it to a different host (and is unnecessary — these links are
|
|
52
|
+
self-authenticating). https only (localhost allowed for tests).
|
|
53
|
+
"""
|
|
54
|
+
u = urlparse(url)
|
|
55
|
+
if u.scheme != "https" and u.hostname not in ("localhost", "127.0.0.1"):
|
|
56
|
+
raise SecurityException(
|
|
57
|
+
"download URL isn't https — refusing to fetch")
|
|
58
|
+
with httpx.Client(timeout=60.0, follow_redirects=True) as http, \
|
|
59
|
+
http.stream("GET", url) as r:
|
|
60
|
+
# bounded stream — don't buffer an unbounded response
|
|
61
|
+
if r.status_code >= 400:
|
|
62
|
+
raise AccessError(f"download failed: HTTP {r.status_code}")
|
|
63
|
+
out = bytearray()
|
|
64
|
+
for chunk in r.iter_bytes(1 << 20):
|
|
65
|
+
out += chunk
|
|
66
|
+
if len(out) > max_bytes:
|
|
67
|
+
raise AccessError(
|
|
68
|
+
f"file exceeds {max_bytes} bytes — refusing to buffer")
|
|
69
|
+
return bytes(out)
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Durable BFS over a SharePoint site (pages + document libraries).
|
|
2
|
+
|
|
3
|
+
Queue entries: site:<id> → list:<list_id> + drive:<drive_id>;
|
|
4
|
+
list:<id> → page:<item_id>…; drive:<id> → item:<drive_id>:<item_id>…
|
|
5
|
+
File bytes are routed through the shared extract_files (docx/pdf/pptx/xlsx/
|
|
6
|
+
csv/txt/md/html). Site-page canvas HTML goes through extract_chunks.
|
|
7
|
+
Only items reachable through the chosen root are ever fetched.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import uuid
|
|
15
|
+
from urllib.parse import urlparse
|
|
16
|
+
|
|
17
|
+
from confluence_knowledge.client import AccessError, ConfluenceClient
|
|
18
|
+
from confluence_knowledge.extract import extract_chunks
|
|
19
|
+
from confluence_knowledge.extract_files import extract_file_chunks, is_secret_name
|
|
20
|
+
from confluence_knowledge.index import Index
|
|
21
|
+
from confluence_knowledge.settings import SetupError
|
|
22
|
+
|
|
23
|
+
MAX_FILE_BYTES = 25 * 1024 * 1024
|
|
24
|
+
MAX_FILES = 20_000
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def resolve_root(client: ConfluenceClient, url: str) -> tuple[str, str]:
|
|
28
|
+
"""SharePoint URL → (root queue entry, site webUrl for citations).
|
|
29
|
+
|
|
30
|
+
Accepts:
|
|
31
|
+
https://host.sharepoint.com/sites/name → whole site (pages+libraries)
|
|
32
|
+
…/sites/name/<library or folder path> → that subtree only
|
|
33
|
+
https://host.sharepoint.com → root site
|
|
34
|
+
"""
|
|
35
|
+
u = urlparse(url.strip())
|
|
36
|
+
if u.scheme != "https" and u.hostname not in ("localhost", "127.0.0.1"):
|
|
37
|
+
raise SetupError("SharePoint URL must be https")
|
|
38
|
+
host = u.hostname
|
|
39
|
+
path = u.path.rstrip("/") or "/"
|
|
40
|
+
|
|
41
|
+
# site path = /sites/{name} or /teams/{name} (or root "/")
|
|
42
|
+
site_path, item_path = "/", ""
|
|
43
|
+
parts = [p for p in path.split("/") if p]
|
|
44
|
+
if parts and parts[0] in ("sites", "teams"):
|
|
45
|
+
site_path = "/" + "/".join(parts[:2])
|
|
46
|
+
item_path = "/".join(parts[2:])
|
|
47
|
+
site_path = site_path.rstrip("/")
|
|
48
|
+
|
|
49
|
+
if site_path == "/":
|
|
50
|
+
site = client.get(f"sites/{host}").json()
|
|
51
|
+
else:
|
|
52
|
+
site = client.get(f"sites/{host}:{site_path}").json()
|
|
53
|
+
site_id = site["id"]
|
|
54
|
+
site_web = site.get("webUrl", url)
|
|
55
|
+
|
|
56
|
+
if item_path:
|
|
57
|
+
item = client.get(
|
|
58
|
+
f"sites/{site_id}/drive/root:/{item_path}").json()
|
|
59
|
+
drive = (item.get("parentReference") or {}).get("driveId") or \
|
|
60
|
+
client.get(f"sites/{site_id}/drive").json()["id"]
|
|
61
|
+
return f"item:{drive}:{item['id']}", site_web
|
|
62
|
+
return f"site:{site_id}", site_web
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _paged(client: ConfluenceClient, path: str, params: dict | None = None):
|
|
66
|
+
"""Follow @odata.nextLink — absolute links are validated inside the
|
|
67
|
+
client's origin+base by _validate_path, so pagination stays in scope."""
|
|
68
|
+
while True:
|
|
69
|
+
r = client.get(path, params)
|
|
70
|
+
d = r.json()
|
|
71
|
+
yield from d.get("value", [])
|
|
72
|
+
nxt = d.get("@odata.nextLink")
|
|
73
|
+
if not nxt:
|
|
74
|
+
return
|
|
75
|
+
path, params = nxt, None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class SPCrawler:
|
|
79
|
+
def __init__(self, client: ConfluenceClient, index: Index, embedder=None,
|
|
80
|
+
downloader=None):
|
|
81
|
+
self.client = client
|
|
82
|
+
self.index = index
|
|
83
|
+
self.embedder = embedder
|
|
84
|
+
self.downloader = downloader # injected for tests; real one in server
|
|
85
|
+
|
|
86
|
+
# ---- expansions ----
|
|
87
|
+
|
|
88
|
+
def _expand_site(self, site_id: str) -> tuple[list[str], list[str]]:
|
|
89
|
+
"""site → the Site Pages list + every document library."""
|
|
90
|
+
out, warns = [], []
|
|
91
|
+
for lst in _paged(self.client, f"sites/{site_id}/lists"):
|
|
92
|
+
name = lst.get("displayName", "")
|
|
93
|
+
base = (lst.get("list") or {}).get("template", "")
|
|
94
|
+
if name == "Site Pages" or base == "webPageLibrary":
|
|
95
|
+
out.append(f"list:{site_id}:{lst['id']}")
|
|
96
|
+
for drv in _paged(self.client, f"sites/{site_id}/drives"):
|
|
97
|
+
out.append(f"drive:{drv['id']}")
|
|
98
|
+
return out, warns
|
|
99
|
+
|
|
100
|
+
def _expand_drive(self, drive_id: str) -> list[str]:
|
|
101
|
+
return [f"item:{drive_id}:{it['id']}"
|
|
102
|
+
for it in _paged(self.client, f"drives/{drive_id}/root/children")]
|
|
103
|
+
|
|
104
|
+
def _expand_item(self, drive_id: str, item_id: str) -> list[str]:
|
|
105
|
+
return [f"item:{drive_id}:{it['id']}"
|
|
106
|
+
for it in _paged(self.client,
|
|
107
|
+
f"drives/{drive_id}/items/{item_id}/children")]
|
|
108
|
+
|
|
109
|
+
# ---- pages ----
|
|
110
|
+
|
|
111
|
+
def _page_from_list_item(self, site_id: str, list_id: str,
|
|
112
|
+
item_id: str) -> tuple[dict, list, list]:
|
|
113
|
+
d = self.client.get(
|
|
114
|
+
f"sites/{site_id}/lists/{list_id}/items/{item_id}",
|
|
115
|
+
{"$expand": "fields"}).json()
|
|
116
|
+
fields = d.get("fields", {})
|
|
117
|
+
title = fields.get("Title") or d.get("name") or item_id
|
|
118
|
+
canvas = fields.get("CanvasContent1") or fields.get("WikiField") or ""
|
|
119
|
+
warns: list[str] = []
|
|
120
|
+
chunks = []
|
|
121
|
+
if canvas:
|
|
122
|
+
html = canvas
|
|
123
|
+
try:
|
|
124
|
+
parts = json.loads(canvas)
|
|
125
|
+
if isinstance(parts, list):
|
|
126
|
+
html = "\n".join(p.get("innerHtml", "")
|
|
127
|
+
for p in parts if isinstance(p, dict))
|
|
128
|
+
except (json.JSONDecodeError, TypeError):
|
|
129
|
+
pass # raw HTML already
|
|
130
|
+
chunks, warns = extract_chunks(html)
|
|
131
|
+
else:
|
|
132
|
+
warns.append("no canvas content — page may be empty or classic")
|
|
133
|
+
page = {"page_id": f"page:{item_id}", "title": title,
|
|
134
|
+
"url": (d.get("webUrl")
|
|
135
|
+
or (fields.get("_dlc_DocIdUrl") or {}).get("Url", "")),
|
|
136
|
+
"version": str(d.get("lastModifiedDateTime") or ""),
|
|
137
|
+
"content_hash": hashlib.sha256(canvas.encode()).hexdigest(),
|
|
138
|
+
"warnings": warns}
|
|
139
|
+
return page, chunks, warns
|
|
140
|
+
|
|
141
|
+
def _page_from_item(self, drive_id: str, item_id: str,
|
|
142
|
+
downloader) -> tuple[dict, list, list]:
|
|
143
|
+
d = self.client.get(
|
|
144
|
+
f"drives/{drive_id}/items/{item_id}",
|
|
145
|
+
{"$select": "id,name,size,webUrl,lastModifiedDateTime,"
|
|
146
|
+
"@microsoft.graph.downloadUrl"}).json()
|
|
147
|
+
name = d.get("name", item_id)
|
|
148
|
+
warns: list[str] = []
|
|
149
|
+
if is_secret_name(name):
|
|
150
|
+
# disclose-and-skip — same posture as local files: the file is
|
|
151
|
+
# never a page in the index, only a warning on the job
|
|
152
|
+
return None, [], [f"{name}: looks like a secret — skipped"]
|
|
153
|
+
dl_url = d.get("@microsoft.graph.downloadUrl")
|
|
154
|
+
if not dl_url or (d.get("size") or 0) > MAX_FILE_BYTES:
|
|
155
|
+
why = ("no download URL" if not dl_url
|
|
156
|
+
else f"too large ({d['size']} bytes)")
|
|
157
|
+
return None, [], [f"{name}: {why} — skipped"]
|
|
158
|
+
data = downloader(dl_url)
|
|
159
|
+
chunks, warns = extract_file_chunks(name, data)
|
|
160
|
+
return {"page_id": f"file:{item_id}", "title": name,
|
|
161
|
+
"url": d.get("webUrl", ""),
|
|
162
|
+
"version": str(d.get("lastModifiedDateTime", "")),
|
|
163
|
+
"content_hash": hashlib.sha256(data).hexdigest(),
|
|
164
|
+
"warnings": warns}, chunks, warns
|
|
165
|
+
|
|
166
|
+
# ---- the crawl ----
|
|
167
|
+
|
|
168
|
+
def start(self, root_entry: str) -> str:
|
|
169
|
+
job_id = uuid.uuid4().hex[:12]
|
|
170
|
+
self.index.abandon_running_jobs()
|
|
171
|
+
self.index.create_job(job_id, root_entry)
|
|
172
|
+
self.index.save_job(job_id, "running", [root_entry], [])
|
|
173
|
+
return job_id
|
|
174
|
+
|
|
175
|
+
def _checkpoint(self, job_id, queue, errors, seen, levels, warns) -> None:
|
|
176
|
+
with self.index.lock, self.index.db:
|
|
177
|
+
self.index.db.execute("INSERT OR REPLACE INTO meta VALUES (?,?)",
|
|
178
|
+
(f"seen:{job_id}", json.dumps(sorted(seen))))
|
|
179
|
+
self.index.db.execute("INSERT OR REPLACE INTO meta VALUES (?,?)",
|
|
180
|
+
(f"levels:{job_id}", json.dumps(levels)))
|
|
181
|
+
self.index.db.execute("INSERT OR REPLACE INTO meta VALUES (?,?)",
|
|
182
|
+
(f"warns:{job_id}", json.dumps(warns)))
|
|
183
|
+
self.index.db.execute(
|
|
184
|
+
"UPDATE jobs SET queue=?, errors=? WHERE job_id=?",
|
|
185
|
+
(json.dumps(queue), json.dumps(errors), job_id))
|
|
186
|
+
|
|
187
|
+
def step(self, job_id: str) -> dict:
|
|
188
|
+
job = self.index.job(job_id)
|
|
189
|
+
if job is None:
|
|
190
|
+
raise AccessError(f"unknown job {job_id}")
|
|
191
|
+
if job["state"] != "running":
|
|
192
|
+
return {"job_id": job_id, "state": job["state"],
|
|
193
|
+
"remaining": len(job["queue"]), "errors": job["errors"]}
|
|
194
|
+
|
|
195
|
+
queue: list[str] = job["queue"]
|
|
196
|
+
errors: list[str] = job["errors"]
|
|
197
|
+
seen_raw = self.index.meta_get(f"seen:{job_id}")
|
|
198
|
+
seen: set[str] = set(json.loads(seen_raw)) if seen_raw else set()
|
|
199
|
+
levels_raw = self.index.meta_get(f"levels:{job_id}")
|
|
200
|
+
levels: dict[str, int] = (json.loads(levels_raw) if levels_raw else {})
|
|
201
|
+
warns_raw = self.index.meta_get(f"warns:{job_id}")
|
|
202
|
+
warns: list[str] = json.loads(warns_raw) if warns_raw else []
|
|
203
|
+
|
|
204
|
+
processed = 0
|
|
205
|
+
while queue:
|
|
206
|
+
if len(seen) >= MAX_FILES:
|
|
207
|
+
errors.append(f"file cap reached ({MAX_FILES}) — remaining skipped")
|
|
208
|
+
queue.clear()
|
|
209
|
+
break
|
|
210
|
+
if processed:
|
|
211
|
+
self._checkpoint(job_id, queue, errors, seen, levels, warns)
|
|
212
|
+
entry = queue.pop(0)
|
|
213
|
+
processed += 1
|
|
214
|
+
try:
|
|
215
|
+
if entry.startswith("site:"):
|
|
216
|
+
kids, w = self._expand_site(entry[5:])
|
|
217
|
+
warns.extend(w)
|
|
218
|
+
queue.extend(k for k in kids if k not in seen)
|
|
219
|
+
continue
|
|
220
|
+
if entry.startswith("list:"):
|
|
221
|
+
_, site_id, list_id = entry.split(":", 2)
|
|
222
|
+
for it in _paged(
|
|
223
|
+
self.client,
|
|
224
|
+
f"sites/{site_id}/lists/{list_id}/items"):
|
|
225
|
+
queue.append(f"page:{site_id}:{list_id}:{it['id']}")
|
|
226
|
+
continue
|
|
227
|
+
if entry.startswith("drive:"):
|
|
228
|
+
queue.extend(self._expand_drive(entry[6:]))
|
|
229
|
+
continue
|
|
230
|
+
if entry.startswith("page:"):
|
|
231
|
+
_, site_id, list_id, item_id = entry.split(":", 3)
|
|
232
|
+
page, chunks, w = self._page_from_list_item(
|
|
233
|
+
site_id, list_id, item_id)
|
|
234
|
+
warns.extend(w)
|
|
235
|
+
elif entry.startswith("item:"):
|
|
236
|
+
_, drive_id, item_id = entry.split(":", 2)
|
|
237
|
+
meta = self.client.get(
|
|
238
|
+
f"drives/{drive_id}/items/{item_id}").json()
|
|
239
|
+
if meta.get("folder") is not None:
|
|
240
|
+
queue.extend(self._expand_item(drive_id, item_id))
|
|
241
|
+
continue
|
|
242
|
+
page, chunks, w = self._page_from_item(
|
|
243
|
+
drive_id, item_id, self.downloader)
|
|
244
|
+
warns.extend(w)
|
|
245
|
+
if page is None:
|
|
246
|
+
continue
|
|
247
|
+
else:
|
|
248
|
+
errors.append(f"unknown queue entry {entry}")
|
|
249
|
+
continue
|
|
250
|
+
except AccessError as e:
|
|
251
|
+
errors.append(f"{entry}: {e}")
|
|
252
|
+
continue
|
|
253
|
+
pid = page["page_id"]
|
|
254
|
+
if pid in seen:
|
|
255
|
+
continue
|
|
256
|
+
for c in chunks:
|
|
257
|
+
c.chunk_id = f"{pid}:{page['content_hash'][:8]}:{c.ordinal}"
|
|
258
|
+
vecs = None
|
|
259
|
+
if self.embedder is not None and chunks:
|
|
260
|
+
vecs = self.embedder.embed(
|
|
261
|
+
[f"{c.heading_path}\n{c.text}" for c in chunks])
|
|
262
|
+
self.index.upsert_page({
|
|
263
|
+
"page_id": pid, "parent_id": None, "title": page["title"],
|
|
264
|
+
"url": page["url"], "level": levels.get(pid, 0),
|
|
265
|
+
"version": page["version"],
|
|
266
|
+
"content_hash": page["content_hash"],
|
|
267
|
+
"warnings": page["warnings"]}, chunks, vecs)
|
|
268
|
+
seen.add(pid)
|
|
269
|
+
|
|
270
|
+
self._checkpoint(job_id, queue, errors, seen, levels, warns)
|
|
271
|
+
self.index.publish(seen)
|
|
272
|
+
self.index.save_job(job_id, "done" if not queue else "running",
|
|
273
|
+
queue, errors)
|
|
274
|
+
return {"job_id": job_id,
|
|
275
|
+
"state": "done" if not queue else "running",
|
|
276
|
+
"remaining": len(queue), "pages_seen": len(seen),
|
|
277
|
+
"errors": errors, "warnings": warns}
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""stdio MCP server for sharepoint-knowledge — the only surface the agent
|
|
2
|
+
reaches. Scope = configured site + signed-in account + chosen subtree."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import threading
|
|
9
|
+
|
|
10
|
+
from confluence_knowledge.client import AccessError, SecurityException
|
|
11
|
+
from confluence_knowledge.index import Index
|
|
12
|
+
from confluence_knowledge.retrieval import (
|
|
13
|
+
LocalEmbedder,
|
|
14
|
+
LocalReranker,
|
|
15
|
+
check_citations,
|
|
16
|
+
retrieve,
|
|
17
|
+
)
|
|
18
|
+
from confluence_knowledge.settings import SetupError
|
|
19
|
+
from mcp.server.fastmcp import FastMCP
|
|
20
|
+
|
|
21
|
+
from .auth_sp import acquire_token
|
|
22
|
+
from .client_sp import download_file, make_client
|
|
23
|
+
from .crawl_sp import SPCrawler, resolve_root
|
|
24
|
+
from .settings_sp import APP_NAME, SPProfile, graph_base, home, index_root
|
|
25
|
+
|
|
26
|
+
mcp = FastMCP("sharepoint-knowledge")
|
|
27
|
+
|
|
28
|
+
_scopes: dict[str, dict] = {}
|
|
29
|
+
_evidence_sets: dict[str, list[dict]] = {}
|
|
30
|
+
_indexes: dict[str, Index] = {}
|
|
31
|
+
_clients: dict[str, object] = {}
|
|
32
|
+
_EMBEDDER = None
|
|
33
|
+
_RERANKER = None
|
|
34
|
+
_model_lock = threading.Lock()
|
|
35
|
+
_models_ready = threading.Event()
|
|
36
|
+
_warm_lock = threading.Lock()
|
|
37
|
+
_warm_started = False
|
|
38
|
+
|
|
39
|
+
LOADING_NOTE = "loading local models — call this tool again"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _index_for(scope_key: str) -> Index:
|
|
43
|
+
idx = _indexes.get(scope_key)
|
|
44
|
+
if idx is None:
|
|
45
|
+
idx = _indexes[scope_key] = Index(index_root() / f"{scope_key}.db")
|
|
46
|
+
return idx
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _embedder():
|
|
50
|
+
global _EMBEDDER
|
|
51
|
+
with _model_lock:
|
|
52
|
+
if _EMBEDDER is None:
|
|
53
|
+
_EMBEDDER = LocalEmbedder(home(), APP_NAME)
|
|
54
|
+
return _EMBEDDER
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _reranker():
|
|
58
|
+
global _RERANKER
|
|
59
|
+
with _model_lock:
|
|
60
|
+
if _RERANKER is None:
|
|
61
|
+
_RERANKER = LocalReranker(home(), APP_NAME)
|
|
62
|
+
return _RERANKER
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _warm_models() -> None:
|
|
66
|
+
try:
|
|
67
|
+
_embedder()
|
|
68
|
+
_reranker()
|
|
69
|
+
except SetupError:
|
|
70
|
+
pass
|
|
71
|
+
finally:
|
|
72
|
+
_models_ready.set()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _start_warmup() -> None:
|
|
76
|
+
global _warm_started
|
|
77
|
+
with _warm_lock:
|
|
78
|
+
if _warm_started:
|
|
79
|
+
return
|
|
80
|
+
_warm_started = True
|
|
81
|
+
threading.Thread(target=_warm_models, name="model-warmup", daemon=True).start()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _models_loaded(wait: float) -> bool:
|
|
85
|
+
_start_warmup()
|
|
86
|
+
return _models_ready.wait(wait)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _client_for(account: str) -> object:
|
|
90
|
+
cli = _clients.get(account)
|
|
91
|
+
if cli is None:
|
|
92
|
+
profile = SPProfile.load()
|
|
93
|
+
token = acquire_token(profile, workdir_from_home(), home())
|
|
94
|
+
cli = _clients[account] = make_client(graph_base(), token)
|
|
95
|
+
return cli
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def workdir_from_home():
|
|
99
|
+
from .settings_sp import workdir
|
|
100
|
+
return workdir()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _scope(scope_handle: str) -> dict:
|
|
104
|
+
s = _scopes.get(scope_handle)
|
|
105
|
+
if s is None:
|
|
106
|
+
parts = scope_handle.split(":", 3)
|
|
107
|
+
if len(parts) == 4 and parts[0] == "scope" and \
|
|
108
|
+
connect(parts[3])["scope_handle"] == scope_handle:
|
|
109
|
+
return _scopes[scope_handle]
|
|
110
|
+
raise SetupError("unknown scope — call connect first")
|
|
111
|
+
return s
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@mcp.tool()
|
|
115
|
+
def status() -> dict:
|
|
116
|
+
"""Report setup readiness — profile present, token reachable, models."""
|
|
117
|
+
models = home() / "models"
|
|
118
|
+
try:
|
|
119
|
+
profile = SPProfile.load()
|
|
120
|
+
try:
|
|
121
|
+
token = acquire_token(profile, workdir_from_home(), home())
|
|
122
|
+
who = "token ready"
|
|
123
|
+
except SetupError as e:
|
|
124
|
+
who = f"sign-in needed: {e}"
|
|
125
|
+
cfg = {"site": profile.site_url, "tenant": profile.tenant_id,
|
|
126
|
+
"auth": who}
|
|
127
|
+
configured = who == "token ready"
|
|
128
|
+
except SetupError as e:
|
|
129
|
+
cfg = {"error": str(e)}
|
|
130
|
+
configured = False
|
|
131
|
+
token = None
|
|
132
|
+
if token:
|
|
133
|
+
try:
|
|
134
|
+
cli = _client_for(profile.site_url)
|
|
135
|
+
me = cli.get("me").json()
|
|
136
|
+
cfg["signed_in_as"] = me.get("userPrincipalName") or me.get("mail")
|
|
137
|
+
except AccessError as e:
|
|
138
|
+
cfg["auth"] = str(e)
|
|
139
|
+
configured = False
|
|
140
|
+
return {
|
|
141
|
+
"configured": configured,
|
|
142
|
+
"profile": cfg,
|
|
143
|
+
"embedding_model_ready": (models / "all-MiniLM-L6-v2").exists(),
|
|
144
|
+
"reranker_model_ready": (models / "ms-marco-MiniLM-L6-v2").exists(),
|
|
145
|
+
"models_loaded": _models_ready.is_set() and _EMBEDDER is not None,
|
|
146
|
+
"index_root": str(index_root()),
|
|
147
|
+
"live_scopes": sorted(_scopes),
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@mcp.tool()
|
|
152
|
+
def connect(root: str) -> dict:
|
|
153
|
+
"""Bind a scope to a SharePoint URL — a site (pages + all libraries),
|
|
154
|
+
or a library/folder path inside it. One root per connect."""
|
|
155
|
+
profile = SPProfile.load()
|
|
156
|
+
cli = _client_for(profile.site_url)
|
|
157
|
+
if root.startswith(("site:", "item:", "drive:", "list:", "page:")):
|
|
158
|
+
root_entry, site_web = root, profile.site_url
|
|
159
|
+
else:
|
|
160
|
+
root_entry, site_web = resolve_root(cli, root)
|
|
161
|
+
principal = "me"
|
|
162
|
+
scope_key = profile.scope_key(principal, root_entry)
|
|
163
|
+
idx = _index_for(scope_key)
|
|
164
|
+
st = idx.stats()
|
|
165
|
+
handle = f"scope:{scope_key}:{root}"
|
|
166
|
+
_scopes[handle] = {"key": scope_key, "root_entry": root_entry,
|
|
167
|
+
"site_web": site_web, "client": cli}
|
|
168
|
+
_start_warmup()
|
|
169
|
+
return {
|
|
170
|
+
"scope_handle": handle,
|
|
171
|
+
"root": root_entry,
|
|
172
|
+
"site": site_web,
|
|
173
|
+
"cached": st["pages"] > 0,
|
|
174
|
+
"cached_pages": st["pages"],
|
|
175
|
+
"cached_as_of": st["as_of"],
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
_workers: dict[str, threading.Thread] = {}
|
|
180
|
+
_worker_errors: dict[str, str] = {}
|
|
181
|
+
_workers_lock = threading.Lock()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _worker_loop(s: dict, job_id: str) -> None:
|
|
185
|
+
idx = _index_for(s["key"])
|
|
186
|
+
try:
|
|
187
|
+
_models_ready.wait()
|
|
188
|
+
c = SPCrawler(s["client"], idx, embedder=_embedder(),
|
|
189
|
+
downloader=download_file)
|
|
190
|
+
r = c.step(job_id)
|
|
191
|
+
while r["state"] == "running":
|
|
192
|
+
r = c.step(job_id)
|
|
193
|
+
except Exception as e: # noqa: BLE001 — surfaced via job state
|
|
194
|
+
err = f"{type(e).__name__}: {e}"
|
|
195
|
+
_worker_errors[f"{s['key']}:{job_id}"] = err
|
|
196
|
+
job = idx.job(job_id)
|
|
197
|
+
if job and job["state"] == "running":
|
|
198
|
+
idx.save_job(job_id, "error", job["queue"], job["errors"] + [err])
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _ensure_worker(s: dict, job_id: str) -> None:
|
|
202
|
+
key = f"{s['key']}:{job_id}"
|
|
203
|
+
with _workers_lock:
|
|
204
|
+
t = _workers.get(key)
|
|
205
|
+
if t is not None and t.is_alive():
|
|
206
|
+
return
|
|
207
|
+
_workers[key] = threading.Thread(
|
|
208
|
+
target=_worker_loop, args=(s, job_id),
|
|
209
|
+
name=f"crawl-{job_id}", daemon=True)
|
|
210
|
+
_workers[key].start()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@mcp.tool()
|
|
214
|
+
def start_crawl(scope_handle: str, refresh: bool = False) -> dict:
|
|
215
|
+
"""Begin indexing the root in the background (Site Pages + libraries)."""
|
|
216
|
+
s = _scope(scope_handle)
|
|
217
|
+
idx = _index_for(s["key"])
|
|
218
|
+
c = SPCrawler(s["client"], idx)
|
|
219
|
+
job_id = c.start(s["root_entry"])
|
|
220
|
+
_ensure_worker(s, job_id)
|
|
221
|
+
return {"job_id": job_id, "state": "running",
|
|
222
|
+
"note": "crawl runs in the background — call crawl_step to poll"}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@mcp.tool()
|
|
226
|
+
def crawl_step(scope_handle: str, job_id: str) -> dict:
|
|
227
|
+
"""Read crawl progress instantly; resumes a dead worker automatically."""
|
|
228
|
+
s = _scope(scope_handle)
|
|
229
|
+
idx = _index_for(s["key"])
|
|
230
|
+
job = idx.job(job_id)
|
|
231
|
+
if job is None:
|
|
232
|
+
raise AccessError(f"unknown job {job_id}")
|
|
233
|
+
|
|
234
|
+
def progress() -> dict:
|
|
235
|
+
p = idx.job_progress(job_id)
|
|
236
|
+
warns = idx.meta_get(f"warns:{job_id}")
|
|
237
|
+
if warns:
|
|
238
|
+
p["warnings"] = json.loads(warns)
|
|
239
|
+
return p
|
|
240
|
+
|
|
241
|
+
if job["state"] == "running":
|
|
242
|
+
if not _models_ready.is_set():
|
|
243
|
+
_start_warmup()
|
|
244
|
+
note = LOADING_NOTE
|
|
245
|
+
else:
|
|
246
|
+
note = None
|
|
247
|
+
_ensure_worker(s, job_id)
|
|
248
|
+
p = progress()
|
|
249
|
+
if note:
|
|
250
|
+
p["note"] = note
|
|
251
|
+
return p
|
|
252
|
+
p = progress()
|
|
253
|
+
if p["state"] == "done":
|
|
254
|
+
p["note"] = "no further calls needed — the index is published"
|
|
255
|
+
return p
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _coverage(idx: Index) -> str:
|
|
259
|
+
job = idx.latest_job()
|
|
260
|
+
if job is None or job["state"] == "abandoned":
|
|
261
|
+
return "unknown"
|
|
262
|
+
if job["state"] == "running":
|
|
263
|
+
return "refresh in progress — answering from the previous snapshot"
|
|
264
|
+
return "partial" if job["errors"] else "complete"
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@mcp.tool()
|
|
268
|
+
def search(scope_handle: str, question: str) -> dict:
|
|
269
|
+
"""Hybrid retrieve + local rerank; returns bounded, cited evidence."""
|
|
270
|
+
s = _scope(scope_handle)
|
|
271
|
+
idx = _index_for(s["key"])
|
|
272
|
+
st = idx.stats()
|
|
273
|
+
if st["pages"] == 0:
|
|
274
|
+
job = idx.latest_job()
|
|
275
|
+
if job and job["state"] == "running":
|
|
276
|
+
return {"evidence": [], "coverage": "crawling",
|
|
277
|
+
"note": f"crawl {job['job_id']} still running — keep calling crawl_step "
|
|
278
|
+
"until state is done, then search"}
|
|
279
|
+
return {"evidence": [], "coverage": "empty",
|
|
280
|
+
"note": "nothing indexed — run a crawl first"}
|
|
281
|
+
if not _models_loaded(10.0):
|
|
282
|
+
return {"evidence": [], "coverage": "loading", "note": LOADING_NOTE}
|
|
283
|
+
ev = retrieve(idx, question, _embedder(), _reranker())
|
|
284
|
+
set_id = f"ev:{len(_evidence_sets)}:{s['key'][:8]}"
|
|
285
|
+
_evidence_sets[set_id] = ev
|
|
286
|
+
return {
|
|
287
|
+
"evidence_set": set_id,
|
|
288
|
+
"coverage": _coverage(idx),
|
|
289
|
+
"as_of": st["as_of"],
|
|
290
|
+
"evidence": [
|
|
291
|
+
{"chunk_id": e["chunk_id"], "page_title": e["page_title"],
|
|
292
|
+
"page_url": e["page_url"], "heading": e["heading"],
|
|
293
|
+
"score": e.get("rerank_score"), "text": e["text"][:2000]}
|
|
294
|
+
for e in ev
|
|
295
|
+
],
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@mcp.tool()
|
|
300
|
+
def get_evidence(scope_handle: str, chunk_ids: list[str]) -> dict:
|
|
301
|
+
"""Return full text of previously returned chunks — same scope only."""
|
|
302
|
+
s = _scope(scope_handle)
|
|
303
|
+
idx = _index_for(s["key"])
|
|
304
|
+
all_chunks = {c["chunk_id"]: c for c in idx.active_chunks()}
|
|
305
|
+
out = []
|
|
306
|
+
for cid in chunk_ids[:16]:
|
|
307
|
+
c = all_chunks.get(cid)
|
|
308
|
+
if c is None:
|
|
309
|
+
raise SecurityException(f"chunk {cid} not in this scope's index")
|
|
310
|
+
p = idx.active_pages().get(c["page_id"], {})
|
|
311
|
+
out.append({"chunk_id": cid, "text": c["text"],
|
|
312
|
+
"page_title": p.get("title", ""), "page_url": p.get("url", "")})
|
|
313
|
+
return {"chunks": out}
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@mcp.tool()
|
|
317
|
+
def verify_citations(scope_handle: str, evidence_set: str,
|
|
318
|
+
mappings: list[dict]) -> dict:
|
|
319
|
+
_scope(scope_handle)
|
|
320
|
+
ev = _evidence_sets.get(evidence_set)
|
|
321
|
+
if ev is None or not evidence_set.endswith(_scopes[scope_handle]["key"][:8]):
|
|
322
|
+
raise SecurityException(
|
|
323
|
+
"unknown or cross-scope evidence set — run search again and cite its evidence_set")
|
|
324
|
+
return check_citations(ev, mappings)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def main() -> None:
|
|
328
|
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
329
|
+
_start_warmup()
|
|
330
|
+
mcp.run()
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
if __name__ == "__main__":
|
|
334
|
+
main()
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Filesystem layout + connection profile for sharepoint-knowledge.
|
|
2
|
+
|
|
3
|
+
Same machinery as the other connectors: ~/.sharepoint-knowledge home,
|
|
4
|
+
SP_KNOWLEDGE_* env overrides, a non-secret profile.json. The token cache
|
|
5
|
+
itself is a credential blob in the OS keyring (or protected-file fallback).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from confluence_knowledge.settings import (
|
|
17
|
+
SetupError,
|
|
18
|
+
default_home,
|
|
19
|
+
resolve_workdir,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
APP_NAME = "sharepoint-knowledge"
|
|
23
|
+
INDEX_DIR_NAME = ".sp_knowledge_index"
|
|
24
|
+
PROFILE_NAME = "profile.json"
|
|
25
|
+
AUTH_FILE_NAME = ".sp_auth.json"
|
|
26
|
+
AUTH_ENV = "SP_KNOWLEDGE_TOKEN"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def home() -> Path:
|
|
30
|
+
return default_home("SP_KNOWLEDGE_HOME", ".sharepoint-knowledge")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def workdir() -> Path:
|
|
34
|
+
return resolve_workdir("SP_KNOWLEDGE_WORKDIR",
|
|
35
|
+
"SP_KNOWLEDGE_HOME", ".sharepoint-knowledge")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def index_root() -> Path:
|
|
39
|
+
return workdir() / INDEX_DIR_NAME
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def graph_base() -> str:
|
|
43
|
+
"""Microsoft Graph base — overridable for the fake server in e2e tests."""
|
|
44
|
+
return os.environ.get("SP_KNOWLEDGE_GRAPH_BASE",
|
|
45
|
+
"https://graph.microsoft.com/v1.0")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class SPProfile:
|
|
50
|
+
"""Non-secret connection profile. The token cache is a credential blob."""
|
|
51
|
+
|
|
52
|
+
tenant_id: str
|
|
53
|
+
client_id: str
|
|
54
|
+
site_url: str # canonical https site URL the user indexed
|
|
55
|
+
credential_ref: str # opaque key — not the token
|
|
56
|
+
|
|
57
|
+
def scope_key(self, principal: str, root: str) -> str:
|
|
58
|
+
raw = f"{self.site_url}|{principal}|{root}"
|
|
59
|
+
return hashlib.sha256(raw.encode()).hexdigest()[:24]
|
|
60
|
+
|
|
61
|
+
def save(self) -> Path:
|
|
62
|
+
p = home() / PROFILE_NAME
|
|
63
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
tmp = p.with_suffix(".tmp")
|
|
65
|
+
tmp.write_text(json.dumps({
|
|
66
|
+
"tenant_id": self.tenant_id, "client_id": self.client_id,
|
|
67
|
+
"site_url": self.site_url, "credential_ref": self.credential_ref,
|
|
68
|
+
}, indent=2))
|
|
69
|
+
os.replace(tmp, p)
|
|
70
|
+
return p
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
def load() -> SPProfile:
|
|
74
|
+
p = home() / PROFILE_NAME
|
|
75
|
+
if not p.exists():
|
|
76
|
+
raise SetupError("no SharePoint profile configured — run "
|
|
77
|
+
"`sharepoint-knowledge setup`")
|
|
78
|
+
d = json.loads(p.read_text())
|
|
79
|
+
return SPProfile(d["tenant_id"], d["client_id"], d["site_url"],
|
|
80
|
+
d["credential_ref"])
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sharepoint-knowledge
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: SharePoint connector for the grounded-Q&A engine — site pages + document libraries, Entra device-code sign-in, local models, cited answers
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Project-URL: Homepage, https://github.com/nkunal1992/agents
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: confluence-knowledge[files]>=0.4.1
|
|
10
|
+
Requires-Dist: msal>=1.31
|
|
11
|
+
Requires-Dist: httpx>=0.27
|
|
12
|
+
Requires-Dist: mcp>=1.8
|
|
13
|
+
Provides-Extra: models
|
|
14
|
+
Requires-Dist: confluence-knowledge[models]>=0.4.1; extra == "models"
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
17
|
+
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
18
|
+
Requires-Dist: python-docx>=1.1; extra == "dev"
|
|
19
|
+
Requires-Dist: python-pptx>=1.0; extra == "dev"
|
|
20
|
+
Requires-Dist: openpyxl>=3.1; extra == "dev"
|
|
21
|
+
Requires-Dist: pypdf>=5.0; extra == "dev"
|
|
22
|
+
|
|
23
|
+
# sharepoint-knowledge
|
|
24
|
+
|
|
25
|
+
SharePoint connector on the shared grounded-Q&A engine — Site Pages +
|
|
26
|
+
document libraries (docx/pdf/pptx/xlsx/csv/txt/md/html), Entra device-code
|
|
27
|
+
sign-in, local models, cited answers. Same engine as
|
|
28
|
+
[`confluence-knowledge`](../confluence-knowledge) and
|
|
29
|
+
[`local-knowledge`](../local-knowledge).
|
|
30
|
+
|
|
31
|
+
- `sharepoint_knowledge/auth_sp.py` — MSAL device-code flow, token cache in
|
|
32
|
+
the OS keyring (protected-file fallback), silent refresh
|
|
33
|
+
- `sharepoint_knowledge/client_sp.py` — GET-only Graph client; downloads go
|
|
34
|
+
through `@microsoft.graph.downloadUrl` WITHOUT the Bearer header
|
|
35
|
+
- `sharepoint_knowledge/crawl_sp.py` — durable BFS site → pages + libraries
|
|
36
|
+
- `sharepoint_knowledge/server_sp.py` — the `sharepoint-knowledge/*` tools
|
|
37
|
+
- `sharepoint_knowledge/cli_sp.py` — setup/doctor/prepare-models/register/
|
|
38
|
+
update/serve/clean
|
|
39
|
+
|
|
40
|
+
See `docs/sharepoint-knowledge.md` for the full guide.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
sharepoint_knowledge/__init__.py,sha256=J8KcyQSaE9FuCWplCAnhlSJW0DNI48H4oDqCM8gxiUo,97
|
|
2
|
+
sharepoint_knowledge/auth_sp.py,sha256=vzschQ7NEL8XYb2LnjMkEFKh7Pu5h0OSQVyqQSJ7_Fk,4314
|
|
3
|
+
sharepoint_knowledge/cli_sp.py,sha256=n7iFP-MKg2QQp6NtJGL9ponUw-9ll2fIYzpokZrSG0k,7153
|
|
4
|
+
sharepoint_knowledge/client_sp.py,sha256=ZvRWSuz0NATPgS8JH_WTO-ef17kCXNu8odH3JSsacOU,3055
|
|
5
|
+
sharepoint_knowledge/crawl_sp.py,sha256=7t90HE_cCl5pLIRKYhrLo1Wqg3h-SUIoLEO4fCT39aY,12242
|
|
6
|
+
sharepoint_knowledge/server_sp.py,sha256=VueieIIAiVXmpZ0N0j_IvY-XI5oq6P8jrYhfz_ppI_g,10601
|
|
7
|
+
sharepoint_knowledge/settings_sp.py,sha256=Coz78N3VpmulKyvHUpTdiAdzd1NODx581qsMfXDDxjQ,2483
|
|
8
|
+
sharepoint_knowledge-0.1.0.dist-info/METADATA,sha256=xZsG0XWvi8pGVj_yCZ5tdy4imqvfjusbAqorfZ9RP0E,1779
|
|
9
|
+
sharepoint_knowledge-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
10
|
+
sharepoint_knowledge-0.1.0.dist-info/entry_points.txt,sha256=Hwjazt5G5FCCo1vQLJMl6WcHA6Ss-Ux4kIob2bLlj_I,74
|
|
11
|
+
sharepoint_knowledge-0.1.0.dist-info/top_level.txt,sha256=LKQ769avV5sRwxHMNtEe5bTs2HmV79KZ3to22ymUBX8,21
|
|
12
|
+
sharepoint_knowledge-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sharepoint_knowledge
|