canisend 0.2.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.
- canisend/__init__.py +3 -0
- canisend/cli.py +677 -0
- canisend/config_schema.py +56 -0
- canisend/evidence.py +543 -0
- canisend/examples.py +112 -0
- canisend/git_tracking.py +53 -0
- canisend/job_import.py +228 -0
- canisend/jobs.py +226 -0
- canisend/llm.py +107 -0
- canisend/match.py +410 -0
- canisend/material_review.py +216 -0
- canisend/materials.py +193 -0
- canisend/orchestrator.py +686 -0
- canisend/package_check.py +87 -0
- canisend/parse.py +200 -0
- canisend/pipeline.py +224 -0
- canisend/profile.py +189 -0
- canisend/ready_check.py +124 -0
- canisend/resource_files.py +78 -0
- canisend/resources/.codex-plugin/plugin.json +33 -0
- canisend/resources/.env.example +6 -0
- canisend/resources/__init__.py +1 -0
- canisend/resources/agent-skills/canisend/SKILL.md +69 -0
- canisend/resources/agent-skills/canisend/agents/openai.yaml +4 -0
- canisend/resources/agent-skills/canisend/references/agent-orchestration.md +114 -0
- canisend/resources/agent-skills/canisend/references/file-contracts.md +103 -0
- canisend/resources/agent-skills/canisend/references/job-lifecycle.md +63 -0
- canisend/resources/agent-skills/canisend/references/platforms.md +66 -0
- canisend/resources/agent-skills/canisend/references/privacy.md +93 -0
- canisend/resources/agent-skills/canisend/references/provider-config.md +74 -0
- canisend/resources/agent-skills/canisend/references/quality-gates.md +71 -0
- canisend/resources/agent-skills/canisend/references/typst-profile.md +69 -0
- canisend/resources/agent-skills/canisend/references/workflow.md +131 -0
- canisend/resources/examples/end_to_end/README.md +80 -0
- canisend/resources/examples/end_to_end/fake_llm_provider.py +128 -0
- canisend/resources/examples/end_to_end/full_job_advert.md +19 -0
- canisend/resources/examples/end_to_end/jobs_ac_uk_sample.xml +18 -0
- canisend/resources/examples/end_to_end/profile/generated/.gitkeep +1 -0
- canisend/resources/examples/end_to_end/profile/profile.yaml +13 -0
- canisend/resources/examples/end_to_end/profile/typst/cover_letter_base.typ +19 -0
- canisend/resources/examples/end_to_end/profile/typst/cv.typ +18 -0
- canisend/resources/examples/end_to_end/profile/typst/research_statement.typ +13 -0
- canisend/resources/examples/end_to_end/profile/typst/teaching_statement.typ +13 -0
- canisend/resources/platform-bridges/AGENTS.md +32 -0
- canisend/resources/platform-bridges/CLAUDE.md +25 -0
- canisend/resources/prompts/cover_letter_writer.md +45 -0
- canisend/resources/prompts/criteria_checker.md +44 -0
- canisend/resources/prompts/cv_tailor.md +44 -0
- canisend/resources/prompts/job_parser.md +38 -0
- canisend/resources/prompts/package_builder.md +33 -0
- canisend/resources/prompts/profile_evidence_augmenter.md +46 -0
- canisend/resources/prompts/profile_matcher.md +37 -0
- canisend/resources/schemas/criteria_check.schema.json +23 -0
- canisend/resources/schemas/fit_report.schema.json +13 -0
- canisend/resources/schemas/parsed_job.schema.json +51 -0
- canisend/resources/skills/canisend/SKILL.md +69 -0
- canisend/resources/skills/canisend/agents/openai.yaml +4 -0
- canisend/resources/skills/canisend/references/agent-orchestration.md +114 -0
- canisend/resources/skills/canisend/references/file-contracts.md +103 -0
- canisend/resources/skills/canisend/references/job-lifecycle.md +63 -0
- canisend/resources/skills/canisend/references/platforms.md +66 -0
- canisend/resources/skills/canisend/references/privacy.md +93 -0
- canisend/resources/skills/canisend/references/provider-config.md +74 -0
- canisend/resources/skills/canisend/references/quality-gates.md +71 -0
- canisend/resources/skills/canisend/references/typst-profile.md +69 -0
- canisend/resources/skills/canisend/references/workflow.md +131 -0
- canisend/resources/skills/canisend-cover-letter/SKILL.md +33 -0
- canisend/resources/skills/canisend-cover-letter/agents/openai.yaml +4 -0
- canisend/resources/skills/canisend-criteria-check/SKILL.md +33 -0
- canisend/resources/skills/canisend-criteria-check/agents/openai.yaml +4 -0
- canisend/resources/skills/canisend-cv-tailoring/SKILL.md +33 -0
- canisend/resources/skills/canisend-cv-tailoring/agents/openai.yaml +4 -0
- canisend/resources/skills/canisend-material-review/SKILL.md +35 -0
- canisend/resources/skills/canisend-material-review/agents/openai.yaml +4 -0
- canisend/resources/skills/canisend-research-statement/SKILL.md +33 -0
- canisend/resources/skills/canisend-research-statement/agents/openai.yaml +4 -0
- canisend/resources/templates/typst/application_package.typ +35 -0
- canisend/resources/templates/typst/cover_letter.typ +40 -0
- canisend/resources/templates/typst/cv_notes.typ +18 -0
- canisend/rss.py +89 -0
- canisend/skill_distribution.py +31 -0
- canisend/typst.py +29 -0
- canisend/typst_mapping.py +300 -0
- canisend/versioning.py +123 -0
- canisend/workspace.py +273 -0
- canisend-0.2.0.dist-info/METADATA +514 -0
- canisend-0.2.0.dist-info/RECORD +90 -0
- canisend-0.2.0.dist-info/WHEEL +4 -0
- canisend-0.2.0.dist-info/entry_points.txt +2 -0
- canisend-0.2.0.dist-info/licenses/LICENSE +21 -0
canisend/__init__.py
ADDED
canisend/cli.py
ADDED
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from canisend import __version__
|
|
7
|
+
from canisend.evidence import EvidenceAugmentationError, extract_profile_evidence
|
|
8
|
+
from canisend.examples import run_packaged_example
|
|
9
|
+
from canisend.git_tracking import GitTrackingError, git_add_application_materials
|
|
10
|
+
from canisend.jobs import create_job, create_job_from_lead, list_jobs as list_job_folders
|
|
11
|
+
from canisend.orchestrator import OrchestrationError, run_orchestration
|
|
12
|
+
from canisend.pipeline import run_pipeline as run_job_pipeline
|
|
13
|
+
from canisend.profile import init_profile as create_profile
|
|
14
|
+
from canisend.ready_check import check_application_package
|
|
15
|
+
from canisend.rss import fetch_rss_text, filter_job_leads, parse_jobs_ac_uk_rss, write_job_leads
|
|
16
|
+
from canisend.skill_distribution import export_skill_distribution
|
|
17
|
+
from canisend.typst import render_typst_files
|
|
18
|
+
from canisend.versioning import fetch_remote_versions, format_version_report
|
|
19
|
+
from canisend.workspace import (
|
|
20
|
+
doctor_lines,
|
|
21
|
+
init_workspace as create_workspace,
|
|
22
|
+
load_workspace_config,
|
|
23
|
+
prune_deprecated_workspace_files,
|
|
24
|
+
update_workspace_defaults,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
APP_HELP = """Prepare evidence-backed academic and professional job application materials from local files.
|
|
29
|
+
|
|
30
|
+
Common workflow:
|
|
31
|
+
canisend init-workspace --workspace ~/CanISendWorkspace
|
|
32
|
+
canisend doctor --workspace ~/CanISendWorkspace
|
|
33
|
+
canisend init-profile --workspace ~/CanISendWorkspace --mode typst
|
|
34
|
+
canisend extract-profile-evidence --workspace ~/CanISendWorkspace
|
|
35
|
+
canisend new-job --workspace ~/CanISendWorkspace --title ... --institution ...
|
|
36
|
+
canisend run --workspace ~/CanISendWorkspace --job jobs/<job-slug>
|
|
37
|
+
canisend check-package --workspace ~/CanISendWorkspace --job jobs/<job-slug>
|
|
38
|
+
|
|
39
|
+
Version checks:
|
|
40
|
+
canisend --version
|
|
41
|
+
canisend version
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
app = typer.Typer(
|
|
45
|
+
help=APP_HELP,
|
|
46
|
+
no_args_is_help=True,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _version_callback(value: bool) -> None:
|
|
51
|
+
if not value:
|
|
52
|
+
return
|
|
53
|
+
_echo_version_report()
|
|
54
|
+
raise typer.Exit()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _echo_version_report() -> None:
|
|
58
|
+
try:
|
|
59
|
+
remote = fetch_remote_versions()
|
|
60
|
+
error = None
|
|
61
|
+
except Exception as exc:
|
|
62
|
+
remote = None
|
|
63
|
+
error = str(exc)
|
|
64
|
+
|
|
65
|
+
for line in format_version_report(local_version=__version__, remote=remote, error=error):
|
|
66
|
+
typer.echo(line)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _stdin_is_interactive() -> bool:
|
|
70
|
+
return sys.stdin.isatty()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _should_git_add_materials(value: bool | None) -> bool:
|
|
74
|
+
if value is not None:
|
|
75
|
+
return value
|
|
76
|
+
if not _stdin_is_interactive():
|
|
77
|
+
return False
|
|
78
|
+
return typer.confirm("Add generated application materials to git?", default=False)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@app.callback()
|
|
82
|
+
def main(
|
|
83
|
+
version: bool = typer.Option(
|
|
84
|
+
False,
|
|
85
|
+
"--version",
|
|
86
|
+
callback=_version_callback,
|
|
87
|
+
is_eager=True,
|
|
88
|
+
help="Show local, remote stable, and remote prerelease versions, then exit.",
|
|
89
|
+
),
|
|
90
|
+
) -> None:
|
|
91
|
+
"""CanISend command-line interface."""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@app.command("version")
|
|
95
|
+
def version_command() -> None:
|
|
96
|
+
"""Show local, remote stable, and remote prerelease version information."""
|
|
97
|
+
_echo_version_report()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@app.command("init-profile")
|
|
101
|
+
def init_profile(
|
|
102
|
+
workspace: Path = typer.Option(
|
|
103
|
+
Path("."),
|
|
104
|
+
"--workspace",
|
|
105
|
+
help="User workspace directory containing canisend.yaml.",
|
|
106
|
+
),
|
|
107
|
+
profile_dir: Path | None = typer.Option(
|
|
108
|
+
None,
|
|
109
|
+
"--profile-dir",
|
|
110
|
+
help="Directory for profile evidence files. Relative paths are resolved against --workspace.",
|
|
111
|
+
),
|
|
112
|
+
mode: str = typer.Option(
|
|
113
|
+
"hybrid",
|
|
114
|
+
"--mode",
|
|
115
|
+
help="Profile scaffold mode: markdown, typst, or hybrid.",
|
|
116
|
+
),
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Create starter profile files."""
|
|
119
|
+
config = load_workspace_config(workspace)
|
|
120
|
+
resolved_profile_dir = config.path("profile_dir", profile_dir)
|
|
121
|
+
try:
|
|
122
|
+
created = create_profile(resolved_profile_dir, mode=mode)
|
|
123
|
+
except ValueError as exc:
|
|
124
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
125
|
+
typer.echo(f"Profile ready at {resolved_profile_dir}")
|
|
126
|
+
if created:
|
|
127
|
+
typer.echo(f"Created {len(created)} profile files.")
|
|
128
|
+
else:
|
|
129
|
+
typer.echo("No files created; existing profile files were left unchanged.")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@app.command("init-workspace")
|
|
133
|
+
def init_workspace(
|
|
134
|
+
workspace: Path = typer.Option(
|
|
135
|
+
Path("."),
|
|
136
|
+
"--workspace",
|
|
137
|
+
help="User workspace directory for private profile, jobs, local prompts, and agent skills.",
|
|
138
|
+
),
|
|
139
|
+
profile_mode: str = typer.Option(
|
|
140
|
+
"typst",
|
|
141
|
+
"--profile-mode",
|
|
142
|
+
help="Profile scaffold mode: markdown, typst, or hybrid.",
|
|
143
|
+
),
|
|
144
|
+
overwrite: bool = typer.Option(
|
|
145
|
+
False,
|
|
146
|
+
"--overwrite",
|
|
147
|
+
help="Overwrite existing default resources and config files.",
|
|
148
|
+
),
|
|
149
|
+
) -> None:
|
|
150
|
+
"""Create a productized user workspace without requiring a repository fork."""
|
|
151
|
+
try:
|
|
152
|
+
created = create_workspace(workspace, profile_mode=profile_mode, overwrite=overwrite)
|
|
153
|
+
except ValueError as exc:
|
|
154
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
155
|
+
typer.echo(f"Workspace ready at {workspace}")
|
|
156
|
+
if created:
|
|
157
|
+
typer.echo(f"Created or updated {len(created)} files.")
|
|
158
|
+
else:
|
|
159
|
+
typer.echo("No files changed; existing workspace files were left unchanged.")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@app.command("update-workspace")
|
|
163
|
+
def update_workspace(
|
|
164
|
+
workspace: Path = typer.Option(
|
|
165
|
+
Path("."),
|
|
166
|
+
"--workspace",
|
|
167
|
+
help="User workspace directory whose default resources should be refreshed.",
|
|
168
|
+
),
|
|
169
|
+
overwrite: bool = typer.Option(
|
|
170
|
+
False,
|
|
171
|
+
"--overwrite",
|
|
172
|
+
help="Overwrite local default-resource copies. Leave off to preserve local edits.",
|
|
173
|
+
),
|
|
174
|
+
prune_deprecated: bool = typer.Option(
|
|
175
|
+
False,
|
|
176
|
+
"--prune-deprecated",
|
|
177
|
+
help="Remove deprecated packaged workspace files such as retired platform bridges.",
|
|
178
|
+
),
|
|
179
|
+
) -> None:
|
|
180
|
+
"""Copy current packaged prompts, templates, schemas, and agent skills into a workspace."""
|
|
181
|
+
try:
|
|
182
|
+
copied = update_workspace_defaults(workspace, overwrite=overwrite)
|
|
183
|
+
except ValueError as exc:
|
|
184
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
185
|
+
removed = prune_deprecated_workspace_files(workspace) if prune_deprecated else []
|
|
186
|
+
typer.echo(f"Workspace defaults checked at {workspace}")
|
|
187
|
+
if copied:
|
|
188
|
+
typer.echo(f"Created or updated {len(copied)} files.")
|
|
189
|
+
else:
|
|
190
|
+
typer.echo("No default files changed; existing local files were left unchanged.")
|
|
191
|
+
if prune_deprecated:
|
|
192
|
+
suffix = "file" if len(removed) == 1 else "files"
|
|
193
|
+
typer.echo(f"Removed {len(removed)} deprecated {suffix}.")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@app.command("export-skills")
|
|
197
|
+
def export_skills(
|
|
198
|
+
target: Path = typer.Option(
|
|
199
|
+
...,
|
|
200
|
+
"--target",
|
|
201
|
+
help="Directory to receive the CanISend skill distribution.",
|
|
202
|
+
),
|
|
203
|
+
kind: str = typer.Option(
|
|
204
|
+
"codex-plugin",
|
|
205
|
+
"--kind",
|
|
206
|
+
help="Export kind: codex-plugin or skills-only.",
|
|
207
|
+
),
|
|
208
|
+
overwrite: bool = typer.Option(
|
|
209
|
+
False,
|
|
210
|
+
"--overwrite",
|
|
211
|
+
help="Write into a non-empty target directory.",
|
|
212
|
+
),
|
|
213
|
+
) -> None:
|
|
214
|
+
"""Export packaged CanISend skills for Codex marketplace or Claude skills usage."""
|
|
215
|
+
try:
|
|
216
|
+
copied = export_skill_distribution(target, kind=kind, overwrite=overwrite)
|
|
217
|
+
except ValueError as exc:
|
|
218
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
219
|
+
typer.echo(f"Exported CanISend {kind} distribution to {target}")
|
|
220
|
+
typer.echo(f"Wrote {len(copied)} files.")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@app.command("doctor")
|
|
224
|
+
def doctor(
|
|
225
|
+
workspace: Path = typer.Option(
|
|
226
|
+
Path("."),
|
|
227
|
+
"--workspace",
|
|
228
|
+
help="User workspace directory to inspect.",
|
|
229
|
+
),
|
|
230
|
+
) -> None:
|
|
231
|
+
"""Report local workspace, provider, and rendering readiness."""
|
|
232
|
+
for line in doctor_lines(workspace):
|
|
233
|
+
typer.echo(line)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@app.command("run-example")
|
|
237
|
+
def run_example(
|
|
238
|
+
workspace: Path = typer.Option(
|
|
239
|
+
Path("/tmp/canisend-example"),
|
|
240
|
+
"--workspace",
|
|
241
|
+
help="Directory where the packaged fake-data example workspace should be created.",
|
|
242
|
+
),
|
|
243
|
+
overwrite: bool = typer.Option(
|
|
244
|
+
False,
|
|
245
|
+
"--overwrite",
|
|
246
|
+
help="Replace an existing non-empty example workspace.",
|
|
247
|
+
),
|
|
248
|
+
) -> None:
|
|
249
|
+
"""Run the packaged end-to-end fake-data workflow locally."""
|
|
250
|
+
try:
|
|
251
|
+
result = run_packaged_example(workspace, overwrite=overwrite)
|
|
252
|
+
except ValueError as exc:
|
|
253
|
+
typer.echo(str(exc), err=True)
|
|
254
|
+
raise typer.Exit(code=1) from exc
|
|
255
|
+
|
|
256
|
+
typer.echo(f"Example workflow complete at {result.workspace}")
|
|
257
|
+
typer.echo(f"Job: {result.job_dir.relative_to(result.workspace)}")
|
|
258
|
+
typer.echo(f"RSS leads: {result.leads_file.relative_to(result.workspace)}")
|
|
259
|
+
typer.echo("Key outputs:")
|
|
260
|
+
for output in [
|
|
261
|
+
"parsed_job.json",
|
|
262
|
+
"02_fit_report.md",
|
|
263
|
+
"03_cover_letter_draft.md",
|
|
264
|
+
"05_criteria_checklist.md",
|
|
265
|
+
"07_material_review_checklist.md",
|
|
266
|
+
"typst/cover_letter_content.json",
|
|
267
|
+
"typst/cover_letter.typ",
|
|
268
|
+
"typst/application_package_content.json",
|
|
269
|
+
"typst/application_package.typ",
|
|
270
|
+
]:
|
|
271
|
+
typer.echo(f" - {result.job_dir.relative_to(result.workspace) / output}")
|
|
272
|
+
typer.echo("Next: inspect the generated job folder, then try the same workflow with your private workspace.")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@app.command("check-package")
|
|
276
|
+
def check_package(
|
|
277
|
+
job: Path = typer.Option(
|
|
278
|
+
...,
|
|
279
|
+
"--job",
|
|
280
|
+
help="Job folder path or slug. Slugs are resolved under the workspace jobs directory.",
|
|
281
|
+
),
|
|
282
|
+
workspace: Path = typer.Option(
|
|
283
|
+
Path("."),
|
|
284
|
+
"--workspace",
|
|
285
|
+
help="User workspace directory containing canisend.yaml.",
|
|
286
|
+
),
|
|
287
|
+
profile_dir: Path | None = typer.Option(
|
|
288
|
+
None,
|
|
289
|
+
"--profile-dir",
|
|
290
|
+
help="Directory containing generated profile evidence. Relative paths are resolved against --workspace.",
|
|
291
|
+
),
|
|
292
|
+
) -> None:
|
|
293
|
+
"""Check whether a generated application package has unresolved issues."""
|
|
294
|
+
config = load_workspace_config(workspace)
|
|
295
|
+
result = check_application_package(
|
|
296
|
+
job_dir=config.job_dir(job),
|
|
297
|
+
profile_dir=config.path("profile_dir", profile_dir),
|
|
298
|
+
)
|
|
299
|
+
for line in result.output_lines():
|
|
300
|
+
typer.echo(line)
|
|
301
|
+
if not result.ok:
|
|
302
|
+
raise typer.Exit(code=1)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@app.command("orchestrate")
|
|
306
|
+
def orchestrate(
|
|
307
|
+
job: Path = typer.Option(
|
|
308
|
+
...,
|
|
309
|
+
"--job",
|
|
310
|
+
help="Job folder path or slug. Slugs are resolved under the workspace jobs directory.",
|
|
311
|
+
),
|
|
312
|
+
plan: Path = typer.Option(..., "--plan", help="Local orchestration YAML plan."),
|
|
313
|
+
workspace: Path = typer.Option(
|
|
314
|
+
Path("."),
|
|
315
|
+
"--workspace",
|
|
316
|
+
help="User workspace directory containing canisend.yaml.",
|
|
317
|
+
),
|
|
318
|
+
dry_run: bool = typer.Option(
|
|
319
|
+
False,
|
|
320
|
+
"--dry-run",
|
|
321
|
+
help="Validate and print task readiness without launching workers.",
|
|
322
|
+
),
|
|
323
|
+
allow_private_sources: bool = typer.Option(
|
|
324
|
+
False,
|
|
325
|
+
"--allow-private-sources",
|
|
326
|
+
help="Allow Tier 2 private-source tasks declared in the plan.",
|
|
327
|
+
),
|
|
328
|
+
allow_provider_backed: bool = typer.Option(
|
|
329
|
+
False,
|
|
330
|
+
"--allow-provider-backed",
|
|
331
|
+
help="Allow Tier 3 provider-backed tasks declared in the plan.",
|
|
332
|
+
),
|
|
333
|
+
fail_fast: bool = typer.Option(
|
|
334
|
+
False,
|
|
335
|
+
"--fail-fast",
|
|
336
|
+
help="Stop scheduling unrelated tasks after the first failure.",
|
|
337
|
+
),
|
|
338
|
+
) -> None:
|
|
339
|
+
"""Coordinate multiple local agent CLI workers for one job."""
|
|
340
|
+
config = load_workspace_config(workspace)
|
|
341
|
+
try:
|
|
342
|
+
result = run_orchestration(
|
|
343
|
+
workspace=config.root,
|
|
344
|
+
job_dir=config.job_dir(job),
|
|
345
|
+
plan_path=plan,
|
|
346
|
+
dry_run=dry_run,
|
|
347
|
+
allow_private_sources=allow_private_sources,
|
|
348
|
+
allow_provider_backed=allow_provider_backed,
|
|
349
|
+
fail_fast=fail_fast,
|
|
350
|
+
)
|
|
351
|
+
except OrchestrationError as exc:
|
|
352
|
+
typer.echo(str(exc), err=True)
|
|
353
|
+
raise typer.Exit(code=1) from exc
|
|
354
|
+
if result.run_dir is not None:
|
|
355
|
+
typer.echo(f"Orchestration run: {result.run_dir}")
|
|
356
|
+
for task_id, status in sorted(result.task_statuses.items()):
|
|
357
|
+
typer.echo(f"{task_id}: {status}")
|
|
358
|
+
if not result.ok:
|
|
359
|
+
raise typer.Exit(code=1)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@app.command("extract-profile-evidence")
|
|
363
|
+
def extract_profile_evidence_command(
|
|
364
|
+
workspace: Path = typer.Option(
|
|
365
|
+
Path("."),
|
|
366
|
+
"--workspace",
|
|
367
|
+
help="User workspace directory containing canisend.yaml.",
|
|
368
|
+
),
|
|
369
|
+
profile_dir: Path | None = typer.Option(
|
|
370
|
+
None,
|
|
371
|
+
"--profile-dir",
|
|
372
|
+
help="Directory containing profile.yaml and Typst profile sources. Relative paths are resolved against --workspace.",
|
|
373
|
+
),
|
|
374
|
+
llm_augment: bool = typer.Option(
|
|
375
|
+
False,
|
|
376
|
+
"--llm-augment",
|
|
377
|
+
help="Use the configured LLM provider to supplement locally extracted profile evidence.",
|
|
378
|
+
),
|
|
379
|
+
prompt_dir: Path | None = typer.Option(
|
|
380
|
+
None,
|
|
381
|
+
"--prompt-dir",
|
|
382
|
+
help="Directory containing prompt files. Relative paths are resolved against --workspace.",
|
|
383
|
+
),
|
|
384
|
+
) -> None:
|
|
385
|
+
"""Generate normalized evidence Markdown from local profile sources."""
|
|
386
|
+
config = load_workspace_config(workspace)
|
|
387
|
+
provider = None
|
|
388
|
+
if llm_augment:
|
|
389
|
+
from canisend.llm import load_llm_config, provider_from_config
|
|
390
|
+
|
|
391
|
+
provider = provider_from_config(load_llm_config())
|
|
392
|
+
typer.echo("Evidence augmentation: LLM-backed")
|
|
393
|
+
try:
|
|
394
|
+
written = extract_profile_evidence(
|
|
395
|
+
config.path("profile_dir", profile_dir),
|
|
396
|
+
llm_provider=provider,
|
|
397
|
+
prompt_dir=config.path("prompt_dir", prompt_dir),
|
|
398
|
+
)
|
|
399
|
+
except EvidenceAugmentationError as exc:
|
|
400
|
+
typer.echo(str(exc), err=True)
|
|
401
|
+
raise typer.Exit(code=1) from exc
|
|
402
|
+
typer.echo(f"Generated {len(written)} evidence files.")
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
@app.command("new-job")
|
|
406
|
+
def new_job(
|
|
407
|
+
title: str = typer.Option(..., "--title", help="Job title."),
|
|
408
|
+
institution: str = typer.Option(..., "--institution", help="Hiring institution."),
|
|
409
|
+
deadline: str = typer.Option("unknown", "--deadline", help="Application deadline."),
|
|
410
|
+
source_url: str = typer.Option("", "--source-url", help="Original job advert URL."),
|
|
411
|
+
workspace: Path = typer.Option(
|
|
412
|
+
Path("."),
|
|
413
|
+
"--workspace",
|
|
414
|
+
help="User workspace directory containing canisend.yaml.",
|
|
415
|
+
),
|
|
416
|
+
jobs_dir: Path | None = typer.Option(
|
|
417
|
+
None,
|
|
418
|
+
"--jobs-dir",
|
|
419
|
+
help="Directory for job folders. Relative paths are resolved against --workspace.",
|
|
420
|
+
),
|
|
421
|
+
advert_file: Path | None = typer.Option(
|
|
422
|
+
None,
|
|
423
|
+
"--advert-file",
|
|
424
|
+
help="Local .md, .txt, or .pdf job advert file to import.",
|
|
425
|
+
),
|
|
426
|
+
fetch_url: bool = typer.Option(
|
|
427
|
+
False,
|
|
428
|
+
"--fetch-url",
|
|
429
|
+
help="Explicitly fetch --source-url and import readable HTML text into job_advert.md.",
|
|
430
|
+
),
|
|
431
|
+
) -> None:
|
|
432
|
+
"""Create a local job folder and advert file."""
|
|
433
|
+
config = load_workspace_config(workspace)
|
|
434
|
+
try:
|
|
435
|
+
job_dir = create_job(
|
|
436
|
+
jobs_dir=config.path("jobs_dir", jobs_dir),
|
|
437
|
+
title=title,
|
|
438
|
+
institution=institution,
|
|
439
|
+
deadline=deadline,
|
|
440
|
+
source_url=source_url,
|
|
441
|
+
advert_file=advert_file,
|
|
442
|
+
fetch_url=fetch_url,
|
|
443
|
+
)
|
|
444
|
+
except ValueError as exc:
|
|
445
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
446
|
+
typer.echo(f"Created job at {job_dir}")
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
@app.command("new-job-from-lead")
|
|
450
|
+
def new_job_from_lead(
|
|
451
|
+
workspace: Path = typer.Option(
|
|
452
|
+
Path("."),
|
|
453
|
+
"--workspace",
|
|
454
|
+
help="User workspace directory containing canisend.yaml.",
|
|
455
|
+
),
|
|
456
|
+
leads_file: Path | None = typer.Option(
|
|
457
|
+
None,
|
|
458
|
+
"--leads-file",
|
|
459
|
+
help="Local RSS lead JSON file created by fetch-jobs-ac-uk. Relative paths are resolved against --workspace.",
|
|
460
|
+
),
|
|
461
|
+
lead_index: int = typer.Option(..., "--lead-index", help="Zero-based index of the selected lead."),
|
|
462
|
+
institution: str = typer.Option(..., "--institution", help="Hiring institution for the job workspace."),
|
|
463
|
+
deadline: str = typer.Option("unknown", "--deadline", help="Application deadline."),
|
|
464
|
+
title: str | None = typer.Option(None, "--title", help="Override the RSS lead title."),
|
|
465
|
+
jobs_dir: Path | None = typer.Option(
|
|
466
|
+
None,
|
|
467
|
+
"--jobs-dir",
|
|
468
|
+
help="Directory for job folders. Relative paths are resolved against --workspace.",
|
|
469
|
+
),
|
|
470
|
+
) -> None:
|
|
471
|
+
"""Create a local job folder from a selected RSS lead without scraping."""
|
|
472
|
+
config = load_workspace_config(workspace)
|
|
473
|
+
try:
|
|
474
|
+
job_dir = create_job_from_lead(
|
|
475
|
+
leads_file=config.lead_file(leads_file),
|
|
476
|
+
lead_index=lead_index,
|
|
477
|
+
jobs_dir=config.path("jobs_dir", jobs_dir),
|
|
478
|
+
institution=institution,
|
|
479
|
+
deadline=deadline,
|
|
480
|
+
title=title,
|
|
481
|
+
)
|
|
482
|
+
except ValueError as exc:
|
|
483
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
484
|
+
typer.echo(f"Created job from lead at {job_dir}")
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
@app.command("list-jobs")
|
|
488
|
+
def list_jobs_command(
|
|
489
|
+
workspace: Path = typer.Option(
|
|
490
|
+
Path("."),
|
|
491
|
+
"--workspace",
|
|
492
|
+
help="User workspace directory containing canisend.yaml.",
|
|
493
|
+
),
|
|
494
|
+
jobs_dir: Path | None = typer.Option(
|
|
495
|
+
None,
|
|
496
|
+
"--jobs-dir",
|
|
497
|
+
help="Directory containing job folders. Relative paths are resolved against --workspace.",
|
|
498
|
+
),
|
|
499
|
+
) -> None:
|
|
500
|
+
"""List all job folders with status, deadline, and institution."""
|
|
501
|
+
config = load_workspace_config(workspace)
|
|
502
|
+
jobs = list_job_folders(config.path("jobs_dir", jobs_dir))
|
|
503
|
+
if not jobs:
|
|
504
|
+
typer.echo("No job folders found.")
|
|
505
|
+
return
|
|
506
|
+
typer.echo(f"{'Deadline':<12} {'Status':<18} {'Institution':<30} {'Title':<40} {'Next action'}")
|
|
507
|
+
typer.echo("-" * 120)
|
|
508
|
+
for job in jobs:
|
|
509
|
+
typer.echo(
|
|
510
|
+
f"{job['deadline']:<12} "
|
|
511
|
+
f"{job['status']:<18} "
|
|
512
|
+
f"{job['institution'][:28]:<30} "
|
|
513
|
+
f"{job['title'][:40]:<40} "
|
|
514
|
+
f"{job['next_action']}"
|
|
515
|
+
)
|
|
516
|
+
typer.echo(f"\n{len(jobs)} job(s) found.")
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
@app.command("fetch-jobs-ac-uk")
|
|
520
|
+
def fetch_jobs_ac_uk(
|
|
521
|
+
workspace: Path = typer.Option(
|
|
522
|
+
Path("."),
|
|
523
|
+
"--workspace",
|
|
524
|
+
help="User workspace directory containing canisend.yaml.",
|
|
525
|
+
),
|
|
526
|
+
feed_url: str = typer.Option("", "--feed-url", help="jobs.ac.uk RSS feed URL."),
|
|
527
|
+
rss_file: Path | None = typer.Option(
|
|
528
|
+
None,
|
|
529
|
+
"--rss-file",
|
|
530
|
+
help="Local RSS XML file for testing or offline import.",
|
|
531
|
+
),
|
|
532
|
+
output: Path | None = typer.Option(
|
|
533
|
+
None,
|
|
534
|
+
"--output",
|
|
535
|
+
help="JSON output path. Relative paths are resolved against --workspace.",
|
|
536
|
+
),
|
|
537
|
+
include: list[str] = typer.Option([], "--include", help="Include jobs matching this keyword."),
|
|
538
|
+
exclude: list[str] = typer.Option([], "--exclude", help="Exclude jobs matching this keyword."),
|
|
539
|
+
limit: int = typer.Option(100, "--limit", help="Maximum number of leads to write."),
|
|
540
|
+
) -> None:
|
|
541
|
+
"""Fetch jobs.ac.uk RSS leads and apply local keyword filters."""
|
|
542
|
+
if rss_file is None and not feed_url:
|
|
543
|
+
raise typer.BadParameter("Provide --feed-url or --rss-file.")
|
|
544
|
+
|
|
545
|
+
config = load_workspace_config(workspace)
|
|
546
|
+
output_path = config.lead_file(output)
|
|
547
|
+
xml_text = rss_file.read_text(encoding="utf-8") if rss_file is not None else fetch_rss_text(feed_url)
|
|
548
|
+
leads = parse_jobs_ac_uk_rss(xml_text, feed_url=feed_url)
|
|
549
|
+
filtered = filter_job_leads(leads, include_keywords=include, exclude_keywords=exclude)
|
|
550
|
+
limited = filtered[:limit]
|
|
551
|
+
write_job_leads(output_path, limited)
|
|
552
|
+
typer.echo(f"Wrote {len(limited)} jobs.ac.uk leads to {output_path}")
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
@app.command("run")
|
|
556
|
+
def run_pipeline(
|
|
557
|
+
job: Path = typer.Option(..., "--job", help="Path to the job folder."),
|
|
558
|
+
workspace: Path = typer.Option(
|
|
559
|
+
Path("."),
|
|
560
|
+
"--workspace",
|
|
561
|
+
help="User workspace directory containing canisend.yaml.",
|
|
562
|
+
),
|
|
563
|
+
profile_dir: Path | None = typer.Option(
|
|
564
|
+
None,
|
|
565
|
+
"--profile-dir",
|
|
566
|
+
help="Directory containing generated profile evidence. Relative paths are resolved against --workspace.",
|
|
567
|
+
),
|
|
568
|
+
llm_parser: bool = typer.Option(
|
|
569
|
+
False,
|
|
570
|
+
"--llm-parser",
|
|
571
|
+
help="Use configured LLM provider and prompts/job_parser.md instead of deterministic parsing.",
|
|
572
|
+
),
|
|
573
|
+
llm_drafts: bool = typer.Option(
|
|
574
|
+
False,
|
|
575
|
+
"--llm-drafts",
|
|
576
|
+
help="Use configured LLM provider for fit report, cover letter, CV notes, and criteria checklist.",
|
|
577
|
+
),
|
|
578
|
+
prompt_dir: Path | None = typer.Option(
|
|
579
|
+
None,
|
|
580
|
+
"--prompt-dir",
|
|
581
|
+
help="Directory containing application prompt files. Relative paths are resolved against --workspace.",
|
|
582
|
+
),
|
|
583
|
+
dry_run: bool = typer.Option(
|
|
584
|
+
False,
|
|
585
|
+
"--dry-run",
|
|
586
|
+
help="Preview what would be generated without writing any files.",
|
|
587
|
+
),
|
|
588
|
+
git_add_materials: bool | None = typer.Option(
|
|
589
|
+
None,
|
|
590
|
+
"--git-add-materials/--no-git-add-materials",
|
|
591
|
+
help="Ask git to stage generated application materials after a successful run.",
|
|
592
|
+
),
|
|
593
|
+
) -> None:
|
|
594
|
+
"""Run the application preparation pipeline for one job."""
|
|
595
|
+
config = load_workspace_config(workspace)
|
|
596
|
+
job_dir = config.job_dir(job)
|
|
597
|
+
if dry_run:
|
|
598
|
+
from canisend.evidence import load_generated_evidence
|
|
599
|
+
from canisend.llm import load_llm_config, provider_from_config
|
|
600
|
+
from canisend.parse import parse_job_advert, parse_job_advert_with_provider
|
|
601
|
+
from canisend.resource_files import read_resource_text
|
|
602
|
+
|
|
603
|
+
import yaml as _yaml
|
|
604
|
+
|
|
605
|
+
metadata = _yaml.safe_load((job_dir / "job.yaml").read_text(encoding="utf-8"))
|
|
606
|
+
advert_text = (job_dir / "job_advert.md").read_text(encoding="utf-8")
|
|
607
|
+
evidence = load_generated_evidence(config.path("profile_dir", profile_dir))
|
|
608
|
+
if llm_parser:
|
|
609
|
+
prompt_text = read_resource_text("prompts/job_parser.md", local_path=config.path("prompt_dir", prompt_dir) / "job_parser.md")
|
|
610
|
+
provider = provider_from_config(load_llm_config())
|
|
611
|
+
parsed_job = parse_job_advert_with_provider(advert_text=advert_text, metadata=metadata, provider=provider, prompt_text=prompt_text)
|
|
612
|
+
typer.echo("Parser: LLM-backed")
|
|
613
|
+
else:
|
|
614
|
+
parsed_job = parse_job_advert(advert_text, metadata)
|
|
615
|
+
typer.echo("Parser: deterministic")
|
|
616
|
+
|
|
617
|
+
typer.echo(f" Title: {parsed_job['title']}")
|
|
618
|
+
typer.echo(f" Institution: {parsed_job['institution']}")
|
|
619
|
+
typer.echo(f" Essential criteria: {len(parsed_job['essential_criteria'])}")
|
|
620
|
+
typer.echo(f" Desirable criteria: {len(parsed_job['desirable_criteria'])}")
|
|
621
|
+
typer.echo(f" Required documents: {len(parsed_job['required_documents'])}")
|
|
622
|
+
typer.echo(f" Evidence items available: {len(evidence)}")
|
|
623
|
+
typer.echo(f"\nOutputs that would be generated:")
|
|
624
|
+
|
|
625
|
+
outputs = [
|
|
626
|
+
"parsed_job.json", "01_job_summary.md", "02_fit_report.md",
|
|
627
|
+
"03_cover_letter_draft.md", "04_cv_tailoring_notes.md",
|
|
628
|
+
"05_criteria_checklist.md", "06_final_application_package.md",
|
|
629
|
+
"07_material_review_checklist.md",
|
|
630
|
+
"typst/cover_letter_content.json", "typst/cover_letter.typ",
|
|
631
|
+
"typst/application_package_content.json", "typst/application_package.typ",
|
|
632
|
+
]
|
|
633
|
+
for output in outputs:
|
|
634
|
+
typer.echo(f" - {job_dir}/{output}")
|
|
635
|
+
typer.echo(f"\nDraft mode: {'LLM-backed' if llm_drafts else 'deterministic'}")
|
|
636
|
+
return
|
|
637
|
+
|
|
638
|
+
written = run_job_pipeline(
|
|
639
|
+
job_dir,
|
|
640
|
+
profile_dir=config.path("profile_dir", profile_dir),
|
|
641
|
+
use_llm_parser=llm_parser,
|
|
642
|
+
use_llm_drafts=llm_drafts,
|
|
643
|
+
prompt_dir=config.path("prompt_dir", prompt_dir),
|
|
644
|
+
)
|
|
645
|
+
typer.echo(f"Generated {len(written)} files for {job_dir}")
|
|
646
|
+
if _should_git_add_materials(git_add_materials):
|
|
647
|
+
try:
|
|
648
|
+
git_result = git_add_application_materials(job_dir, repo_dir=config.root)
|
|
649
|
+
except GitTrackingError as exc:
|
|
650
|
+
typer.echo(f"Could not add generated application materials to git: {exc}", err=True)
|
|
651
|
+
return
|
|
652
|
+
suffix = "file" if len(git_result.files) == 1 else "files"
|
|
653
|
+
typer.echo(f"Added {len(git_result.files)} generated application material {suffix} to git.")
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
@app.command("render-typst")
|
|
657
|
+
def render_typst(
|
|
658
|
+
job: Path = typer.Option(..., "--job", help="Path to the job folder."),
|
|
659
|
+
workspace: Path = typer.Option(
|
|
660
|
+
Path("."),
|
|
661
|
+
"--workspace",
|
|
662
|
+
help="User workspace directory containing canisend.yaml.",
|
|
663
|
+
),
|
|
664
|
+
typst_bin: str = typer.Option("typst", "--typst-bin", help="Typst executable path or command name."),
|
|
665
|
+
) -> None:
|
|
666
|
+
"""Render generated Typst files for one job."""
|
|
667
|
+
config = load_workspace_config(workspace)
|
|
668
|
+
job_dir = config.job_dir(job)
|
|
669
|
+
try:
|
|
670
|
+
rendered = render_typst_files(job_dir, typst_bin=typst_bin)
|
|
671
|
+
except FileNotFoundError as exc:
|
|
672
|
+
typer.echo(str(exc))
|
|
673
|
+
raise typer.Exit(code=1) from exc
|
|
674
|
+
except RuntimeError as exc:
|
|
675
|
+
typer.echo(str(exc))
|
|
676
|
+
raise typer.Exit(code=1) from exc
|
|
677
|
+
typer.echo(f"Rendered {len(rendered)} PDF files for {job_dir}")
|