stage-cli 1.0.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.
Files changed (170) hide show
  1. stage/__init__.py +1 -0
  2. stage/__main__.py +8 -0
  3. stage/banner.py +32 -0
  4. stage/bootstrap/__init__.py +0 -0
  5. stage/bootstrap/openjobs.py +392 -0
  6. stage/classify/__init__.py +29 -0
  7. stage/classify/eligibility.py +115 -0
  8. stage/classify/internship.py +64 -0
  9. stage/classify/role.py +91 -0
  10. stage/classify/scope.py +47 -0
  11. stage/cli/__init__.py +0 -0
  12. stage/cli/app.py +4 -0
  13. stage/cli/commands/__init__.py +8 -0
  14. stage/cli/commands/discovery.py +294 -0
  15. stage/cli/commands/insight.py +494 -0
  16. stage/cli/commands/pipeline.py +337 -0
  17. stage/cli/commands/postings.py +473 -0
  18. stage/cli/commands/schedule.py +171 -0
  19. stage/cli/housekeeping.py +64 -0
  20. stage/cli/logfile.py +56 -0
  21. stage/cli/notify.py +170 -0
  22. stage/cli/options.py +678 -0
  23. stage/cli/render.py +1398 -0
  24. stage/cli/runlock.py +74 -0
  25. stage/cli/schedule.py +702 -0
  26. stage/cli/schedule_state.py +363 -0
  27. stage/cli/selection.py +83 -0
  28. stage/cli/serialize.py +196 -0
  29. stage/companies.py +542 -0
  30. stage/data/companies/a.yaml +1289 -0
  31. stage/data/companies/b.yaml +900 -0
  32. stage/data/companies/c.yaml +1377 -0
  33. stage/data/companies/d.yaml +497 -0
  34. stage/data/companies/e.yaml +519 -0
  35. stage/data/companies/f.yaml +454 -0
  36. stage/data/companies/g.yaml +601 -0
  37. stage/data/companies/h.yaml +446 -0
  38. stage/data/companies/i.yaml +503 -0
  39. stage/data/companies/j.yaml +138 -0
  40. stage/data/companies/k.yaml +278 -0
  41. stage/data/companies/l.yaml +402 -0
  42. stage/data/companies/m.yaml +937 -0
  43. stage/data/companies/n.yaml +549 -0
  44. stage/data/companies/o.yaml +371 -0
  45. stage/data/companies/other.yaml +58 -0
  46. stage/data/companies/p.yaml +825 -0
  47. stage/data/companies/q.yaml +121 -0
  48. stage/data/companies/r.yaml +583 -0
  49. stage/data/companies/s.yaml +1140 -0
  50. stage/data/companies/t.yaml +817 -0
  51. stage/data/companies/u.yaml +196 -0
  52. stage/data/companies/v.yaml +325 -0
  53. stage/data/companies/w.yaml +353 -0
  54. stage/data/companies/x.yaml +67 -0
  55. stage/data/companies/y.yaml +36 -0
  56. stage/data/companies/z.yaml +146 -0
  57. stage/data/fonts/DejaVuSans.LICENSE.txt +99 -0
  58. stage/data/fonts/DejaVuSans.ttf +0 -0
  59. stage/data/lexicon/company_tokens.yaml +228 -0
  60. stage/data/lexicon/eligibility.yaml +455 -0
  61. stage/data/lexicon/inclusive_suffixes.yaml +37 -0
  62. stage/data/lexicon/internship.yaml +187 -0
  63. stage/data/lexicon/language.yaml +226 -0
  64. stage/data/lexicon/locations.yaml +1159 -0
  65. stage/data/lexicon/roles.yaml +2012 -0
  66. stage/data/lexicon/terms.yaml +76 -0
  67. stage/data/lexicon/workday_facets.yaml +27 -0
  68. stage/data/seed_companies.yaml +198 -0
  69. stage/dedup/__init__.py +19 -0
  70. stage/dedup/identity.py +113 -0
  71. stage/dedup/resolve.py +97 -0
  72. stage/domain/__init__.py +244 -0
  73. stage/domain/company.py +49 -0
  74. stage/domain/coverage.py +86 -0
  75. stage/domain/custom_board.py +92 -0
  76. stage/domain/discovery.py +94 -0
  77. stage/domain/enums.py +114 -0
  78. stage/domain/events.py +204 -0
  79. stage/domain/filters.py +27 -0
  80. stage/domain/health.py +169 -0
  81. stage/domain/ids.py +48 -0
  82. stage/domain/job.py +47 -0
  83. stage/domain/matching.py +15 -0
  84. stage/domain/priority.py +34 -0
  85. stage/domain/quarantine.py +39 -0
  86. stage/domain/rate_state.py +78 -0
  87. stage/domain/retention.py +20 -0
  88. stage/domain/rotation.py +46 -0
  89. stage/domain/signals.py +12 -0
  90. stage/domain/sync_run.py +35 -0
  91. stage/domain/text.py +113 -0
  92. stage/domain/validator.py +14 -0
  93. stage/domain/visits.py +60 -0
  94. stage/domain/workday.py +38 -0
  95. stage/http/__init__.py +58 -0
  96. stage/http/breaker.py +53 -0
  97. stage/http/cache.py +44 -0
  98. stage/http/client.py +725 -0
  99. stage/http/profiles.py +101 -0
  100. stage/lexicon.py +370 -0
  101. stage/normalize/__init__.py +16 -0
  102. stage/normalize/language.py +47 -0
  103. stage/normalize/location.py +271 -0
  104. stage/normalize/terms.py +153 -0
  105. stage/normalize/urls.py +122 -0
  106. stage/paths.py +86 -0
  107. stage/py.typed +0 -0
  108. stage/services/__init__.py +0 -0
  109. stage/services/canary.py +120 -0
  110. stage/services/coverage.py +231 -0
  111. stage/services/discover.py +747 -0
  112. stage/services/export.py +274 -0
  113. stage/services/health.py +237 -0
  114. stage/services/maintenance.py +225 -0
  115. stage/services/quarantine.py +20 -0
  116. stage/services/query.py +86 -0
  117. stage/services/sync.py +1257 -0
  118. stage/sources/__init__.py +82 -0
  119. stage/sources/_text.py +79 -0
  120. stage/sources/ashby.py +93 -0
  121. stage/sources/bamboohr.py +80 -0
  122. stage/sources/base.py +225 -0
  123. stage/sources/breezy.py +90 -0
  124. stage/sources/collage.py +60 -0
  125. stage/sources/community_feeds.py +142 -0
  126. stage/sources/curated_markdown.py +289 -0
  127. stage/sources/custom_json.py +610 -0
  128. stage/sources/espresso.py +154 -0
  129. stage/sources/feed.py +44 -0
  130. stage/sources/greenhouse.py +104 -0
  131. stage/sources/jobbank.py +147 -0
  132. stage/sources/jobvite.py +133 -0
  133. stage/sources/lever.py +76 -0
  134. stage/sources/oracle_cloud.py +187 -0
  135. stage/sources/platforms.py +609 -0
  136. stage/sources/quebec_emploi.py +146 -0
  137. stage/sources/recruitee.py +96 -0
  138. stage/sources/simplify.py +110 -0
  139. stage/sources/smartrecruiters.py +216 -0
  140. stage/sources/speedyapply.py +200 -0
  141. stage/sources/themuse.py +157 -0
  142. stage/sources/workable.py +83 -0
  143. stage/sources/workday.py +524 -0
  144. stage/sources/zshah.py +99 -0
  145. stage/storage/__init__.py +29 -0
  146. stage/storage/migrations/0001_initial.sql +239 -0
  147. stage/storage/migrations/__init__.py +135 -0
  148. stage/storage/repository.py +213 -0
  149. stage/storage/search.py +28 -0
  150. stage/storage/sqlite_repo.py +1586 -0
  151. stage/storage/writer.py +249 -0
  152. stage/tui/__init__.py +0 -0
  153. stage/tui/app.py +82 -0
  154. stage/tui/help.py +26 -0
  155. stage/tui/safe.py +21 -0
  156. stage/tui/screens/__init__.py +0 -0
  157. stage/tui/screens/boards.py +186 -0
  158. stage/tui/screens/postings.py +509 -0
  159. stage/tui/screens/review.py +209 -0
  160. stage/tui/screens/splash.py +37 -0
  161. stage/tui/screens/stats.py +124 -0
  162. stage/tui/screens/sync.py +194 -0
  163. stage/tui/state.py +160 -0
  164. stage/tui/theme.tcss +205 -0
  165. stage/tui/widgets/__init__.py +0 -0
  166. stage_cli-1.0.0.dist-info/METADATA +379 -0
  167. stage_cli-1.0.0.dist-info/RECORD +170 -0
  168. stage_cli-1.0.0.dist-info/WHEEL +4 -0
  169. stage_cli-1.0.0.dist-info/entry_points.txt +2 -0
  170. stage_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
