aggrete 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.
- aggrete/__init__.py +0 -0
- aggrete/accumulator.py +111 -0
- aggrete/auth.py +96 -0
- aggrete/connectors/__init__.py +0 -0
- aggrete/connectors/drive.py +170 -0
- aggrete/entities.py +69 -0
- aggrete/ingest.py +260 -0
- aggrete/plugin.py +158 -0
- aggrete/policy.py +219 -0
- aggrete/proxy.py +340 -0
- aggrete/signin.py +157 -0
- aggrete-0.1.0.dist-info/METADATA +315 -0
- aggrete-0.1.0.dist-info/RECORD +16 -0
- aggrete-0.1.0.dist-info/WHEEL +5 -0
- aggrete-0.1.0.dist-info/entry_points.txt +3 -0
- aggrete-0.1.0.dist-info/top_level.txt +1 -0
aggrete/__init__.py
ADDED
|
File without changes
|
aggrete/accumulator.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Layer 4 state: what each user has already pulled, across every connector.
|
|
2
|
+
|
|
3
|
+
Keyed to the *user*, not the session. A new chat must not reset the budget.
|
|
4
|
+
MemoryStore is for tests and single-process runs; RedisStore is what you
|
|
5
|
+
deploy, because the whole point is state shared across clients and gateways.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from typing import Iterable, Protocol
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_window(w: str | int) -> int:
|
|
15
|
+
if isinstance(w, int):
|
|
16
|
+
return w
|
|
17
|
+
units = {"s": 1, "m": 60, "h": 3600, "d": 86400}
|
|
18
|
+
return int(w[:-1]) * units[w[-1]]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Store(Protocol):
|
|
22
|
+
def record(self, user: str, domain: str, entities: Iterable[str], ttl: int) -> None: ...
|
|
23
|
+
def entities(self, user: str, domain: str) -> set[str]: ...
|
|
24
|
+
def domains(self, user: str) -> set[str]: ...
|
|
25
|
+
def grant(self, user: str, rule_id: str, ttl: int, purpose: str) -> None: ...
|
|
26
|
+
def granted(self, user: str, rule_id: str) -> str | None: ...
|
|
27
|
+
def reset(self, user: str) -> None: ...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MemoryStore:
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
self._ents: dict[tuple[str, str], dict[str, float]] = {}
|
|
33
|
+
self._grants: dict[tuple[str, str], tuple[float, str]] = {}
|
|
34
|
+
|
|
35
|
+
def _live(self, key: tuple[str, str]) -> dict[str, float]:
|
|
36
|
+
now = time.time()
|
|
37
|
+
bucket = {e: exp for e, exp in self._ents.get(key, {}).items() if exp > now}
|
|
38
|
+
self._ents[key] = bucket
|
|
39
|
+
return bucket
|
|
40
|
+
|
|
41
|
+
def record(self, user, domain, entities, ttl):
|
|
42
|
+
key = (user, domain)
|
|
43
|
+
bucket = self._live(key)
|
|
44
|
+
exp = time.time() + ttl
|
|
45
|
+
# touching a domain with no entities still marks the domain as seen
|
|
46
|
+
bucket.setdefault("__touched__", exp)
|
|
47
|
+
for e in entities:
|
|
48
|
+
bucket[e] = exp
|
|
49
|
+
self._ents[key] = bucket
|
|
50
|
+
|
|
51
|
+
def entities(self, user, domain):
|
|
52
|
+
return {e for e in self._live((user, domain)) if e != "__touched__"}
|
|
53
|
+
|
|
54
|
+
def domains(self, user):
|
|
55
|
+
return {d for (u, d) in list(self._ents) if u == user and self._live((u, d))}
|
|
56
|
+
|
|
57
|
+
def grant(self, user, rule_id, ttl, purpose):
|
|
58
|
+
self._grants[(user, rule_id)] = (time.time() + ttl, purpose)
|
|
59
|
+
|
|
60
|
+
def granted(self, user, rule_id):
|
|
61
|
+
exp, purpose = self._grants.get((user, rule_id), (0, ""))
|
|
62
|
+
return purpose if exp > time.time() else None
|
|
63
|
+
|
|
64
|
+
def reset(self, user):
|
|
65
|
+
for k in [k for k in self._ents if k[0] == user]:
|
|
66
|
+
del self._ents[k]
|
|
67
|
+
for k in [k for k in self._grants if k[0] == user]:
|
|
68
|
+
del self._grants[k]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class RedisStore:
|
|
72
|
+
"""Same contract, backed by Redis sets with TTLs."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, client, prefix: str = "coc"):
|
|
75
|
+
self.r = client
|
|
76
|
+
self.p = prefix
|
|
77
|
+
|
|
78
|
+
def _key(self, user, domain):
|
|
79
|
+
return f"{self.p}:ents:{user}:{domain}"
|
|
80
|
+
|
|
81
|
+
def record(self, user, domain, entities, ttl):
|
|
82
|
+
key = self._key(user, domain)
|
|
83
|
+
pipe = self.r.pipeline()
|
|
84
|
+
pipe.sadd(key, "__touched__", *entities)
|
|
85
|
+
pipe.expire(key, ttl)
|
|
86
|
+
pipe.sadd(f"{self.p}:domains:{user}", domain)
|
|
87
|
+
pipe.expire(f"{self.p}:domains:{user}", ttl)
|
|
88
|
+
pipe.execute()
|
|
89
|
+
|
|
90
|
+
def entities(self, user, domain):
|
|
91
|
+
raw = self.r.smembers(self._key(user, domain))
|
|
92
|
+
return {e.decode() if isinstance(e, bytes) else e for e in raw} - {"__touched__"}
|
|
93
|
+
|
|
94
|
+
def domains(self, user):
|
|
95
|
+
live = set()
|
|
96
|
+
for d in self.r.smembers(f"{self.p}:domains:{user}"):
|
|
97
|
+
d = d.decode() if isinstance(d, bytes) else d
|
|
98
|
+
if self.r.exists(self._key(user, d)):
|
|
99
|
+
live.add(d)
|
|
100
|
+
return live
|
|
101
|
+
|
|
102
|
+
def grant(self, user, rule_id, ttl, purpose):
|
|
103
|
+
self.r.setex(f"{self.p}:grant:{user}:{rule_id}", ttl, purpose)
|
|
104
|
+
|
|
105
|
+
def granted(self, user, rule_id):
|
|
106
|
+
v = self.r.get(f"{self.p}:grant:{user}:{rule_id}")
|
|
107
|
+
return (v.decode() if isinstance(v, bytes) else v) if v else None
|
|
108
|
+
|
|
109
|
+
def reset(self, user):
|
|
110
|
+
for k in self.r.scan_iter(f"{self.p}:*:{user}*"):
|
|
111
|
+
self.r.delete(k)
|
aggrete/auth.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Who is calling. Over HTTP the answer comes from the bearer token, never from config.
|
|
2
|
+
|
|
3
|
+
Two verifiers:
|
|
4
|
+
|
|
5
|
+
- JWTVerifier. Production. Validates RS256/ES256 JWTs from your IdP (Okta,
|
|
6
|
+
Entra, Google, Auth0, Keycloak…) against its JWKS, checks
|
|
7
|
+
issuer/audience/expiry, and derives the user from a claim.
|
|
8
|
+
- StaticTokens. Development and tests. A fixed token → subject map.
|
|
9
|
+
|
|
10
|
+
The identity the policy engine sees is `identity_for(token)`: the configured
|
|
11
|
+
claim (default `email`, falling back to `sub`). Per-user state in the
|
|
12
|
+
accumulator is keyed on it, so pick a claim that is stable across sessions.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import time
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import jwt
|
|
21
|
+
from jwt import PyJWKClient
|
|
22
|
+
from mcp.server.auth.provider import AccessToken, TokenVerifier
|
|
23
|
+
|
|
24
|
+
DEFAULT_IDENTITY_CLAIMS = ("email", "preferred_username", "sub")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class StaticTokens(TokenVerifier):
|
|
28
|
+
"""`auth: {mode: static, tokens: {<token>: {subject: ..., scopes: [...]}}}`."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, tokens: dict[str, dict[str, Any]]):
|
|
31
|
+
self.tokens = tokens
|
|
32
|
+
|
|
33
|
+
async def verify_token(self, token: str) -> AccessToken | None:
|
|
34
|
+
spec = self.tokens.get(token)
|
|
35
|
+
if not spec:
|
|
36
|
+
return None
|
|
37
|
+
return AccessToken(token=token, client_id=spec.get("client_id", "static"),
|
|
38
|
+
scopes=list(spec.get("scopes", [])), subject=spec["subject"],
|
|
39
|
+
claims={"email": spec.get("email", spec["subject"])})
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class JWTVerifier(TokenVerifier):
|
|
43
|
+
"""`auth: {mode: jwt, issuer: ..., audience: ..., jwks_url: ... | public_key: ...}`."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, issuer: str, audience: str | list[str], *, jwks_url: str | None = None,
|
|
46
|
+
public_key: str | None = None, algorithms: list[str] | None = None,
|
|
47
|
+
required_scopes: list[str] | None = None, leeway: int = 30):
|
|
48
|
+
if not (jwks_url or public_key):
|
|
49
|
+
jwks_url = issuer.rstrip("/") + "/.well-known/jwks.json"
|
|
50
|
+
self.issuer, self.audience = issuer, audience
|
|
51
|
+
self.jwks = PyJWKClient(jwks_url, cache_keys=True) if jwks_url else None
|
|
52
|
+
self.public_key = public_key
|
|
53
|
+
self.algorithms = algorithms or ["RS256", "ES256", "RS384", "ES384", "RS512", "ES512"]
|
|
54
|
+
self.required_scopes = required_scopes or []
|
|
55
|
+
self.leeway = leeway
|
|
56
|
+
|
|
57
|
+
async def verify_token(self, token: str) -> AccessToken | None:
|
|
58
|
+
try:
|
|
59
|
+
key = self.public_key or self.jwks.get_signing_key_from_jwt(token).key
|
|
60
|
+
claims = jwt.decode(token, key, algorithms=self.algorithms, issuer=self.issuer,
|
|
61
|
+
audience=self.audience, leeway=self.leeway,
|
|
62
|
+
options={"require": ["exp", "iss", "sub"]})
|
|
63
|
+
except jwt.PyJWTError:
|
|
64
|
+
return None
|
|
65
|
+
scopes = claims.get("scope", "")
|
|
66
|
+
scopes = scopes.split() if isinstance(scopes, str) else list(scopes or [])
|
|
67
|
+
if any(s not in scopes for s in self.required_scopes):
|
|
68
|
+
return None
|
|
69
|
+
return AccessToken(token=token, client_id=str(claims.get("client_id") or claims.get("azp") or claims.get("aud")),
|
|
70
|
+
scopes=scopes, expires_at=claims.get("exp"), subject=claims["sub"], claims=claims)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def build_verifier(auth_cfg: dict[str, Any]) -> TokenVerifier:
|
|
74
|
+
mode = auth_cfg.get("mode", "jwt")
|
|
75
|
+
if mode == "static":
|
|
76
|
+
return StaticTokens(auth_cfg["tokens"])
|
|
77
|
+
if mode == "jwt":
|
|
78
|
+
return JWTVerifier(auth_cfg["issuer"], auth_cfg["audience"], jwks_url=auth_cfg.get("jwks_url"),
|
|
79
|
+
public_key=auth_cfg.get("public_key"), algorithms=auth_cfg.get("algorithms"),
|
|
80
|
+
required_scopes=auth_cfg.get("required_scopes"))
|
|
81
|
+
raise ValueError(f"unknown auth mode {mode!r}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def identity_for(token: AccessToken, claim: str | None = None) -> str:
|
|
85
|
+
claims = token.claims or {}
|
|
86
|
+
order = (claim,) if claim else DEFAULT_IDENTITY_CLAIMS
|
|
87
|
+
for c in order:
|
|
88
|
+
if c and claims.get(c):
|
|
89
|
+
return str(claims[c])
|
|
90
|
+
if token.subject:
|
|
91
|
+
return token.subject
|
|
92
|
+
raise ValueError("token carries no usable identity claim")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def unexpired(token: AccessToken) -> bool:
|
|
96
|
+
return token.expires_at is None or token.expires_at > time.time()
|
|
File without changes
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Google Drive as an Aggrete upstream, one tool pair per folder.
|
|
2
|
+
|
|
3
|
+
python -m aggrete.connectors.drive --credentials sa.json --root "Northwind"
|
|
4
|
+
|
|
5
|
+
The proxy runs this over stdio and holds the service-account key; people never
|
|
6
|
+
do. Each subfolder of the root becomes two tools, `search_<folder>` and
|
|
7
|
+
`read_<folder>`, so the policy can name folders as domains:
|
|
8
|
+
|
|
9
|
+
domains:
|
|
10
|
+
"drive__*_restructuring_plan": restructuring-plan
|
|
11
|
+
"drive__*_legal_hold": legal-hold
|
|
12
|
+
"drive__*": drive-general
|
|
13
|
+
|
|
14
|
+
Results carry the owner's and last editor's email, which is what the policy
|
|
15
|
+
counts as "people". Read only: the service account needs Viewer on the root.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import asyncio
|
|
22
|
+
import json
|
|
23
|
+
import re
|
|
24
|
+
import time
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
import httpx2 as httpx
|
|
28
|
+
import jwt
|
|
29
|
+
from mcp.server.mcpserver import MCPServer
|
|
30
|
+
|
|
31
|
+
API = "https://www.googleapis.com/drive/v3"
|
|
32
|
+
SCOPE = "https://www.googleapis.com/auth/drive.readonly"
|
|
33
|
+
EXPORT = {"application/vnd.google-apps.document": "text/plain",
|
|
34
|
+
"application/vnd.google-apps.spreadsheet": "text/csv",
|
|
35
|
+
"application/vnd.google-apps.presentation": "text/plain"}
|
|
36
|
+
FIELDS = "files(id,name,mimeType,modifiedTime,owners(emailAddress,displayName),lastModifyingUser(emailAddress),parents,webViewLink)"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def slug(name: str) -> str:
|
|
40
|
+
return re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Drive:
|
|
44
|
+
"""Minimal Drive v3 client on a service account. No Google SDK: PyJWT + httpx."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, credentials: str | Path):
|
|
47
|
+
self.sa = json.loads(Path(credentials).read_text())
|
|
48
|
+
self._tok, self._exp = None, 0.0
|
|
49
|
+
self.http = httpx.Client(timeout=30)
|
|
50
|
+
|
|
51
|
+
def token(self) -> str:
|
|
52
|
+
if self._tok and time.time() < self._exp - 60:
|
|
53
|
+
return self._tok
|
|
54
|
+
now = int(time.time())
|
|
55
|
+
assertion = jwt.encode({"iss": self.sa["client_email"], "scope": SCOPE, "aud": self.sa["token_uri"],
|
|
56
|
+
"iat": now, "exp": now + 3600}, self.sa["private_key"], algorithm="RS256")
|
|
57
|
+
r = self.http.post(self.sa["token_uri"], data={"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", "assertion": assertion})
|
|
58
|
+
r.raise_for_status()
|
|
59
|
+
self._tok, self._exp = r.json()["access_token"], time.time() + r.json().get("expires_in", 3600)
|
|
60
|
+
return self._tok
|
|
61
|
+
|
|
62
|
+
def get(self, path: str, **params):
|
|
63
|
+
r = self.http.get(API + path, params=params, headers={"Authorization": f"Bearer {self.token()}"})
|
|
64
|
+
r.raise_for_status()
|
|
65
|
+
return r
|
|
66
|
+
|
|
67
|
+
def list(self, q: str, page_size: int = 20) -> list[dict]:
|
|
68
|
+
return self.get("/files", q=q, fields=FIELDS, pageSize=page_size, supportsAllDrives="true",
|
|
69
|
+
includeItemsFromAllDrives="true").json().get("files", [])
|
|
70
|
+
|
|
71
|
+
def folder_by_name(self, name: str) -> dict | None:
|
|
72
|
+
fs = self.list(f"mimeType='application/vnd.google-apps.folder' and name='{name}' and trashed=false", 5)
|
|
73
|
+
return fs[0] if fs else None
|
|
74
|
+
|
|
75
|
+
def subfolders(self, folder_id: str) -> list[dict]:
|
|
76
|
+
return self.list(f"'{folder_id}' in parents and mimeType='application/vnd.google-apps.folder' and trashed=false", 50)
|
|
77
|
+
|
|
78
|
+
def descendants(self, folder_id: str, depth: int = 3) -> set[str]:
|
|
79
|
+
ids, frontier = {folder_id}, [folder_id]
|
|
80
|
+
for _ in range(depth):
|
|
81
|
+
nxt = []
|
|
82
|
+
for f in frontier:
|
|
83
|
+
for sub in self.subfolders(f):
|
|
84
|
+
if sub["id"] not in ids:
|
|
85
|
+
ids.add(sub["id"]); nxt.append(sub["id"])
|
|
86
|
+
frontier = nxt
|
|
87
|
+
return ids
|
|
88
|
+
|
|
89
|
+
def search(self, folder_id: str, query: str) -> list[dict]:
|
|
90
|
+
out = []
|
|
91
|
+
for fid in self.descendants(folder_id):
|
|
92
|
+
q = f"'{fid}' in parents and trashed=false and mimeType!='application/vnd.google-apps.folder'"
|
|
93
|
+
if query.strip():
|
|
94
|
+
safe = query.replace("'", "\\'")
|
|
95
|
+
q += f" and (fullText contains '{safe}' or name contains '{safe}')"
|
|
96
|
+
out += self.list(q, 20)
|
|
97
|
+
return out
|
|
98
|
+
|
|
99
|
+
def read(self, file_id: str, folder_id: str) -> tuple[dict, str]:
|
|
100
|
+
meta = self.get(f"/files/{file_id}", fields="id,name,mimeType,parents,owners(emailAddress,displayName),lastModifyingUser(emailAddress),webViewLink", supportsAllDrives="true").json()
|
|
101
|
+
if not (set(meta.get("parents") or []) & self.descendants(folder_id)):
|
|
102
|
+
raise PermissionError("that file is not in this folder")
|
|
103
|
+
mime = meta.get("mimeType", "")
|
|
104
|
+
if mime in EXPORT:
|
|
105
|
+
text = self.get(f"/files/{file_id}/export", mimeType=EXPORT[mime]).text
|
|
106
|
+
elif mime.startswith("text/") or mime in ("application/json",):
|
|
107
|
+
text = self.get(f"/files/{file_id}", alt="media", supportsAllDrives="true").text
|
|
108
|
+
else:
|
|
109
|
+
text = f"[{mime}: binary file, {meta.get('name')}; not rendered]"
|
|
110
|
+
return meta, text[:20000]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def build(drive: Drive, root_name: str) -> MCPServer:
|
|
114
|
+
server = MCPServer("drive")
|
|
115
|
+
root = drive.folder_by_name(root_name)
|
|
116
|
+
if not root:
|
|
117
|
+
# Stay up so the proxy starts; tell whoever asks what is missing.
|
|
118
|
+
@server.tool(name="status", description="Why no Drive folders are available yet.")
|
|
119
|
+
def status() -> str:
|
|
120
|
+
return json.dumps({"error": f"No folder named {root_name!r} is shared with {drive.sa['client_email']}. "
|
|
121
|
+
"Share it (Viewer) and restart the proxy."})
|
|
122
|
+
return server
|
|
123
|
+
folders = drive.subfolders(root["id"]) or [root]
|
|
124
|
+
|
|
125
|
+
@server.tool(name="folders", description="List the Google Drive folders you can search here. Call this first when asked about documents, files or anything in Google Drive.")
|
|
126
|
+
def folders_tool() -> str:
|
|
127
|
+
return json.dumps({"drive_folders": [{"name": f["name"], "search_tool": f"search_{slug(f['name'])}", "read_tool": f"read_{slug(f['name'])}"} for f in folders]})
|
|
128
|
+
|
|
129
|
+
for f in folders:
|
|
130
|
+
s = slug(f["name"]); fid = f["id"]; label = f["name"]
|
|
131
|
+
|
|
132
|
+
def make(fid=fid, label=label):
|
|
133
|
+
def search(query: str = "") -> str:
|
|
134
|
+
return json.dumps({"folder": label, "files": [
|
|
135
|
+
{"id": x["id"], "name": x["name"], "type": x.get("mimeType"), "modified": x.get("modifiedTime"),
|
|
136
|
+
"owner_email": (x.get("owners") or [{}])[0].get("emailAddress"),
|
|
137
|
+
"editor_email": (x.get("lastModifyingUser") or {}).get("emailAddress"), "link": x.get("webViewLink")}
|
|
138
|
+
for x in drive.search(fid, query)]})
|
|
139
|
+
def read(file_id: str) -> str:
|
|
140
|
+
meta, text = drive.read(file_id, fid)
|
|
141
|
+
return json.dumps({"folder": label, "name": meta["name"], "owner_email": (meta.get("owners") or [{}])[0].get("emailAddress"),
|
|
142
|
+
"editor_email": (meta.get("lastModifyingUser") or {}).get("emailAddress"), "text": text})
|
|
143
|
+
return search, read
|
|
144
|
+
|
|
145
|
+
search, read = make()
|
|
146
|
+
sdesc = f"Search Google Drive for documents in the '{label}' folder, by words in the title or full text. Use this to look for {label.lower()} in Drive; leave the query empty to list everything in the folder."
|
|
147
|
+
rdesc = f"Read a Google Drive document from the '{label}' folder (Docs, Sheets and Slides come back as text)."
|
|
148
|
+
server.tool(name=f"search_{s}", description=sdesc)(search)
|
|
149
|
+
server.tool(name=f"read_{s}", description=rdesc)(read)
|
|
150
|
+
return server
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def main() -> None:
|
|
154
|
+
ap = argparse.ArgumentParser()
|
|
155
|
+
ap.add_argument("--credentials", required=True, help="service-account JSON key")
|
|
156
|
+
ap.add_argument("--root", default="Aggrete", help="name of the shared root folder; its subfolders become tools")
|
|
157
|
+
ap.add_argument("--list", action="store_true", help="print the tools that would be exposed and exit")
|
|
158
|
+
a = ap.parse_args()
|
|
159
|
+
drive = Drive(a.credentials)
|
|
160
|
+
if a.list:
|
|
161
|
+
root = drive.folder_by_name(a.root)
|
|
162
|
+
print("root:", root["name"] if root else None, root["id"] if root else "")
|
|
163
|
+
for f in (drive.subfolders(root["id"]) if root else []):
|
|
164
|
+
print(f" {f['name']!r:32} -> drive__search_{slug(f['name'])}, drive__read_{slug(f['name'])}")
|
|
165
|
+
return
|
|
166
|
+
asyncio.run(build(drive, a.root).run_stdio_async())
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
if __name__ == "__main__":
|
|
170
|
+
main()
|
aggrete/entities.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Pull stable person identifiers out of whatever a connector hands back.
|
|
2
|
+
|
|
3
|
+
This function is where this whole design succeeds or fails. Names are not
|
|
4
|
+
identifiers. Prefer source-system IDs, then email, and treat free text as a
|
|
5
|
+
last resort. Tune `IDENTIFIER_KEYS` against your own connectors before trusting
|
|
6
|
+
any threshold you set in coc.yaml.
|
|
7
|
+
|
|
8
|
+
Linking: one JSON object that carries several identifier fields (e.g. both
|
|
9
|
+
`email` and `employee_id`) is ONE person and yields ONE canonical key. Email is
|
|
10
|
+
preferred as the canonical form because it is the identifier most likely to be
|
|
11
|
+
shared across connectors. Cross-domain overlap (COC-HR-004) only works when
|
|
12
|
+
both sides produce the same key. When a record has no email the first ID field
|
|
13
|
+
found (in `ID_KEYS` order) is used instead.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
|
|
21
|
+
EMAIL_KEYS = ("email", "user_email", "primary_email", "mail", "owner_email", "editor_email", "emailaddress")
|
|
22
|
+
ID_KEYS = ("employee_id", "person_id", "worker_id", "user_id", "assignee_id",
|
|
23
|
+
"owner_id", "sfid", "slack_user_id")
|
|
24
|
+
IDENTIFIER_KEYS = set(EMAIL_KEYS) | set(ID_KEYS)
|
|
25
|
+
EMAIL_RE = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def normalize(value: str) -> str:
|
|
29
|
+
v = str(value).strip().lower()
|
|
30
|
+
return f"p:{v}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _canonical(record: dict) -> str | None:
|
|
34
|
+
"""Return the single canonical key for a record, or None if it has none."""
|
|
35
|
+
lowered = {k.lower(): v for k, v in record.items() if isinstance(v, (str, int))}
|
|
36
|
+
for k in EMAIL_KEYS:
|
|
37
|
+
if k in lowered and str(lowered[k]).strip():
|
|
38
|
+
return normalize(lowered[k])
|
|
39
|
+
for k in ID_KEYS:
|
|
40
|
+
if k in lowered and str(lowered[k]).strip():
|
|
41
|
+
return normalize(lowered[k])
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def from_json(obj, out: set[str]) -> set[str]:
|
|
46
|
+
if isinstance(obj, dict):
|
|
47
|
+
key = _canonical(obj)
|
|
48
|
+
if key:
|
|
49
|
+
out.add(key)
|
|
50
|
+
# Nested objects may describe other people (e.g. "manager": {...}).
|
|
51
|
+
for k, v in obj.items():
|
|
52
|
+
if k.lower() not in IDENTIFIER_KEYS:
|
|
53
|
+
from_json(v, out)
|
|
54
|
+
elif isinstance(obj, list):
|
|
55
|
+
for item in obj:
|
|
56
|
+
from_json(item, out)
|
|
57
|
+
return out
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def extract(text: str) -> list[str]:
|
|
61
|
+
"""Best-effort extraction from one tool result payload."""
|
|
62
|
+
found: set[str] = set()
|
|
63
|
+
try:
|
|
64
|
+
from_json(json.loads(text), found)
|
|
65
|
+
except (ValueError, TypeError):
|
|
66
|
+
pass
|
|
67
|
+
if not found: # fall back to emails in prose
|
|
68
|
+
found |= {normalize(m) for m in EMAIL_RE.findall(text or "")}
|
|
69
|
+
return sorted(found)
|