canisend 0.2.0b2__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 +426 -0
- canisend/config_schema.py +56 -0
- canisend/evidence.py +288 -0
- canisend/examples.py +112 -0
- canisend/jobs.py +178 -0
- canisend/llm.py +104 -0
- canisend/match.py +389 -0
- canisend/material_review.py +92 -0
- canisend/materials.py +175 -0
- canisend/package_check.py +71 -0
- canisend/parse.py +167 -0
- canisend/pipeline.py +220 -0
- canisend/profile.py +189 -0
- canisend/resource_files.py +62 -0
- canisend/resources/.env.example +6 -0
- canisend/resources/__init__.py +1 -0
- canisend/resources/agent-skills/canisend/SKILL.md +67 -0
- canisend/resources/agent-skills/canisend/agents/openai.yaml +4 -0
- canisend/resources/agent-skills/canisend/references/agent-orchestration.md +73 -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 +62 -0
- canisend/resources/agent-skills/canisend/references/privacy.md +59 -0
- canisend/resources/agent-skills/canisend/references/provider-config.md +70 -0
- canisend/resources/agent-skills/canisend/references/quality-gates.md +68 -0
- canisend/resources/agent-skills/canisend/references/typst-profile.md +71 -0
- canisend/resources/agent-skills/canisend/references/workflow.md +126 -0
- canisend/resources/examples/end_to_end/README.md +80 -0
- canisend/resources/examples/end_to_end/fake_llm_provider.py +119 -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 +22 -0
- canisend/resources/platform-bridges/CLAUDE.md +21 -0
- canisend/resources/platform-bridges/GEMINI.md +21 -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 +27 -0
- canisend/resources/prompts/profile_matcher.md +37 -0
- canisend/resources/schemas/criteria_check.schema.json +22 -0
- canisend/resources/schemas/fit_report.schema.json +12 -0
- canisend/resources/schemas/parsed_job.schema.json +51 -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/typst.py +29 -0
- canisend/typst_mapping.py +209 -0
- canisend/workspace.py +214 -0
- canisend-0.2.0b2.dist-info/METADATA +378 -0
- canisend-0.2.0b2.dist-info/RECORD +62 -0
- canisend-0.2.0b2.dist-info/WHEEL +4 -0
- canisend-0.2.0b2.dist-info/entry_points.txt +2 -0
- canisend-0.2.0b2.dist-info/licenses/LICENSE +21 -0
canisend/__init__.py
ADDED
canisend/cli.py
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from canisend.evidence import extract_profile_evidence
|
|
6
|
+
from canisend.examples import run_packaged_example
|
|
7
|
+
from canisend.jobs import create_job, create_job_from_lead, list_jobs as list_job_folders
|
|
8
|
+
from canisend.pipeline import run_pipeline as run_job_pipeline
|
|
9
|
+
from canisend.profile import init_profile as create_profile
|
|
10
|
+
from canisend.rss import fetch_rss_text, filter_job_leads, parse_jobs_ac_uk_rss, write_job_leads
|
|
11
|
+
from canisend.typst import render_typst_files
|
|
12
|
+
from canisend.workspace import (
|
|
13
|
+
doctor_lines,
|
|
14
|
+
init_workspace as create_workspace,
|
|
15
|
+
load_workspace_config,
|
|
16
|
+
update_workspace_defaults,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(
|
|
20
|
+
help="Prepare evidence-backed academic and professional job application materials from local files.",
|
|
21
|
+
no_args_is_help=True,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.command("init-profile")
|
|
26
|
+
def init_profile(
|
|
27
|
+
workspace: Path = typer.Option(
|
|
28
|
+
Path("."),
|
|
29
|
+
"--workspace",
|
|
30
|
+
help="User workspace directory containing canisend.yaml.",
|
|
31
|
+
),
|
|
32
|
+
profile_dir: Path | None = typer.Option(
|
|
33
|
+
None,
|
|
34
|
+
"--profile-dir",
|
|
35
|
+
help="Directory for profile evidence files. Relative paths are resolved against --workspace.",
|
|
36
|
+
),
|
|
37
|
+
mode: str = typer.Option(
|
|
38
|
+
"hybrid",
|
|
39
|
+
"--mode",
|
|
40
|
+
help="Profile scaffold mode: markdown, typst, or hybrid.",
|
|
41
|
+
),
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Create starter profile files."""
|
|
44
|
+
config = load_workspace_config(workspace)
|
|
45
|
+
resolved_profile_dir = config.path("profile_dir", profile_dir)
|
|
46
|
+
try:
|
|
47
|
+
created = create_profile(resolved_profile_dir, mode=mode)
|
|
48
|
+
except ValueError as exc:
|
|
49
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
50
|
+
typer.echo(f"Profile ready at {resolved_profile_dir}")
|
|
51
|
+
if created:
|
|
52
|
+
typer.echo(f"Created {len(created)} profile files.")
|
|
53
|
+
else:
|
|
54
|
+
typer.echo("No files created; existing profile files were left unchanged.")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@app.command("init-workspace")
|
|
58
|
+
def init_workspace(
|
|
59
|
+
workspace: Path = typer.Option(
|
|
60
|
+
Path("."),
|
|
61
|
+
"--workspace",
|
|
62
|
+
help="User workspace directory for private profile, jobs, local prompts, and agent skills.",
|
|
63
|
+
),
|
|
64
|
+
profile_mode: str = typer.Option(
|
|
65
|
+
"typst",
|
|
66
|
+
"--profile-mode",
|
|
67
|
+
help="Profile scaffold mode: markdown, typst, or hybrid.",
|
|
68
|
+
),
|
|
69
|
+
overwrite: bool = typer.Option(
|
|
70
|
+
False,
|
|
71
|
+
"--overwrite",
|
|
72
|
+
help="Overwrite existing default resources and config files.",
|
|
73
|
+
),
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Create a productized user workspace without requiring a repository fork."""
|
|
76
|
+
try:
|
|
77
|
+
created = create_workspace(workspace, profile_mode=profile_mode, overwrite=overwrite)
|
|
78
|
+
except ValueError as exc:
|
|
79
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
80
|
+
typer.echo(f"Workspace ready at {workspace}")
|
|
81
|
+
if created:
|
|
82
|
+
typer.echo(f"Created or updated {len(created)} files.")
|
|
83
|
+
else:
|
|
84
|
+
typer.echo("No files changed; existing workspace files were left unchanged.")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@app.command("update-workspace")
|
|
88
|
+
def update_workspace(
|
|
89
|
+
workspace: Path = typer.Option(
|
|
90
|
+
Path("."),
|
|
91
|
+
"--workspace",
|
|
92
|
+
help="User workspace directory whose default resources should be refreshed.",
|
|
93
|
+
),
|
|
94
|
+
overwrite: bool = typer.Option(
|
|
95
|
+
False,
|
|
96
|
+
"--overwrite",
|
|
97
|
+
help="Overwrite local default-resource copies. Leave off to preserve local edits.",
|
|
98
|
+
),
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Copy current packaged prompts, templates, schemas, and agent skills into a workspace."""
|
|
101
|
+
copied = update_workspace_defaults(workspace, overwrite=overwrite)
|
|
102
|
+
typer.echo(f"Workspace defaults checked at {workspace}")
|
|
103
|
+
if copied:
|
|
104
|
+
typer.echo(f"Created or updated {len(copied)} files.")
|
|
105
|
+
else:
|
|
106
|
+
typer.echo("No default files changed; existing local files were left unchanged.")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@app.command("doctor")
|
|
110
|
+
def doctor(
|
|
111
|
+
workspace: Path = typer.Option(
|
|
112
|
+
Path("."),
|
|
113
|
+
"--workspace",
|
|
114
|
+
help="User workspace directory to inspect.",
|
|
115
|
+
),
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Report local workspace, provider, and rendering readiness."""
|
|
118
|
+
for line in doctor_lines(workspace):
|
|
119
|
+
typer.echo(line)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@app.command("run-example")
|
|
123
|
+
def run_example(
|
|
124
|
+
workspace: Path = typer.Option(
|
|
125
|
+
Path("/tmp/canisend-example"),
|
|
126
|
+
"--workspace",
|
|
127
|
+
help="Directory where the packaged fake-data example workspace should be created.",
|
|
128
|
+
),
|
|
129
|
+
overwrite: bool = typer.Option(
|
|
130
|
+
False,
|
|
131
|
+
"--overwrite",
|
|
132
|
+
help="Replace an existing non-empty example workspace.",
|
|
133
|
+
),
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Run the packaged end-to-end fake-data workflow locally."""
|
|
136
|
+
try:
|
|
137
|
+
result = run_packaged_example(workspace, overwrite=overwrite)
|
|
138
|
+
except ValueError as exc:
|
|
139
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
140
|
+
|
|
141
|
+
typer.echo(f"Example workflow complete at {result.workspace}")
|
|
142
|
+
typer.echo(f"Job: {result.job_dir.relative_to(result.workspace)}")
|
|
143
|
+
typer.echo(f"RSS leads: {result.leads_file.relative_to(result.workspace)}")
|
|
144
|
+
typer.echo("Key outputs:")
|
|
145
|
+
for output in [
|
|
146
|
+
"parsed_job.json",
|
|
147
|
+
"02_fit_report.md",
|
|
148
|
+
"03_cover_letter_draft.md",
|
|
149
|
+
"05_criteria_checklist.md",
|
|
150
|
+
"07_material_review_checklist.md",
|
|
151
|
+
"typst/cover_letter_content.json",
|
|
152
|
+
"typst/cover_letter.typ",
|
|
153
|
+
"typst/application_package_content.json",
|
|
154
|
+
"typst/application_package.typ",
|
|
155
|
+
]:
|
|
156
|
+
typer.echo(f" - {result.job_dir.relative_to(result.workspace) / output}")
|
|
157
|
+
typer.echo("Next: inspect the generated job folder, then try the same workflow with your private workspace.")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@app.command("extract-profile-evidence")
|
|
161
|
+
def extract_profile_evidence_command(
|
|
162
|
+
workspace: Path = typer.Option(
|
|
163
|
+
Path("."),
|
|
164
|
+
"--workspace",
|
|
165
|
+
help="User workspace directory containing canisend.yaml.",
|
|
166
|
+
),
|
|
167
|
+
profile_dir: Path | None = typer.Option(
|
|
168
|
+
None,
|
|
169
|
+
"--profile-dir",
|
|
170
|
+
help="Directory containing profile.yaml and Typst profile sources. Relative paths are resolved against --workspace.",
|
|
171
|
+
),
|
|
172
|
+
) -> None:
|
|
173
|
+
"""Generate normalized evidence Markdown from local profile sources."""
|
|
174
|
+
config = load_workspace_config(workspace)
|
|
175
|
+
written = extract_profile_evidence(config.path("profile_dir", profile_dir))
|
|
176
|
+
typer.echo(f"Generated {len(written)} evidence files.")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@app.command("new-job")
|
|
180
|
+
def new_job(
|
|
181
|
+
title: str = typer.Option(..., "--title", help="Job title."),
|
|
182
|
+
institution: str = typer.Option(..., "--institution", help="Hiring institution."),
|
|
183
|
+
deadline: str = typer.Option("unknown", "--deadline", help="Application deadline."),
|
|
184
|
+
source_url: str = typer.Option("", "--source-url", help="Original job advert URL."),
|
|
185
|
+
workspace: Path = typer.Option(
|
|
186
|
+
Path("."),
|
|
187
|
+
"--workspace",
|
|
188
|
+
help="User workspace directory containing canisend.yaml.",
|
|
189
|
+
),
|
|
190
|
+
jobs_dir: Path | None = typer.Option(
|
|
191
|
+
None,
|
|
192
|
+
"--jobs-dir",
|
|
193
|
+
help="Directory for job folders. Relative paths are resolved against --workspace.",
|
|
194
|
+
),
|
|
195
|
+
advert_file: Path | None = typer.Option(
|
|
196
|
+
None,
|
|
197
|
+
"--advert-file",
|
|
198
|
+
help="Local .md or .txt job advert file to import.",
|
|
199
|
+
),
|
|
200
|
+
) -> None:
|
|
201
|
+
"""Create a local job folder and advert file."""
|
|
202
|
+
config = load_workspace_config(workspace)
|
|
203
|
+
try:
|
|
204
|
+
job_dir = create_job(
|
|
205
|
+
jobs_dir=config.path("jobs_dir", jobs_dir),
|
|
206
|
+
title=title,
|
|
207
|
+
institution=institution,
|
|
208
|
+
deadline=deadline,
|
|
209
|
+
source_url=source_url,
|
|
210
|
+
advert_file=advert_file,
|
|
211
|
+
)
|
|
212
|
+
except ValueError as exc:
|
|
213
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
214
|
+
typer.echo(f"Created job at {job_dir}")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@app.command("new-job-from-lead")
|
|
218
|
+
def new_job_from_lead(
|
|
219
|
+
workspace: Path = typer.Option(
|
|
220
|
+
Path("."),
|
|
221
|
+
"--workspace",
|
|
222
|
+
help="User workspace directory containing canisend.yaml.",
|
|
223
|
+
),
|
|
224
|
+
leads_file: Path | None = typer.Option(
|
|
225
|
+
None,
|
|
226
|
+
"--leads-file",
|
|
227
|
+
help="Local RSS lead JSON file created by fetch-jobs-ac-uk. Relative paths are resolved against --workspace.",
|
|
228
|
+
),
|
|
229
|
+
lead_index: int = typer.Option(..., "--lead-index", help="Zero-based index of the selected lead."),
|
|
230
|
+
institution: str = typer.Option(..., "--institution", help="Hiring institution for the job workspace."),
|
|
231
|
+
deadline: str = typer.Option("unknown", "--deadline", help="Application deadline."),
|
|
232
|
+
title: str | None = typer.Option(None, "--title", help="Override the RSS lead title."),
|
|
233
|
+
jobs_dir: Path | None = typer.Option(
|
|
234
|
+
None,
|
|
235
|
+
"--jobs-dir",
|
|
236
|
+
help="Directory for job folders. Relative paths are resolved against --workspace.",
|
|
237
|
+
),
|
|
238
|
+
) -> None:
|
|
239
|
+
"""Create a local job folder from a selected RSS lead without scraping."""
|
|
240
|
+
config = load_workspace_config(workspace)
|
|
241
|
+
try:
|
|
242
|
+
job_dir = create_job_from_lead(
|
|
243
|
+
leads_file=config.lead_file(leads_file),
|
|
244
|
+
lead_index=lead_index,
|
|
245
|
+
jobs_dir=config.path("jobs_dir", jobs_dir),
|
|
246
|
+
institution=institution,
|
|
247
|
+
deadline=deadline,
|
|
248
|
+
title=title,
|
|
249
|
+
)
|
|
250
|
+
except ValueError as exc:
|
|
251
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
252
|
+
typer.echo(f"Created job from lead at {job_dir}")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@app.command("list-jobs")
|
|
256
|
+
def list_jobs_command(
|
|
257
|
+
workspace: Path = typer.Option(
|
|
258
|
+
Path("."),
|
|
259
|
+
"--workspace",
|
|
260
|
+
help="User workspace directory containing canisend.yaml.",
|
|
261
|
+
),
|
|
262
|
+
jobs_dir: Path | None = typer.Option(
|
|
263
|
+
None,
|
|
264
|
+
"--jobs-dir",
|
|
265
|
+
help="Directory containing job folders. Relative paths are resolved against --workspace.",
|
|
266
|
+
),
|
|
267
|
+
) -> None:
|
|
268
|
+
"""List all job folders with status, deadline, and institution."""
|
|
269
|
+
config = load_workspace_config(workspace)
|
|
270
|
+
jobs = list_job_folders(config.path("jobs_dir", jobs_dir))
|
|
271
|
+
if not jobs:
|
|
272
|
+
typer.echo("No job folders found.")
|
|
273
|
+
return
|
|
274
|
+
typer.echo(f"{'Deadline':<12} {'Status':<18} {'Institution':<30} {'Title'}")
|
|
275
|
+
typer.echo("-" * 90)
|
|
276
|
+
for job in jobs:
|
|
277
|
+
typer.echo(f"{job['deadline']:<12} {job['status']:<18} {job['institution'][:28]:<30} {job['title'][:40]}")
|
|
278
|
+
typer.echo(f"\n{len(jobs)} job(s) found.")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@app.command("fetch-jobs-ac-uk")
|
|
282
|
+
def fetch_jobs_ac_uk(
|
|
283
|
+
workspace: Path = typer.Option(
|
|
284
|
+
Path("."),
|
|
285
|
+
"--workspace",
|
|
286
|
+
help="User workspace directory containing canisend.yaml.",
|
|
287
|
+
),
|
|
288
|
+
feed_url: str = typer.Option("", "--feed-url", help="jobs.ac.uk RSS feed URL."),
|
|
289
|
+
rss_file: Path | None = typer.Option(
|
|
290
|
+
None,
|
|
291
|
+
"--rss-file",
|
|
292
|
+
help="Local RSS XML file for testing or offline import.",
|
|
293
|
+
),
|
|
294
|
+
output: Path | None = typer.Option(
|
|
295
|
+
None,
|
|
296
|
+
"--output",
|
|
297
|
+
help="JSON output path. Relative paths are resolved against --workspace.",
|
|
298
|
+
),
|
|
299
|
+
include: list[str] = typer.Option([], "--include", help="Include jobs matching this keyword."),
|
|
300
|
+
exclude: list[str] = typer.Option([], "--exclude", help="Exclude jobs matching this keyword."),
|
|
301
|
+
limit: int = typer.Option(100, "--limit", help="Maximum number of leads to write."),
|
|
302
|
+
) -> None:
|
|
303
|
+
"""Fetch jobs.ac.uk RSS leads and apply local keyword filters."""
|
|
304
|
+
if rss_file is None and not feed_url:
|
|
305
|
+
raise typer.BadParameter("Provide --feed-url or --rss-file.")
|
|
306
|
+
|
|
307
|
+
config = load_workspace_config(workspace)
|
|
308
|
+
output_path = config.lead_file(output)
|
|
309
|
+
xml_text = rss_file.read_text(encoding="utf-8") if rss_file is not None else fetch_rss_text(feed_url)
|
|
310
|
+
leads = parse_jobs_ac_uk_rss(xml_text, feed_url=feed_url)
|
|
311
|
+
filtered = filter_job_leads(leads, include_keywords=include, exclude_keywords=exclude)
|
|
312
|
+
limited = filtered[:limit]
|
|
313
|
+
write_job_leads(output_path, limited)
|
|
314
|
+
typer.echo(f"Wrote {len(limited)} jobs.ac.uk leads to {output_path}")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@app.command("run")
|
|
318
|
+
def run_pipeline(
|
|
319
|
+
job: Path = typer.Option(..., "--job", help="Path to the job folder."),
|
|
320
|
+
workspace: Path = typer.Option(
|
|
321
|
+
Path("."),
|
|
322
|
+
"--workspace",
|
|
323
|
+
help="User workspace directory containing canisend.yaml.",
|
|
324
|
+
),
|
|
325
|
+
profile_dir: Path | None = typer.Option(
|
|
326
|
+
None,
|
|
327
|
+
"--profile-dir",
|
|
328
|
+
help="Directory containing generated profile evidence. Relative paths are resolved against --workspace.",
|
|
329
|
+
),
|
|
330
|
+
llm_parser: bool = typer.Option(
|
|
331
|
+
False,
|
|
332
|
+
"--llm-parser",
|
|
333
|
+
help="Use configured LLM provider and prompts/job_parser.md instead of deterministic parsing.",
|
|
334
|
+
),
|
|
335
|
+
llm_drafts: bool = typer.Option(
|
|
336
|
+
False,
|
|
337
|
+
"--llm-drafts",
|
|
338
|
+
help="Use configured LLM provider for fit report, cover letter, CV notes, and criteria checklist.",
|
|
339
|
+
),
|
|
340
|
+
prompt_dir: Path | None = typer.Option(
|
|
341
|
+
None,
|
|
342
|
+
"--prompt-dir",
|
|
343
|
+
help="Directory containing application prompt files. Relative paths are resolved against --workspace.",
|
|
344
|
+
),
|
|
345
|
+
dry_run: bool = typer.Option(
|
|
346
|
+
False,
|
|
347
|
+
"--dry-run",
|
|
348
|
+
help="Preview what would be generated without writing any files.",
|
|
349
|
+
),
|
|
350
|
+
) -> None:
|
|
351
|
+
"""Run the application preparation pipeline for one job."""
|
|
352
|
+
config = load_workspace_config(workspace)
|
|
353
|
+
job_dir = config.job_dir(job)
|
|
354
|
+
if dry_run:
|
|
355
|
+
from canisend.evidence import load_generated_evidence
|
|
356
|
+
from canisend.llm import load_llm_config, provider_from_config
|
|
357
|
+
from canisend.parse import parse_job_advert, parse_job_advert_with_provider
|
|
358
|
+
from canisend.resource_files import read_resource_text
|
|
359
|
+
|
|
360
|
+
import yaml as _yaml
|
|
361
|
+
|
|
362
|
+
metadata = _yaml.safe_load((job_dir / "job.yaml").read_text(encoding="utf-8"))
|
|
363
|
+
advert_text = (job_dir / "job_advert.md").read_text(encoding="utf-8")
|
|
364
|
+
evidence = load_generated_evidence(config.path("profile_dir", profile_dir))
|
|
365
|
+
if llm_parser:
|
|
366
|
+
prompt_text = read_resource_text("prompts/job_parser.md", local_path=config.path("prompt_dir", prompt_dir) / "job_parser.md")
|
|
367
|
+
provider = provider_from_config(load_llm_config())
|
|
368
|
+
parsed_job = parse_job_advert_with_provider(advert_text=advert_text, metadata=metadata, provider=provider, prompt_text=prompt_text)
|
|
369
|
+
typer.echo("Parser: LLM-backed")
|
|
370
|
+
else:
|
|
371
|
+
parsed_job = parse_job_advert(advert_text, metadata)
|
|
372
|
+
typer.echo("Parser: deterministic")
|
|
373
|
+
|
|
374
|
+
typer.echo(f" Title: {parsed_job['title']}")
|
|
375
|
+
typer.echo(f" Institution: {parsed_job['institution']}")
|
|
376
|
+
typer.echo(f" Essential criteria: {len(parsed_job['essential_criteria'])}")
|
|
377
|
+
typer.echo(f" Desirable criteria: {len(parsed_job['desirable_criteria'])}")
|
|
378
|
+
typer.echo(f" Required documents: {len(parsed_job['required_documents'])}")
|
|
379
|
+
typer.echo(f" Evidence items available: {len(evidence)}")
|
|
380
|
+
typer.echo(f"\nOutputs that would be generated:")
|
|
381
|
+
|
|
382
|
+
outputs = [
|
|
383
|
+
"parsed_job.json", "01_job_summary.md", "02_fit_report.md",
|
|
384
|
+
"03_cover_letter_draft.md", "04_cv_tailoring_notes.md",
|
|
385
|
+
"05_criteria_checklist.md", "06_final_application_package.md",
|
|
386
|
+
"07_material_review_checklist.md",
|
|
387
|
+
"typst/cover_letter_content.json", "typst/cover_letter.typ",
|
|
388
|
+
"typst/application_package_content.json", "typst/application_package.typ",
|
|
389
|
+
]
|
|
390
|
+
for output in outputs:
|
|
391
|
+
typer.echo(f" - {job_dir}/{output}")
|
|
392
|
+
typer.echo(f"\nDraft mode: {'LLM-backed' if llm_drafts else 'deterministic'}")
|
|
393
|
+
return
|
|
394
|
+
|
|
395
|
+
written = run_job_pipeline(
|
|
396
|
+
job_dir,
|
|
397
|
+
profile_dir=config.path("profile_dir", profile_dir),
|
|
398
|
+
use_llm_parser=llm_parser,
|
|
399
|
+
use_llm_drafts=llm_drafts,
|
|
400
|
+
prompt_dir=config.path("prompt_dir", prompt_dir),
|
|
401
|
+
)
|
|
402
|
+
typer.echo(f"Generated {len(written)} files for {job_dir}")
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
@app.command("render-typst")
|
|
406
|
+
def render_typst(
|
|
407
|
+
job: Path = typer.Option(..., "--job", help="Path to the job folder."),
|
|
408
|
+
workspace: Path = typer.Option(
|
|
409
|
+
Path("."),
|
|
410
|
+
"--workspace",
|
|
411
|
+
help="User workspace directory containing canisend.yaml.",
|
|
412
|
+
),
|
|
413
|
+
typst_bin: str = typer.Option("typst", "--typst-bin", help="Typst executable path or command name."),
|
|
414
|
+
) -> None:
|
|
415
|
+
"""Render generated Typst files for one job."""
|
|
416
|
+
config = load_workspace_config(workspace)
|
|
417
|
+
job_dir = config.job_dir(job)
|
|
418
|
+
try:
|
|
419
|
+
rendered = render_typst_files(job_dir, typst_bin=typst_bin)
|
|
420
|
+
except FileNotFoundError as exc:
|
|
421
|
+
typer.echo(str(exc))
|
|
422
|
+
raise typer.Exit(code=1) from exc
|
|
423
|
+
except RuntimeError as exc:
|
|
424
|
+
typer.echo(str(exc))
|
|
425
|
+
raise typer.Exit(code=1) from exc
|
|
426
|
+
typer.echo(f"Rendered {len(rendered)} PDF files for {job_dir}")
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
VALID_CONFIG_KEYS = {
|
|
10
|
+
"profile_dir",
|
|
11
|
+
"jobs_dir",
|
|
12
|
+
"job_leads_dir",
|
|
13
|
+
"prompt_dir",
|
|
14
|
+
"template_dir",
|
|
15
|
+
"schema_dir",
|
|
16
|
+
"agent_skills_dir",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def validate_workspace_config(config_path: Path) -> list[str]:
|
|
21
|
+
if not config_path.exists():
|
|
22
|
+
return ["canisend.yaml not found. Run `canisend init-workspace` to create it."]
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
data = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
|
26
|
+
except yaml.YAMLError as exc:
|
|
27
|
+
return [f"canisend.yaml is not valid YAML: {exc}"]
|
|
28
|
+
|
|
29
|
+
if not isinstance(data, dict):
|
|
30
|
+
return ["canisend.yaml must contain a mapping of key-value pairs."]
|
|
31
|
+
|
|
32
|
+
warnings: list[str] = []
|
|
33
|
+
for key, value in data.items():
|
|
34
|
+
if key not in VALID_CONFIG_KEYS:
|
|
35
|
+
warnings.append(f"Unknown key in canisend.yaml: '{key}'. Valid keys: {', '.join(sorted(VALID_CONFIG_KEYS))}")
|
|
36
|
+
if not isinstance(value, (str, type(None))):
|
|
37
|
+
warnings.append(f"Value for '{key}' must be a string or null, got {type(value).__name__}.")
|
|
38
|
+
|
|
39
|
+
for key in VALID_CONFIG_KEYS:
|
|
40
|
+
if key not in data:
|
|
41
|
+
warnings.append(f"Missing key in canisend.yaml: '{key}'. Default will be used: '{_default_for(key)}'.")
|
|
42
|
+
|
|
43
|
+
return warnings
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _default_for(key: str) -> str:
|
|
47
|
+
defaults: dict[str, str] = {
|
|
48
|
+
"profile_dir": "profile",
|
|
49
|
+
"jobs_dir": "jobs",
|
|
50
|
+
"job_leads_dir": "job_leads",
|
|
51
|
+
"prompt_dir": "prompts",
|
|
52
|
+
"template_dir": "templates",
|
|
53
|
+
"schema_dir": "schemas",
|
|
54
|
+
"agent_skills_dir": "agent-skills",
|
|
55
|
+
}
|
|
56
|
+
return defaults.get(key, "")
|