whoelse 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.
whoelse/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """whoelse: find the people working on what you're working on."""
2
+
3
+ __version__ = "0.1.0"
whoelse/brief.py ADDED
@@ -0,0 +1,168 @@
1
+ """From a match to a concrete thing two people could do together, and the first message.
2
+
3
+ whoelse returns structured evidence and a ranked set of formats. The host model (Claude,
4
+ Cursor, Codex) turns that into prose in the user's own voice; it has the context, we have
5
+ the facts. Nothing here sends anything."""
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections import Counter
10
+ from datetime import date
11
+
12
+ from .models import Asset, Candidate, Profile
13
+
14
+ FORMATS = [
15
+ # (id, label, effort, needs, description)
16
+ ("compare_note", "Comparison note", "days", ["paper"],
17
+ "A short joint write-up comparing your two approaches on the same problem: what each gets right, where they differ, one table. Easy to place on arXiv or a workshop."),
18
+ ("joint_benchmark", "Joint benchmark", "weeks", ["dataset", "repo"],
19
+ "Pool your data or evaluation code into one benchmark with a leaderboard. Benchmarks get cited by everyone who uses them."),
20
+ ("replication", "Cross-replication", "weeks", ["paper", "repo"],
21
+ "Each of you re-runs the other's method on your own data and you publish the result together. Reviewers love it; it costs little new work."),
22
+ ("merge_tools", "Merge tools", "weeks", ["repo"],
23
+ "One of you has half-built what the other needs. Combine into a single repo with both as maintainers."),
24
+ ("shared_dataset", "Shared dataset release", "weeks", ["dataset"],
25
+ "Release one documented dataset on Hugging Face with both names on the card."),
26
+ ("joint_paper", "Joint paper", "months", ["paper"],
27
+ "A full paper. Pick this only after a smaller format worked."),
28
+ ("talk_together", "Talk or podcast together", "days", [],
29
+ "A joint conference proposal, a two-person talk, or a podcast episode where you interview each other. Fastest way to put both names next to each other in public."),
30
+ ("review_each_other", "Review each other's drafts", "days", ["paper"],
31
+ "Exchange drafts before submission. Not a public artefact, but it is how most lasting collaborations start."),
32
+ ]
33
+
34
+
35
+ def _relevant_asset(assets: list[Asset], c: Candidate) -> dict | None:
36
+ """The piece of work that actually overlaps, not merely the newest one."""
37
+ wanted = {k.lower() for k in c.overlap.shared_keywords} | {t.lower() for t in c.overlap.shared_topics}
38
+ if not assets:
39
+ return None
40
+
41
+ def hits(a: Asset) -> int:
42
+ bag = {k.lower() for k in a.keywords} | {t.lower() for t in a.topics} | set((a.title + " " + a.summary).lower().split())
43
+ return sum(1 for w in wanted if w in bag or any(w in x for x in bag))
44
+
45
+ best = max(assets, key=lambda a: (hits(a), a.date or date.min))
46
+ if hits(best) == 0:
47
+ best = max(assets, key=lambda a: a.date or date.min)
48
+ return {"kind": best.kind, "title": best.title, "url": best.url, "date": best.date.isoformat() if best.date else None, "stats": best.stats}
49
+
50
+
51
+ def _top_assets(assets: list[Asset], n: int = 5) -> list[dict]:
52
+ def key(a: Asset) -> tuple:
53
+ s = a.stats
54
+ return (a.date or date.min, s.get("citations", 0) + s.get("stars", 0) + s.get("likes", 0))
55
+
56
+ return [
57
+ {"kind": a.kind, "title": a.title, "url": a.url, "date": a.date.isoformat() if a.date else None, "stats": a.stats}
58
+ for a in sorted(assets, key=key, reverse=True)[:n]
59
+ ]
60
+
61
+
62
+ def _venues(assets: list[Asset], n: int = 5) -> list[str]:
63
+ skip = {"arxiv", "arxiv (cornell university)", "zenodo", "zenodo (cern european organization for nuclear research)", "ssrn", "biorxiv", "medrxiv"}
64
+ c = Counter(a.extra.get("venue", "") for a in assets if a.kind == "paper" and a.extra.get("venue") and a.extra["venue"].lower() not in skip)
65
+ return [v for v, _ in c.most_common(n)]
66
+
67
+
68
+ def joint_work_brief(me: Profile, c: Candidate) -> dict:
69
+ p = c.person
70
+ my_kinds = {a.kind for a in me.assets}
71
+ their_kinds = {a.kind for a in p.assets}
72
+ both = my_kinds | their_kinds
73
+
74
+ runnable = {"repo", "dataset", "model"}
75
+ ranked = []
76
+ for fid, label, effort, needs, desc in FORMATS:
77
+ have = {k for k in needs if k in both}
78
+ fit = len(have) / len(needs) if needs else 1.0 # a format that needs nothing is always possible
79
+ s = fit + (0.35 if effort == "days" else 0.1 if effort == "weeks" else 0.0)
80
+ if fid in ("replication", "joint_benchmark", "merge_tools"):
81
+ # these only work when BOTH sides have something runnable
82
+ if not (my_kinds & runnable and their_kinds & runnable):
83
+ s -= 0.8
84
+ if fid == "shared_dataset" and "dataset" not in their_kinds and "dataset" not in my_kinds:
85
+ s -= 0.8
86
+ if fid in ("compare_note", "joint_paper", "review_each_other") and "paper" not in their_kinds:
87
+ s -= 0.5 # they have no paper to compare or review
88
+ # reward using what only one side has: that's the reason to collaborate at all
89
+ if any(k in c.overlap.they_have or k in c.overlap.you_have for k in needs):
90
+ s += 0.4
91
+ if fit == 0:
92
+ s -= 0.6
93
+ if p.card and p.card.open_to:
94
+ wants = " ".join(p.card.open_to).lower()
95
+ if label.lower() in wants or fid.replace("_", " ") in wants:
96
+ s += 0.7 # they said so themselves
97
+ elif fid == "joint_paper" and "co-author" in wants:
98
+ s += 0.3 # welcome, but a full paper still comes after a smaller first step
99
+ ranked.append((round(s, 2), fid, label, effort, desc))
100
+ ranked.sort(key=lambda x: -x[0])
101
+
102
+ return {
103
+ "you": {"name": me.name, "top_topics": list(me.topics)[:5], "assets": _top_assets(me.assets), "venues": _venues(me.assets)},
104
+ "them": {
105
+ "name": p.name,
106
+ "affiliation": p.affiliation,
107
+ "ids": p.ids,
108
+ "last_active": p.last_active.isoformat() if p.last_active else None,
109
+ "top_topics": list(p.topics)[:5],
110
+ "assets": _top_assets(p.assets),
111
+ "venues": _venues(p.assets),
112
+ "card": p.card.model_dump(mode="json") if p.card else None,
113
+ },
114
+ "overlap": c.overlap.model_dump(),
115
+ "why": c.why,
116
+ "formats": [
117
+ {"id": fid, "label": label, "effort": effort, "fit": s, "what": desc} for s, fid, label, effort, desc in ranked[:5]
118
+ ],
119
+ "venues_in_common": sorted(set(_venues(me.assets)) & set(_venues(p.assets))),
120
+ "how_to_reach": {k: v for k, v in p.contact.model_dump().items() if v and k != "provenance"},
121
+ "contact_provenance": p.contact.provenance,
122
+ "guidance": (
123
+ "Write the proposal in the user's own voice, under 180 words. Name one concrete artefact from each side, "
124
+ "state the format in one sentence, say what each person brings, and end with a single yes/no question. "
125
+ "No flattery, no résumé, no call invitation unless the user asks. If the person publishes a whoelse card, "
126
+ "respect its 'open_to' list and prefer its contact."
127
+ ),
128
+ }
129
+
130
+
131
+ def outreach_skeleton(me: Profile, c: Candidate, format_id: str | None = None) -> dict:
132
+ p = c.person
133
+ fmt = next((f for f in FORMATS if f[0] == format_id), None) if format_id else None
134
+ mine = _relevant_asset(me.assets, c)
135
+ theirs = _relevant_asset(p.assets, c)
136
+ from .match import GENERIC
137
+ sharp = [k for k in c.overlap.shared_keywords if k not in GENERIC][:1] or c.overlap.shared_topics[:1]
138
+ subject = f"{(fmt[1] if fmt else 'Working on the same thing')}: {', '.join(sharp)}".strip(": ")
139
+ to, to_prov = None, None
140
+ if p.card and p.card.contact:
141
+ to, to_prov = p.card.contact, "whoelse card on their own site"
142
+ elif p.contact.email:
143
+ to, to_prov = p.contact.email, p.contact.provenance.get("email", "")
144
+ return {
145
+ "to": to,
146
+ "to_provenance": to_prov,
147
+ "to_fallbacks": [u for u in (p.contact.website, p.contact.github, p.contact.huggingface, p.contact.linkedin, p.contact.orcid) if u],
148
+ "subject": subject,
149
+ "facts": {
150
+ "their_work": theirs,
151
+ "my_work": mine,
152
+ "shared": [k for k in c.overlap.shared_keywords if k not in GENERIC][:3] or c.overlap.shared_topics[:2],
153
+ "format": {"id": fmt[0], "label": fmt[1], "what": fmt[4]} if fmt else None,
154
+ "they_have": c.overlap.they_have,
155
+ "you_have": c.overlap.you_have,
156
+ },
157
+ "structure": [
158
+ "1. One sentence: what of theirs you read and the one thing in it that matters to you.",
159
+ "2. One sentence: what you did on the same problem, with the link.",
160
+ "3. Two sentences: the format and what each of you brings.",
161
+ "4. One question they can answer with yes or no.",
162
+ ],
163
+ "rules": [
164
+ "Send from your own address, one person at a time. The user decides who gets it.",
165
+ "Use only contacts the person made public themselves (see contact_provenance).",
166
+ "Do not mention this tool. Do not flatter. Do not propose a call unless asked.",
167
+ ],
168
+ }
whoelse/config.py ADDED
@@ -0,0 +1,65 @@
1
+ """Runtime configuration. Everything lives in one local folder; nothing leaves the machine
2
+ except the API calls to public, open data sources."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+
11
+ def _default_home() -> Path:
12
+ env = os.environ.get("WHOELSE_HOME")
13
+ if env:
14
+ return Path(env).expanduser()
15
+ return Path.home() / ".whoelse"
16
+
17
+
18
+ @dataclass
19
+ class Settings:
20
+ home: Path = field(default_factory=_default_home)
21
+ # OpenAlex "polite pool": an email in the request gets you faster, more reliable responses.
22
+ # It is only ever sent to api.openalex.org. Leave empty to stay anonymous.
23
+ mailto: str = field(default_factory=lambda: os.environ.get("WHOELSE_MAILTO", ""))
24
+ # OpenAlex API key: without one, requests share a small per-IP daily budget. Free at https://openalex.org/settings/api
25
+ openalex_key: str = field(default_factory=lambda: os.environ.get("WHOELSE_OPENALEX_KEY", ""))
26
+ # Optional GitHub token raises the search rate limit from 10 to 30 requests/min.
27
+ github_token: str = field(default_factory=lambda: os.environ.get("GITHUB_TOKEN", ""))
28
+ # How far back "working on it now" reaches, in months.
29
+ recent_months: int = field(default_factory=lambda: int(os.environ.get("WHOELSE_RECENT_MONTHS", "24")))
30
+ # Cache TTL for HTTP responses, in hours.
31
+ cache_hours: int = field(default_factory=lambda: int(os.environ.get("WHOELSE_CACHE_HOURS", "72")))
32
+ # When set, the HTTP layer answers from this folder of recorded responses and never touches the network.
33
+ fixtures_dir: Path | None = field(
34
+ default_factory=lambda: Path(os.environ["WHOELSE_FIXTURES"]) if os.environ.get("WHOELSE_FIXTURES") else None
35
+ )
36
+ user_agent: str = "whoelse/0.1 (+https://github.com/beskvladimir-create/whoelse)"
37
+
38
+ @property
39
+ def cache_dir(self) -> Path:
40
+ return self.home / "cache"
41
+
42
+ @property
43
+ def profiles_dir(self) -> Path:
44
+ return self.home / "profiles"
45
+
46
+ def ensure(self) -> "Settings":
47
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
48
+ self.profiles_dir.mkdir(parents=True, exist_ok=True)
49
+ return self
50
+
51
+
52
+ _settings: Settings | None = None
53
+
54
+
55
+ def settings() -> Settings:
56
+ global _settings
57
+ if _settings is None:
58
+ _settings = Settings().ensure()
59
+ return _settings
60
+
61
+
62
+ def reset_settings(new: Settings | None = None) -> None:
63
+ """Used by tests."""
64
+ global _settings
65
+ _settings = new.ensure() if new else None