stage/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
stage/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ def main() -> None:
2
+ from stage.cli.app import main as app_main
3
+
4
+ app_main()
5
+
6
+
7
+ if __name__ == "__main__":
8
+ main()
stage/banner.py ADDED
@@ -0,0 +1,32 @@
1
+ WIDE = r"""
2
+ ____ _
3
+ / ___|| |_ __ _ __ _ ___
4
+ \___ \| __| / _` | / _` | / _ \
5
+ ___) | |_ | (_| || (_| || __/
6
+ |____/ \__| \__,_| \__, | \___|
7
+ |___/"""
8
+
9
+ COMPACT = r"""
10
+ ___ _
11
+ / __| |_ __ _ __ _ ___
12
+ \__ \ _/ _` / _` / -_)
13
+ |___/\__\__,_\__, \___|
14
+ |___/"""
15
+
16
+ MIN_WIDE = 34
17
+
18
+
19
+ def block(art: str) -> str:
20
+ lines = art.strip("\n").split("\n")
21
+ width = max(len(line) for line in lines)
22
+ return "\n".join(line.ljust(width) for line in lines)
23
+
24
+
25
+ def banner(width: int) -> str:
26
+ return block(WIDE if width >= MIN_WIDE else COMPACT)
27
+
28
+
29
+ ACCENT = "default"
30
+
31
+
32
+ __all__ = ["ACCENT", "COMPACT", "MIN_WIDE", "WIDE", "banner", "block"]
File without changes
@@ -0,0 +1,392 @@
1
+ import argparse
2
+ import json
3
+ import sys
4
+ from collections import Counter, defaultdict
5
+ from collections.abc import Iterable, Sequence
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import yaml
11
+
12
+ from stage.domain import Platform, PlatformCandidate
13
+ from stage.lexicon import division_qualifiers, fold, name_root_tokens
14
+ from stage.sources.platforms import identify_url
15
+
16
+ SEED_PATH = Path(__file__).resolve().parents[1] / "data" / "seed_companies.yaml"
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class Seed:
21
+ name: str
22
+ section: str = "other"
23
+ note: str | None = None
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class DatasetEntry:
28
+ name: str
29
+ website: str = ""
30
+ ats_links: tuple[str, ...] = ()
31
+ countries: tuple[str, ...] = ()
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class Resolution:
36
+ seed: Seed
37
+ entry: DatasetEntry
38
+ candidate: PlatformCandidate
39
+ display_name: str
40
+ related: bool = False
41
+
42
+
43
+ @dataclass(slots=True)
44
+ class CrossReference:
45
+ resolved: list[Resolution] = field(default_factory=list)
46
+ no_ats_link: list[Seed] = field(default_factory=list)
47
+ unmatched: list[Seed] = field(default_factory=list)
48
+ unrecognized: list[tuple[Seed, str]] = field(default_factory=list)
49
+ collisions: dict[str, list[str]] = field(default_factory=dict)
50
+ name_collisions: list[tuple[Seed, str]] = field(default_factory=list)
51
+
52
+ def platform_histogram(self) -> list[tuple[Platform, int]]:
53
+ counted = Counter(item.candidate.platform for item in self.resolved)
54
+ return sorted(counted.items(), key=lambda pair: (-pair[1], pair[0].value))
55
+
56
+
57
+ def match_key(name: str) -> str:
58
+ return "".join(name_root_tokens(name))
59
+
60
+
61
+ def recognized_boards(entry: DatasetEntry) -> tuple[PlatformCandidate, ...]:
62
+ found: list[PlatformCandidate] = []
63
+ seen: set[str] = set()
64
+ for link in entry.ats_links:
65
+ candidate = identify_url(link)
66
+ if candidate is None or candidate.label in seen:
67
+ continue
68
+ seen.add(candidate.label)
69
+ found.append(candidate)
70
+ return tuple(found)
71
+
72
+
73
+ def _prefix_index(entries: Sequence[DatasetEntry]) -> dict[tuple[str, ...], list[DatasetEntry]]:
74
+ index: defaultdict[tuple[str, ...], list[DatasetEntry]] = defaultdict(list)
75
+ for entry in entries:
76
+ tokens = name_root_tokens(entry.name)
77
+ for size in range(1, len(tokens)):
78
+ index[tokens[:size]].append(entry)
79
+ return index
80
+
81
+
82
+ def is_division_of(
83
+ parent: str, candidate_name: str, independent: frozenset[str] = frozenset()
84
+ ) -> bool:
85
+ if match_key(candidate_name) in independent:
86
+ return False
87
+ root = name_root_tokens(parent)
88
+ tokens = name_root_tokens(candidate_name)
89
+ if len(tokens) <= len(root) or tokens[: len(root)] != root:
90
+ return False
91
+ qualifiers = division_qualifiers()
92
+ return all(token in qualifiers for token in tokens[len(root) :])
93
+
94
+
95
+ def _same_family(names: Sequence[str], independent: frozenset[str] = frozenset()) -> bool:
96
+ if sum(1 for name in names if match_key(name) in independent) > 1:
97
+ return False
98
+ shortest = min(names, key=len)
99
+ root = name_root_tokens(shortest)
100
+ return all(name_root_tokens(name)[: len(root)] == root for name in names)
101
+
102
+
103
+ def load_seeds(path: Path = SEED_PATH) -> tuple[Seed, ...]:
104
+ raw = yaml.safe_load(path.read_text(encoding="utf-8"))
105
+ if not isinstance(raw, list):
106
+ raise ValueError(f"{path} must contain a list of seed entries")
107
+ seeds: list[Seed] = []
108
+ for row in raw:
109
+ if not isinstance(row, dict) or not isinstance(row.get("name"), str):
110
+ raise ValueError(f"{path}: every entry needs a string 'name'")
111
+ note = row.get("note")
112
+ seeds.append(
113
+ Seed(
114
+ name=row["name"],
115
+ section=str(row.get("section", "other")),
116
+ note=str(note) if note is not None else None,
117
+ )
118
+ )
119
+ return tuple(seeds)
120
+
121
+
122
+ def _strings(value: Any) -> tuple[str, ...]:
123
+ if isinstance(value, str):
124
+ return (value,)
125
+ if isinstance(value, list):
126
+ return tuple(item for item in value if isinstance(item, str))
127
+ if isinstance(value, dict):
128
+ return tuple(item for item in value.values() if isinstance(item, str))
129
+ return ()
130
+
131
+
132
+ def load_dataset(path: Path) -> tuple[DatasetEntry, ...]:
133
+ payload = json.loads(path.read_text(encoding="utf-8"))
134
+ rows: Iterable[Any]
135
+ if isinstance(payload, dict):
136
+ rows = next((value for value in payload.values() if isinstance(value, list)), [])
137
+ elif isinstance(payload, list):
138
+ rows = payload
139
+ else:
140
+ raise ValueError(f"{path}: expected a JSON list or an object wrapping one")
141
+
142
+ entries: list[DatasetEntry] = []
143
+ for row in rows:
144
+ if not isinstance(row, dict):
145
+ continue
146
+ name = row.get("name")
147
+ if not isinstance(name, str) or not name.strip():
148
+ continue
149
+ website = row.get("website")
150
+ entries.append(
151
+ DatasetEntry(
152
+ name=name.strip(),
153
+ website=website if isinstance(website, str) else "",
154
+ ats_links=_strings(row.get("ats_links")),
155
+ countries=_strings(row.get("countries")),
156
+ )
157
+ )
158
+ return tuple(entries)
159
+
160
+
161
+ def crossref(seeds: Sequence[Seed], entries: Sequence[DatasetEntry]) -> CrossReference:
162
+ exact: dict[str, DatasetEntry] = {}
163
+ for entry in entries:
164
+ exact.setdefault(match_key(entry.name), entry)
165
+ related_index = _prefix_index(entries)
166
+ independent = frozenset(match_key(seed.name) for seed in seeds)
167
+
168
+ report = CrossReference()
169
+ claims: defaultdict[str, list[str]] = defaultdict(list)
170
+ pending: list[Resolution] = []
171
+
172
+ for seed in seeds:
173
+ primary = exact.get(match_key(seed.name))
174
+ family: list[tuple[DatasetEntry, bool]] = []
175
+ if primary is not None:
176
+ family.append((primary, False))
177
+ for entry in related_index.get(name_root_tokens(seed.name), ()):
178
+ if primary is not None and match_key(entry.name) == match_key(primary.name):
179
+ continue
180
+ if not is_division_of(seed.name, entry.name, independent):
181
+ report.name_collisions.append((seed, entry.name))
182
+ continue
183
+ family.append((entry, True))
184
+
185
+ if not family:
186
+ report.unmatched.append(seed)
187
+ continue
188
+
189
+ for entry, related in family:
190
+ if not entry.ats_links:
191
+ if not related:
192
+ report.no_ats_link.append(seed)
193
+ continue
194
+ candidates = recognized_boards(entry)
195
+ if not candidates:
196
+ if not related:
197
+ report.unrecognized.append((seed, entry.ats_links[0]))
198
+ continue
199
+ for candidate in candidates:
200
+ claims[candidate.label].append(entry.name)
201
+ pending.append(
202
+ Resolution(
203
+ seed=seed,
204
+ entry=entry,
205
+ candidate=candidate,
206
+ display_name=entry.name,
207
+ related=related,
208
+ )
209
+ )
210
+
211
+ contested: set[str] = set()
212
+ for label, names in claims.items():
213
+ unique = sorted(set(names))
214
+ if len(unique) <= 1 or _same_family(unique, independent):
215
+ continue
216
+ report.collisions[label] = unique
217
+ contested.add(label)
218
+
219
+ kept: dict[str, Resolution] = {}
220
+ for item in pending:
221
+ if item.candidate.label in contested:
222
+ continue
223
+ current = kept.get(item.candidate.label)
224
+ if current is None or (current.related and not item.related):
225
+ kept[item.candidate.label] = item
226
+ report.resolved = list(kept.values())
227
+ return report
228
+
229
+
230
+ def to_registry_rows(report: CrossReference) -> str:
231
+ from stage.companies import registry_entry_yaml
232
+ from stage.domain import Company, SourceOfRecord
233
+ from stage.services.discover import is_routable
234
+
235
+ boards_per_entry = Counter(item.entry.name for item in report.resolved)
236
+ blocks = []
237
+ for item in sorted(report.resolved, key=lambda entry: entry.display_name.lower()):
238
+ routable = is_routable(item.candidate.platform)
239
+ siblings = boards_per_entry[item.entry.name]
240
+ contested = siblings > 1
241
+ routable = routable and not contested
242
+ blocks.append(
243
+ registry_entry_yaml(
244
+ Company(
245
+ name=item.display_name,
246
+ platform=item.candidate.platform,
247
+ slug=item.candidate.slug,
248
+ enabled=routable,
249
+ source_of_record=SourceOfRecord.OPENJOBS,
250
+ workday_tenant=item.candidate.workday_tenant,
251
+ workday_site=item.candidate.workday_site,
252
+ workday_dc=item.candidate.workday_dc,
253
+ oracle_host=item.candidate.oracle_host,
254
+ oracle_site=item.candidate.oracle_site,
255
+ )
256
+ )
257
+ )
258
+ return "\n".join(blocks) + ("\n" if blocks else "")
259
+
260
+
261
+ def mine_country(
262
+ entries: Sequence[DatasetEntry], country: str, known: frozenset[str] = frozenset()
263
+ ) -> list[tuple[DatasetEntry, PlatformCandidate]]:
264
+ wanted = fold(country)
265
+ found: list[tuple[DatasetEntry, PlatformCandidate]] = []
266
+ seen: set[str] = set()
267
+ for entry in entries:
268
+ if not any(fold(value) == wanted for value in entry.countries):
269
+ continue
270
+ boards = recognized_boards(entry)
271
+ if len(boards) != 1:
272
+ continue
273
+ candidate = boards[0]
274
+ if candidate.label in known or candidate.label in seen:
275
+ continue
276
+ seen.add(candidate.label)
277
+ found.append((entry, candidate))
278
+ return sorted(found, key=lambda pair: pair[0].name.lower())
279
+
280
+
281
+ def mined_registry_rows(found: Sequence[tuple[DatasetEntry, PlatformCandidate]]) -> str:
282
+ from stage.companies import registry_entry_yaml
283
+ from stage.domain import Company, SourceOfRecord
284
+ from stage.services.discover import is_routable
285
+
286
+ blocks = []
287
+ for entry, candidate in found:
288
+ routable = is_routable(candidate.platform)
289
+ blocks.append(
290
+ registry_entry_yaml(
291
+ Company(
292
+ name=entry.name,
293
+ platform=candidate.platform,
294
+ slug=candidate.slug,
295
+ enabled=routable,
296
+ source_of_record=SourceOfRecord.OPENJOBS,
297
+ workday_tenant=candidate.workday_tenant,
298
+ workday_site=candidate.workday_site,
299
+ workday_dc=candidate.workday_dc,
300
+ oracle_host=candidate.oracle_host,
301
+ oracle_site=candidate.oracle_site,
302
+ )
303
+ )
304
+ )
305
+ return "\n".join(blocks) + ("\n" if blocks else "")
306
+
307
+
308
+ def format_report(report: CrossReference, total_seeds: int) -> str:
309
+ divisions = [item for item in report.resolved if item.related]
310
+ lines = [
311
+ f"seeds: {total_seeds}",
312
+ f"resolved to a platform: {len(report.resolved)} board(s)",
313
+ f" of which division boards: {len(divisions)}",
314
+ f"matched but no ats_links: {len(report.no_ats_link)} -> stage discover",
315
+ f"ats_links present but unrecognized: {len(report.unrecognized)} -> custom_json / feeds",
316
+ f"absent from the dataset: {len(report.unmatched)} -> stage discover",
317
+ "",
318
+ "platform hits",
319
+ ]
320
+ lines.extend(f"{platform.value:<17} {count}" for platform, count in report.platform_histogram())
321
+ if report.collisions:
322
+ lines.append("")
323
+ lines.append("collisions (one board claimed by several seeds — excluded, review by hand):")
324
+ lines.extend(
325
+ f" {label}: {', '.join(names)}" for label, names in sorted(report.collisions.items())
326
+ )
327
+ if divisions:
328
+ lines.append("")
329
+ lines.append("division boards found:")
330
+ lines.extend(
331
+ f" {item.display_name} ({item.candidate.label}) under {item.seed.name}"
332
+ for item in sorted(divisions, key=lambda entry: entry.display_name.lower())
333
+ )
334
+ if report.name_collisions:
335
+ lines.append("")
336
+ lines.append("rejected as name collisions, not divisions:")
337
+ lines.extend(
338
+ f" {name} (looks like {seed.name} but the extra tokens are not qualifiers)"
339
+ for seed, name in sorted(report.name_collisions, key=lambda pair: pair[1].lower())
340
+ )
341
+ if report.unmatched:
342
+ lines.append("")
343
+ lines.append("route to `stage discover --url`:")
344
+ lines.extend(f" {seed.name}" for seed in report.unmatched)
345
+ return "\n".join(lines)
346
+
347
+
348
+ def main(argv: Sequence[str] | None = None) -> int:
349
+ parser = argparse.ArgumentParser(
350
+ description="Cross-reference seeds against the OpenJobs dataset",
351
+ )
352
+ parser.add_argument("--dataset", type=Path, required=True, help="Local companies_v2.json")
353
+ parser.add_argument("--seeds", type=Path, default=SEED_PATH)
354
+ parser.add_argument("--emit-yaml", type=Path, help="Emit candidate rows here")
355
+ parser.add_argument(
356
+ "--mine-country",
357
+ help="Also mine this country",
358
+ )
359
+ args = parser.parse_args(argv)
360
+
361
+ seeds = load_seeds(args.seeds)
362
+ entries = load_dataset(args.dataset)
363
+ report = crossref(seeds, entries)
364
+
365
+ mined: list[tuple[DatasetEntry, PlatformCandidate]] = []
366
+ if args.mine_country:
367
+ from stage.companies import RegistryError, board_label, load_companies
368
+
369
+ known = {item.candidate.label for item in report.resolved}
370
+ try:
371
+ known |= {board_label(company) for company in load_companies()}
372
+ except RegistryError as exc:
373
+ sys.stdout.write(f"\nregistry unreadable, mining without it: {exc}\n")
374
+ mined = mine_country(entries, args.mine_country, frozenset(known))
375
+ sys.stdout.write(
376
+ f"\nmined {len(mined)} new {args.mine_country} board(s) not already known\n"
377
+ )
378
+
379
+ sys.stdout.write(format_report(report, len(seeds)) + "\n")
380
+ if args.emit_yaml is not None:
381
+ args.emit_yaml.write_text(
382
+ to_registry_rows(report) + mined_registry_rows(mined), encoding="utf-8"
383
+ )
384
+ sys.stdout.write(
385
+ f"\nwrote {len(report.resolved) + len(mined)} candidate row(s) to {args.emit_yaml}\n"
386
+ "Review before merging into src/stage/data/companies/ — ats_links go stale.\n"
387
+ )
388
+ return 0
389
+
390
+
391
+ if __name__ == "__main__":
392
+ raise SystemExit(main())
@@ -0,0 +1,29 @@
1
+ from stage.classify.eligibility import (
2
+ EligibilityVerdict,
3
+ resolve_eligibility,
4
+ screen_degree_scope,
5
+ screen_is_cs_role,
6
+ )
7
+ from stage.classify.internship import InternshipVerdict, screen_internship
8
+ from stage.classify.role import RoleVerdict, classify_role
9
+ from stage.classify.scope import (
10
+ Rejection,
11
+ screen_is_internship,
12
+ screen_location,
13
+ to_quarantined,
14
+ )
15
+
16
+ __all__ = [
17
+ "EligibilityVerdict",
18
+ "resolve_eligibility",
19
+ "screen_degree_scope",
20
+ "screen_is_cs_role",
21
+ "InternshipVerdict",
22
+ "Rejection",
23
+ "RoleVerdict",
24
+ "classify_role",
25
+ "screen_internship",
26
+ "screen_is_internship",
27
+ "screen_location",
28
+ "to_quarantined",
29
+ ]
@@ -0,0 +1,115 @@
1
+ from dataclasses import dataclass
2
+
3
+ from stage.classify.role import classify_role
4
+ from stage.classify.scope import Rejection
5
+ from stage.domain import DegreeRequirement, Job, RejectionReason
6
+ from stage.lexicon import eligibility_lexicon, fold
7
+
8
+ _ORDER = ("phd", "masters", "bachelors")
9
+ _CLAUSE = 60
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class EligibilityVerdict:
14
+ degree_requirement: DegreeRequirement
15
+ work_auth_flag: bool
16
+ matched_phrase: str = ""
17
+ work_auth_phrase: str = ""
18
+
19
+
20
+ def _ranked(phrases: frozenset[str]) -> list[str]:
21
+ return sorted(phrases, key=lambda phrase: (-len(phrase), phrase))
22
+
23
+
24
+ def _hit(haystack: str, phrases: frozenset[str]) -> str:
25
+ padded = f" {haystack} "
26
+ for phrase in _ranked(phrases):
27
+ if f" {phrase} " in padded:
28
+ return phrase
29
+ return ""
30
+
31
+
32
+ def resolve_eligibility(job: Job) -> EligibilityVerdict:
33
+ lexicon = eligibility_lexicon()
34
+ body = fold(f"{job.title_raw} {job.description}")
35
+
36
+ degree = DegreeRequirement.UNKNOWN
37
+ matched = ""
38
+ for level in _ORDER:
39
+ phrase = _hit(body, lexicon.degree_required.get(level, frozenset()))
40
+ if phrase:
41
+ degree = DegreeRequirement(level)
42
+ matched = phrase
43
+ break
44
+
45
+ excluded = _hit(body, lexicon.work_auth_excluded)
46
+ return EligibilityVerdict(
47
+ degree_requirement=degree,
48
+ work_auth_flag=bool(excluded),
49
+ matched_phrase=matched or excluded,
50
+ work_auth_phrase=excluded,
51
+ )
52
+
53
+
54
+ def _restricted_hit(haystack: str, phrases: frozenset[str], alternatives: frozenset[str]) -> str:
55
+ padded = f" {haystack} "
56
+ for phrase in _ranked(phrases):
57
+ needle = f" {phrase} "
58
+ start = padded.find(needle)
59
+ while start >= 0:
60
+ window = padded[max(0, start - _CLAUSE) : start + len(needle) + _CLAUSE]
61
+ if not _hit(window.strip(), alternatives):
62
+ return phrase
63
+ start = padded.find(needle, start + 1)
64
+ return ""
65
+
66
+
67
+ def screen_degree_scope(job: Job) -> Rejection | None:
68
+ lexicon = eligibility_lexicon()
69
+
70
+ body = fold(f"{job.title_raw} {job.description}")
71
+
72
+ required = _restricted_hit(body, lexicon.phd_required, lexicon.undergraduate_tokens)
73
+ if required:
74
+ return Rejection(reason=RejectionReason.OUT_OF_SCOPE_DEGREE, matched_phrase=required)
75
+
76
+ graduate_only = _restricted_hit(
77
+ body,
78
+ lexicon.degree_required.get("phd", frozenset())
79
+ | lexicon.degree_required.get("masters", frozenset()),
80
+ lexicon.undergraduate_tokens,
81
+ )
82
+ if graduate_only:
83
+ return Rejection(reason=RejectionReason.OUT_OF_SCOPE_DEGREE, matched_phrase=graduate_only)
84
+
85
+ title = fold(job.title_raw)
86
+ token = _hit(title, lexicon.phd_title_tokens)
87
+ if token and not _hit(title, lexicon.degree_list_tokens):
88
+ return Rejection(reason=RejectionReason.OUT_OF_SCOPE_DEGREE, matched_phrase=token)
89
+
90
+ graduate = _hit(title, lexicon.graduate_title_tokens) or token
91
+ if graduate and not _hit(title, lexicon.undergraduate_tokens):
92
+ return Rejection(reason=RejectionReason.OUT_OF_SCOPE_DEGREE, matched_phrase=graduate)
93
+ return None
94
+
95
+
96
+ def screen_is_cs_role(job: Job) -> Rejection | None:
97
+ lexicon = eligibility_lexicon()
98
+ title = fold(job.title_raw)
99
+ title_verdict = classify_role(job.title_raw, source_category=job.signals.category)
100
+
101
+ phrase = _hit(title, lexicon.excluded_titles)
102
+ exception = _hit(title, lexicon.technical_title_exceptions)
103
+ if phrase and not (exception and title_verdict.matched):
104
+ return Rejection(reason=RejectionReason.NOT_A_CS_ROLE, matched_phrase=phrase)
105
+
106
+ phrase = _hit(title, lexicon.non_cs)
107
+ if phrase:
108
+ return Rejection(reason=RejectionReason.NOT_A_CS_ROLE, matched_phrase=phrase)
109
+
110
+ if title_verdict.matched:
111
+ return None
112
+ return Rejection(
113
+ reason=RejectionReason.UNKNOWN_CS_ROLE,
114
+ matched_phrase="no matching CS role or source category",
115
+ )
@@ -0,0 +1,64 @@
1
+ from dataclasses import dataclass
2
+
3
+ from stage.domain import Language
4
+ from stage.lexicon import fold, internship_lexicon
5
+ from stage.normalize import detect_language
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class InternshipVerdict:
10
+ is_internship: bool = False
11
+ matched: tuple[str, ...] = ()
12
+ disqualified_by: str = ""
13
+
14
+
15
+ def _phrase_hits(folded: str, phrases: frozenset[str]) -> list[str]:
16
+ padded = f" {folded} "
17
+ return sorted(phrase for phrase in phrases if f" {phrase} " in padded)
18
+
19
+
20
+ def _blocked_positions(folded: str, blocked: frozenset[str]) -> str:
21
+ padded = f" {folded} "
22
+ for phrase in sorted(blocked):
23
+ if f" {phrase} " in padded:
24
+ return phrase
25
+ return ""
26
+
27
+
28
+ def _structured_hit(employment_type: str, values: frozenset[str]) -> str:
29
+ hits = _phrase_hits(fold(employment_type), values)
30
+ return max(hits, key=len) if hits else ""
31
+
32
+
33
+ def screen_internship(
34
+ title: str, employment_type: str = "", category: str = ""
35
+ ) -> InternshipVerdict:
36
+ folded_title = fold(title)
37
+ lexicon = internship_lexicon()
38
+
39
+ disqualifier = _blocked_positions(folded_title, lexicon.disqualifiers)
40
+ if disqualifier:
41
+ return InternshipVerdict(is_internship=False, disqualified_by=disqualifier)
42
+
43
+ excluded = _structured_hit(category, lexicon.structured_excluded)
44
+ if excluded:
45
+ return InternshipVerdict(is_internship=False, disqualified_by=excluded)
46
+
47
+ blocked = _blocked_positions(folded_title, lexicon.blocked_bigrams)
48
+ matched = _phrase_hits(folded_title, lexicon.markers)
49
+ if {"stage", "stages"}.intersection(matched) and detect_language(title).language not in {
50
+ Language.FR,
51
+ Language.BILINGUAL,
52
+ }:
53
+ matched = [phrase for phrase in matched if phrase not in {"stage", "stages"}]
54
+ if blocked and not [phrase for phrase in matched if f" {phrase} " not in f" {blocked} "]:
55
+ return InternshipVerdict(is_internship=False, disqualified_by=blocked)
56
+
57
+ structured = _structured_hit(employment_type, lexicon.structured_internship)
58
+ if structured and not matched:
59
+ senior = _blocked_positions(folded_title, lexicon.structured_only_blocked)
60
+ if senior:
61
+ return InternshipVerdict(is_internship=False, disqualified_by=senior)
62
+
63
+ evidence = tuple(matched) + ((structured,) if structured else ())
64
+ return InternshipVerdict(is_internship=bool(evidence), matched=evidence)