knowme-careerforge-skill 0.0.4 → 0.0.7
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.
- package/AGENT.md +276 -0
- package/ARCHITECTURE.md +220 -0
- package/README.md +46 -63
- package/README.zh-CN.md +47 -66
- package/SKILL.md +38 -135
- package/agents/claude/knowme-careerforge.md +2 -17
- package/agents/codex/knowme-careerforge.yaml +2 -20
- package/agents/cursor/knowme-careerforge.mdc +4 -13
- package/agents/gemini/knowme-careerforge.json +1 -1
- package/agents/opencode/skill.yaml +1 -1
- package/agents/windsurf/knowme-careerforge.rules +2 -12
- package/dist/cli/src/commands/init.js +51 -32
- package/dist/cli/src/index.js +25 -21
- package/dist/scripts/rendering/engine-loader.d.ts +2 -0
- package/dist/scripts/rendering/engine-loader.js +46 -0
- package/dist/scripts/rendering/render-pdf.d.ts +3 -8
- package/dist/scripts/rendering/render-pdf.js +7 -103
- package/dist/scripts/rendering/runtime.d.ts +9 -0
- package/dist/scripts/rendering/runtime.js +63 -0
- package/dist/scripts/validation/validate-ats.d.ts +2 -22
- package/dist/scripts/validation/validate-ats.js +6 -168
- package/dist/scripts/validation/validate-layout.d.ts +2 -23
- package/dist/scripts/validation/validate-layout.js +5 -159
- package/package.json +15 -4
- package/references/01-evidence-mining.md +35 -0
- package/references/02-career-goal.md +40 -0
- package/references/03-jd-analysis.md +32 -0
- package/references/04-template-selection.md +30 -0
- package/references/05-html-canvas-tokens.md +12 -0
- package/references/06-qa-and-rendering.md +27 -0
- package/references/07-artifact-contract.md +83 -0
- package/scripts/Agent.md +9 -136
- package/scripts/build/build-gallery.py +66 -56
- package/scripts/build/run-all-tests.py +1 -0
- package/scripts/build/sync-version.py +27 -9
- package/scripts/contracts/profile.py +101 -0
- package/scripts/evidence/extract-evidence.py +109 -62
- package/scripts/pipeline/forge.py +276 -136
- package/scripts/rendering/README.md +65 -0
- package/scripts/rendering/browser-engine.d.ts +15 -0
- package/scripts/rendering/browser-engine.js +408 -0
- package/scripts/rendering/browser_engine.py +39 -0
- package/scripts/rendering/engine-loader.ts +11 -0
- package/scripts/rendering/render-pdf.py +26 -151
- package/scripts/rendering/render-pdf.ts +7 -83
- package/scripts/template/instantiate-resume.py +266 -173
- package/scripts/template/search-template.py +89 -3
- package/scripts/validation/measure-dom.js +5 -0
- package/scripts/validation/validate-ats.ts +6 -174
- package/scripts/validation/validate-layout.ts +5 -159
- package/scripts/validation/validate-resume.py +23 -134
- package/skill.json +7 -6
- package/src/knowledge/index.json +391 -3
- package/src/knowledge/resume-schema.json +377 -71
- package/src/knowledge/templates.json +389 -1
- package/src/references/01-evidence-mining.md +24 -30
- package/src/references/02-career-goal.md +4 -2
- package/src/references/03-jd-analysis.md +5 -17
- package/src/references/04-template-selection.md +20 -33
- package/src/references/05-html-canvas-tokens.md +6 -45
- package/src/references/06-qa-and-rendering.md +16 -43
- package/src/references/07-artifact-contract.md +83 -0
- package/src/templates/Agent.md +11 -0
- package/src/templates/academic-research/README.md +25 -0
- package/src/templates/academic-research/canvas.html +21 -0
- package/src/templates/academic-research/metadata.json +38 -0
- package/src/templates/academic-research/sample-profile.json +86 -0
- package/src/templates/academic-research/style.css +213 -0
- package/src/templates/classic/README.md +9 -0
- package/src/templates/classic/canvas.html +21 -0
- package/src/templates/classic/metadata.json +3 -0
- package/src/templates/classic/sample-profile.json +86 -0
- package/src/templates/classic/style.css +14 -4
- package/src/templates/common/base.css +32 -0
- package/src/templates/common/canvas-bindings.css +41 -0
- package/src/templates/common/resume-contract.md +8 -0
- package/src/templates/compact-dense/README.md +25 -0
- package/src/templates/compact-dense/canvas.html +14 -0
- package/src/templates/compact-dense/metadata.json +38 -0
- package/src/templates/compact-dense/sample-profile.json +86 -0
- package/src/templates/compact-dense/style.css +218 -0
- package/src/templates/creative-tech/README.md +26 -0
- package/src/templates/creative-tech/canvas.html +15 -0
- package/src/templates/creative-tech/metadata.json +39 -0
- package/src/templates/creative-tech/sample-profile.json +86 -0
- package/src/templates/creative-tech/style.css +257 -0
- package/src/templates/data-analyst/README.md +26 -0
- package/src/templates/data-analyst/canvas.html +15 -0
- package/src/templates/data-analyst/metadata.json +39 -0
- package/src/templates/data-analyst/sample-profile.json +86 -0
- package/src/templates/data-analyst/style.css +285 -0
- package/src/templates/executive/README.md +9 -0
- package/src/templates/executive/canvas.html +15 -0
- package/src/templates/executive/sample-profile.json +86 -0
- package/src/templates/executive/style.css +58 -6
- package/src/templates/international-flow/README.md +26 -0
- package/src/templates/international-flow/canvas.html +21 -0
- package/src/templates/international-flow/metadata.json +37 -0
- package/src/templates/international-flow/sample-profile.json +86 -0
- package/src/templates/international-flow/style.css +208 -0
- package/src/templates/minimal/README.md +12 -1
- package/src/templates/minimal/canvas.html +21 -0
- package/src/templates/minimal/sample-profile.json +86 -0
- package/src/templates/minimal/style.css +24 -0
- package/src/templates/modern/README.md +9 -0
- package/src/templates/modern/canvas.html +15 -0
- package/src/templates/modern/sample-profile.json +86 -0
- package/src/templates/modern/style.css +48 -1
- package/src/templates/startup-generalist/README.md +25 -0
- package/src/templates/startup-generalist/canvas.html +21 -0
- package/src/templates/startup-generalist/metadata.json +37 -0
- package/src/templates/startup-generalist/sample-profile.json +86 -0
- package/src/templates/startup-generalist/style.css +237 -0
- package/src/templates/classic/template.html +0 -133
- package/src/templates/executive/template.html +0 -101
- package/src/templates/minimal/template.html +0 -114
- package/src/templates/modern/template.html +0 -139
|
@@ -225,86 +225,133 @@ def scan_repo(repo_path: Path) -> Dict[str, Any]:
|
|
|
225
225
|
return facts
|
|
226
226
|
|
|
227
227
|
def build_master_profile(facts: Dict[str, Any], user_overrides: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
228
|
+
"""Build structured candidate profile from codebase facts + optional user overrides.
|
|
229
|
+
|
|
230
|
+
Placeholder discipline:
|
|
231
|
+
- repo_mode=True : facts dict is populated from real repo scan; safe to synthesize
|
|
232
|
+
experience/education skeletons because they'll be user-reviewed before use.
|
|
233
|
+
- repo_mode=False: only user_overrides carry real data. NEVER emit fabricated placeholder
|
|
234
|
+
strings (fake company, fake school, fake dates). Use null / empty list instead.
|
|
235
|
+
"""
|
|
228
236
|
overrides = user_overrides or {}
|
|
229
237
|
author = facts.get("author_info", {})
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
238
|
+
repo_mode: bool = bool(facts.get("detected_tech_stacks") or facts.get("evidence_items"))
|
|
239
|
+
|
|
240
|
+
# ── Basics ────────────────────────────────────────────────────────────────
|
|
241
|
+
name = overrides.get("name") or author.get("name") or (None if not repo_mode else "候选人")
|
|
242
|
+
title = overrides.get("title") or (None if not repo_mode else "资深工程师 / 技术专家")
|
|
243
|
+
email = overrides.get("email") or author.get("email") or (None if not repo_mode else "candidate@example.com")
|
|
244
|
+
phone = overrides.get("phone") or (None if not repo_mode else None)
|
|
245
|
+
github = overrides.get("github") or author.get("git_remote") or None
|
|
246
|
+
location = overrides.get("location") or (None if not repo_mode else None)
|
|
247
|
+
|
|
237
248
|
tech_stacks = facts.get("detected_tech_stacks", [])
|
|
238
|
-
languages
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
249
|
+
languages = list(facts.get("detected_languages", {}).keys())
|
|
250
|
+
|
|
251
|
+
# ── Skills ────────────────────────────────────────────────────────────────
|
|
252
|
+
all_tech = tech_stacks + [l for l in languages if l not in tech_stacks]
|
|
253
|
+
arch_signals = facts.get("architectural_signals", [])
|
|
254
|
+
skills: list = []
|
|
255
|
+
if all_tech:
|
|
256
|
+
skills.append({"category": "核心技术栈", "items": all_tech, "highlighted": all_tech[:4]})
|
|
257
|
+
if arch_signals:
|
|
258
|
+
skills.append({"category": "工程化与架构", "items": arch_signals, "highlighted": arch_signals[:2]})
|
|
259
|
+
|
|
260
|
+
# ── Experience ────────────────────────────────────────────────────────────
|
|
261
|
+
bullets = [
|
|
262
|
+
{
|
|
263
|
+
"text": item["claim"],
|
|
264
|
+
"evidenceLevel": item["evidenceLevel"],
|
|
265
|
+
"evidenceSource": item["evidenceSource"],
|
|
266
|
+
"keywords": item.get("keywords", [])
|
|
267
|
+
}
|
|
268
|
+
for item in facts.get("evidence_items", [])
|
|
269
|
+
]
|
|
270
|
+
|
|
271
|
+
if repo_mode and bullets:
|
|
272
|
+
# Repo mode: synthesize a single experience entry from code evidence.
|
|
273
|
+
# Company / dates are placeholders that the user MUST review.
|
|
274
|
+
experience = [
|
|
263
275
|
{
|
|
264
|
-
"company": overrides.get("company") or "
|
|
265
|
-
"role": title,
|
|
266
|
-
"startDate":
|
|
267
|
-
"endDate": "至今",
|
|
268
|
-
"location": "
|
|
276
|
+
"company": overrides.get("company") or "[请填写:公司名称]",
|
|
277
|
+
"role": title or "[请填写:职位名称]",
|
|
278
|
+
"startDate": overrides.get("startDate") or "[请填写:开始时间]",
|
|
279
|
+
"endDate": overrides.get("endDate") or "至今",
|
|
280
|
+
"location": location or "[请填写:工作地点]",
|
|
269
281
|
"summary": "负责核心产品研发与关键架构攻坚",
|
|
270
|
-
"bullets":
|
|
271
|
-
{
|
|
272
|
-
"text": item["claim"],
|
|
273
|
-
"evidenceLevel": item["evidenceLevel"],
|
|
274
|
-
"evidenceSource": item["evidenceSource"],
|
|
275
|
-
"keywords": item["keywords"]
|
|
276
|
-
}
|
|
277
|
-
for item in facts.get("evidence_items", [])
|
|
278
|
-
]
|
|
282
|
+
"bullets": bullets
|
|
279
283
|
}
|
|
280
|
-
]
|
|
281
|
-
|
|
284
|
+
]
|
|
285
|
+
elif overrides.get("experience"):
|
|
286
|
+
experience = overrides["experience"]
|
|
287
|
+
else:
|
|
288
|
+
experience = []
|
|
289
|
+
|
|
290
|
+
# ── Projects ──────────────────────────────────────────────────────────────
|
|
291
|
+
if repo_mode and tech_stacks:
|
|
292
|
+
projects = [
|
|
282
293
|
{
|
|
283
|
-
"name": facts.get("repo_name"
|
|
294
|
+
"name": facts.get("repo_name") or "[请填写:项目名称]",
|
|
284
295
|
"role": "核心负责人 / 主力开发者",
|
|
285
296
|
"techStack": tech_stacks[:5],
|
|
286
297
|
"repoUrl": github,
|
|
287
|
-
"startDate": "
|
|
298
|
+
"startDate": "[请填写:开始时间]",
|
|
288
299
|
"endDate": "至今",
|
|
289
300
|
"bullets": [
|
|
290
|
-
{
|
|
291
|
-
"text": item["claim"],
|
|
292
|
-
"evidenceLevel": item["evidenceLevel"],
|
|
293
|
-
"evidenceSource": item["evidenceSource"]
|
|
294
|
-
}
|
|
301
|
+
{"text": item["claim"], "evidenceLevel": item["evidenceLevel"], "evidenceSource": item["evidenceSource"]}
|
|
295
302
|
for item in facts.get("evidence_items", [])
|
|
296
303
|
]
|
|
297
304
|
}
|
|
298
|
-
]
|
|
299
|
-
|
|
305
|
+
]
|
|
306
|
+
elif overrides.get("projects"):
|
|
307
|
+
projects = overrides["projects"]
|
|
308
|
+
else:
|
|
309
|
+
projects = []
|
|
310
|
+
|
|
311
|
+
# ── Education ─────────────────────────────────────────────────────────────
|
|
312
|
+
if overrides.get("education"):
|
|
313
|
+
education = overrides["education"]
|
|
314
|
+
elif overrides.get("school") or overrides.get("degree"):
|
|
315
|
+
education = [
|
|
316
|
+
{
|
|
317
|
+
"institution": overrides.get("school") or "[请填写:学校名称]",
|
|
318
|
+
"degree": overrides.get("degree") or "[请填写:学历与专业]",
|
|
319
|
+
"startDate": overrides.get("edu_start") or "[请填写:入学时间]",
|
|
320
|
+
"endDate": overrides.get("edu_end") or "[请填写:毕业时间]"
|
|
321
|
+
}
|
|
322
|
+
]
|
|
323
|
+
elif repo_mode:
|
|
324
|
+
# Repo mode: emit a reminder skeleton so the user knows to fill it in.
|
|
325
|
+
education = [
|
|
300
326
|
{
|
|
301
|
-
"institution":
|
|
302
|
-
"degree":
|
|
303
|
-
"startDate": "
|
|
304
|
-
"endDate": "
|
|
327
|
+
"institution": "[请填写:学校名称]",
|
|
328
|
+
"degree": "[请填写:学历与专业]",
|
|
329
|
+
"startDate": "[请填写:入学时间]",
|
|
330
|
+
"endDate": "[请填写:毕业时间]"
|
|
305
331
|
}
|
|
306
332
|
]
|
|
307
|
-
|
|
333
|
+
else:
|
|
334
|
+
education = []
|
|
335
|
+
|
|
336
|
+
# ── Assemble ──────────────────────────────────────────────────────────────
|
|
337
|
+
basics: Dict[str, Any] = {}
|
|
338
|
+
if name: basics["name"] = name
|
|
339
|
+
if title: basics["title"] = title
|
|
340
|
+
if email: basics["email"] = email
|
|
341
|
+
if phone: basics["phone"] = phone
|
|
342
|
+
if location: basics["location"] = location
|
|
343
|
+
if github: basics["github"] = github
|
|
344
|
+
if all_tech:
|
|
345
|
+
basics["summary"] = (
|
|
346
|
+
f"拥有坚实的技术研发与工程化落地实战经验,"
|
|
347
|
+
f"主导过多个基于 {', '.join(all_tech[:3])} 的核心系统设计与交付。"
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
profile: Dict[str, Any] = {"basics": basics}
|
|
351
|
+
if skills: profile["skills"] = skills
|
|
352
|
+
if experience: profile["experience"] = experience
|
|
353
|
+
if projects: profile["projects"] = projects
|
|
354
|
+
if education: profile["education"] = education
|
|
308
355
|
return profile
|
|
309
356
|
|
|
310
357
|
def main():
|
|
@@ -1,142 +1,282 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
import
|
|
2
|
+
"""Run an isolated user-input resume build with explicit artifact and QA states."""
|
|
3
|
+
import argparse
|
|
4
|
+
import contextlib
|
|
5
|
+
import copy
|
|
6
|
+
import hashlib
|
|
7
|
+
import importlib.util
|
|
8
|
+
import io
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import tempfile
|
|
15
|
+
import uuid
|
|
16
|
+
from datetime import datetime, timezone
|
|
8
17
|
from pathlib import Path
|
|
9
18
|
|
|
10
|
-
|
|
11
|
-
curr = Path(__file__).resolve().parent
|
|
12
|
-
for _ in range(5):
|
|
13
|
-
if (curr / "SKILL.md").exists() or (curr / "package.json").exists():
|
|
14
|
-
return curr
|
|
15
|
-
curr = curr.parent
|
|
16
|
-
return Path.cwd()
|
|
19
|
+
ROOT = Path(__file__).resolve().parents[2]
|
|
17
20
|
|
|
18
|
-
def main():
|
|
19
|
-
parser = argparse.ArgumentParser(description="KnowMe CareerForge — One-Shot Unified Forge Engine")
|
|
20
|
-
parser.add_argument("--repo", "-r", default=".", help="Target code repository path (default: current directory)")
|
|
21
|
-
parser.add_argument("--role", help="Target job title / role (e.g. 'AI Agent Engineer', '资深前端专家')")
|
|
22
|
-
parser.add_argument("--jd", help="Path to JD file or raw JD text")
|
|
23
|
-
parser.add_argument("--template", "-t", default="minimal", help="Template ID (minimal, modern, executive, classic)")
|
|
24
|
-
parser.add_argument("--name", help="Candidate full name override")
|
|
25
|
-
parser.add_argument("--email", help="Candidate email override")
|
|
26
|
-
parser.add_argument("--phone", help="Candidate phone override")
|
|
27
|
-
parser.add_argument("--output", "-o", default="workspace/resume.pdf", help="Output PDF file path")
|
|
28
|
-
parser.add_argument("--html-output", default="workspace/resume.html", help="Intermediate working canvas HTML path")
|
|
29
|
-
parser.add_argument("--quiet", "-q", action="store_true", help="Quiet mode (compact JSON output for AI Agent)")
|
|
30
21
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
22
|
+
def load_module(name, relative):
|
|
23
|
+
spec = importlib.util.spec_from_file_location(name, ROOT / 'scripts' / relative)
|
|
24
|
+
module = importlib.util.module_from_spec(spec)
|
|
25
|
+
spec.loader.exec_module(module)
|
|
26
|
+
return module
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
contract = load_module('profile_contract', 'contracts/profile.py')
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def atomic_write(path, data):
|
|
33
|
+
path = Path(path)
|
|
34
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
fd, temp = tempfile.mkstemp(prefix='.' + path.name + '-', dir=path.parent)
|
|
36
|
+
try:
|
|
37
|
+
with os.fdopen(fd, 'wb') as stream:
|
|
38
|
+
stream.write(data if isinstance(data, bytes) else data.encode('utf-8'))
|
|
39
|
+
os.replace(temp, path)
|
|
40
|
+
finally:
|
|
41
|
+
if os.path.exists(temp):
|
|
42
|
+
os.unlink(temp)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def write_json(path, value):
|
|
46
|
+
atomic_write(path, json.dumps(value, ensure_ascii=False, indent=2) + '\n')
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def file_hash(path):
|
|
50
|
+
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def publish_aliases(pairs):
|
|
54
|
+
"""Stage every requested copy before publishing; rollback ordinary write failures.
|
|
55
|
+
|
|
56
|
+
The run directory remains canonical. Lock files reject concurrent publishers
|
|
57
|
+
to the same requested destination instead of interleaving their outputs.
|
|
58
|
+
"""
|
|
59
|
+
staged, locks, replaced = [], [], []
|
|
60
|
+
try:
|
|
61
|
+
for source, target in pairs:
|
|
62
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
lock = target.with_name('.' + target.name + '.knowme-lock')
|
|
64
|
+
fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
65
|
+
os.close(fd)
|
|
66
|
+
locks.append(lock)
|
|
67
|
+
if target.exists() and not target.is_file():
|
|
68
|
+
raise ValueError('Output is not a regular file: ' + str(target))
|
|
69
|
+
backup = target.read_bytes() if target.exists() else None
|
|
70
|
+
fd, temp = tempfile.mkstemp(prefix='.knowme-export-', dir=target.parent)
|
|
71
|
+
os.close(fd)
|
|
72
|
+
staged.append((Path(temp), target, backup))
|
|
73
|
+
shutil.copyfile(source, temp)
|
|
74
|
+
for temp, target, backup in staged:
|
|
75
|
+
os.replace(temp, target)
|
|
76
|
+
replaced.append((target, backup))
|
|
77
|
+
except Exception:
|
|
78
|
+
for target, backup in reversed(replaced):
|
|
79
|
+
if backup is None:
|
|
80
|
+
target.unlink(missing_ok=True)
|
|
71
81
|
else:
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
args.html_output, args.output
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
82
|
+
atomic_write(target, backup)
|
|
83
|
+
raise
|
|
84
|
+
finally:
|
|
85
|
+
for temp, _, _ in staged:
|
|
86
|
+
temp.unlink(missing_ok=True)
|
|
87
|
+
for lock in locks:
|
|
88
|
+
lock.unlink(missing_ok=True)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Parser(argparse.ArgumentParser):
|
|
92
|
+
def error(self, message):
|
|
93
|
+
print(json.dumps({'status': 'FAIL', 'stage': 'arguments', 'errors': [message],
|
|
94
|
+
'warnings': [], 'checks': {}, 'outputs': {}}, ensure_ascii=False))
|
|
95
|
+
self.exit(1)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def main():
|
|
99
|
+
parser = Parser(description=__doc__)
|
|
100
|
+
parser.add_argument('--profile-json', required=True, help='User/Agent supplied facts or Draft/Master document; never a repository')
|
|
101
|
+
parser.add_argument('--workspace', default='workspace/runs', help='Parent directory for unique run directories')
|
|
102
|
+
parser.add_argument('--draft', action='store_true', help='Create an incomplete draft canvas without claiming PDF delivery')
|
|
103
|
+
parser.add_argument('--role')
|
|
104
|
+
parser.add_argument('--jd', help='Optional JD path or inline text; used only as highlighting/search hints')
|
|
105
|
+
parser.add_argument('--template', '-t')
|
|
106
|
+
parser.add_argument('--font-preset', choices=('system', 'arial-unicode'), default='system', help='Explicit local font choice; missing fonts fail verification')
|
|
107
|
+
for key in ('name', 'email', 'phone'):
|
|
108
|
+
parser.add_argument('--' + key)
|
|
109
|
+
parser.add_argument('--output', '-o', help='Optional verified PDF copy; default is the isolated run PDF')
|
|
110
|
+
parser.add_argument('--html-output', help='Optional verified HTML copy; default is the isolated run canvas')
|
|
111
|
+
parser.add_argument('--expected-pages', type=int, choices=(1, 2), default=1)
|
|
112
|
+
parser.add_argument('--auto-heal', action='store_true')
|
|
113
|
+
parser.add_argument('--quiet', '-q', action='store_true')
|
|
114
|
+
parser.add_argument('--summary', action='store_true', help='Compact report with manifest path; full diagnostics stay on disk')
|
|
115
|
+
args = parser.parse_args()
|
|
116
|
+
report = {'schemaVersion': '1.0', 'runId': uuid.uuid4().hex, 'status': 'RUNNING',
|
|
117
|
+
'stage': 'input', 'errors': [], 'warnings': [], 'checks': {}, 'outputs': {}}
|
|
118
|
+
run = None
|
|
119
|
+
exit_code = 1
|
|
120
|
+
try:
|
|
121
|
+
parent = Path(args.workspace).resolve()
|
|
122
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
run = parent / report['runId']
|
|
124
|
+
run.mkdir(mode=0o700)
|
|
125
|
+
report['runDirectory'] = str(run)
|
|
126
|
+
report['startedAt'] = datetime.now(timezone.utc).isoformat()
|
|
127
|
+
write_json(run / 'manifest.json', report)
|
|
128
|
+
source = Path(args.profile_json).resolve()
|
|
129
|
+
aliases = [Path(p).resolve() for p in (args.html_output, args.output) if p]
|
|
130
|
+
if len(set([source] + aliases)) != len([source] + aliases):
|
|
131
|
+
raise ValueError('Input, HTML and PDF output paths must be distinct')
|
|
132
|
+
for target in aliases:
|
|
133
|
+
if target == run or run in target.parents:
|
|
134
|
+
raise ValueError('Explicit output cannot replace an internal run artifact')
|
|
135
|
+
raw_bytes = source.read_bytes()
|
|
136
|
+
atomic_write(run / 'input.json', raw_bytes)
|
|
137
|
+
supplied = contract.load_document(json.loads(raw_bytes))
|
|
138
|
+
if supplied['kind'] == 'variant':
|
|
139
|
+
raise ValueError('A Variant is not a Master; use its original Master to derive another resume')
|
|
140
|
+
if supplied['kind'] == 'draft' and not args.draft:
|
|
141
|
+
raise ValueError('Draft input cannot be delivered; review facts and provide a Master, or use --draft')
|
|
142
|
+
facts = copy.deepcopy(supplied['profile'])
|
|
143
|
+
for key in ('name', 'email', 'phone'):
|
|
144
|
+
if getattr(args, key) is not None:
|
|
145
|
+
facts['basics'][key] = getattr(args, key)
|
|
146
|
+
kind = 'draft' if args.draft else 'master'
|
|
147
|
+
master = contract.document(kind, facts, {'type': 'user-input', 'path': str(source),
|
|
148
|
+
'inputSha256': hashlib.sha256(raw_bytes).hexdigest(),
|
|
149
|
+
'suppliedKind': supplied['kind'], 'suppliedSource': supplied['source']})
|
|
150
|
+
write_json(run / (kind + '.json'), master)
|
|
151
|
+
report['checks']['missingFields'] = master['missingFields']
|
|
152
|
+
report['warnings'].extend('Missing optional contact/position field: ' + v for v in master['missingFields'] if v != 'basics.name')
|
|
153
|
+
if not args.draft:
|
|
154
|
+
errors = contract.delivery_errors(master['profile'])
|
|
155
|
+
if errors:
|
|
156
|
+
raise ValueError('; '.join(errors))
|
|
157
|
+
profile = copy.deepcopy(master['profile'])
|
|
158
|
+
if args.role is not None:
|
|
159
|
+
profile['basics']['title'] = args.role
|
|
160
|
+
keywords = []
|
|
161
|
+
if args.jd:
|
|
162
|
+
report['stage'] = 'jd'
|
|
163
|
+
try:
|
|
164
|
+
jd_file = Path(args.jd)
|
|
165
|
+
jd = jd_file.read_text(encoding='utf-8') if jd_file.is_file() else args.jd
|
|
166
|
+
except OSError:
|
|
167
|
+
jd = args.jd
|
|
168
|
+
atomic_write(run / 'jd.txt', jd)
|
|
169
|
+
analyzer = load_module('jd_analyzer', 'evidence/analyze-jd.py')
|
|
170
|
+
with contextlib.redirect_stdout(io.StringIO()):
|
|
171
|
+
result = analyzer.analyze_jd_text(jd)
|
|
172
|
+
write_json(run / 'jd-analysis.json', result)
|
|
173
|
+
keywords = result.get('detectedSkills', [])
|
|
174
|
+
report['stage'] = 'template'
|
|
175
|
+
template = args.template
|
|
176
|
+
if not template:
|
|
177
|
+
search = load_module('template_search', 'template/search-template.py')
|
|
178
|
+
with contextlib.redirect_stdout(io.StringIO()):
|
|
179
|
+
results = search.search_templates(role=args.role or profile['basics'].get('title') or '',
|
|
180
|
+
keywords=','.join(keywords), target_pages=args.expected_pages, engine='hybrid')
|
|
181
|
+
if not results:
|
|
182
|
+
raise ValueError('No matching template available')
|
|
183
|
+
template = results[0].get('id') or results[0]['template']['id']
|
|
184
|
+
variant = contract.document('draft' if args.draft else 'variant', profile,
|
|
185
|
+
{'masterSha256': master['profileSha256'], 'masterPath': str(run / (kind + '.json')),
|
|
186
|
+
'template': template, 'targetRole': args.role, 'fontPreset': args.font_preset})
|
|
187
|
+
variant_path = run / ('draft-canvas-profile.json' if args.draft else 'variant.json')
|
|
188
|
+
write_json(variant_path, variant)
|
|
189
|
+
report['templateUsed'] = template
|
|
190
|
+
report['fontPreset'] = args.font_preset
|
|
191
|
+
report['stage'] = 'binding'
|
|
192
|
+
inst = load_module('instantiate_resume', 'template/instantiate-resume.py')
|
|
193
|
+
canvas, pdf = run / 'resume.html', run / 'resume.pdf'
|
|
194
|
+
with contextlib.redirect_stdout(io.StringIO()):
|
|
195
|
+
inst.instantiate_workspace(template, str(variant_path), ','.join(keywords), str(canvas), font_preset=args.font_preset)
|
|
196
|
+
if args.draft:
|
|
197
|
+
report.update(status='DRAFT', stage='draft', outputs={'draft': str(run / 'draft.json'), 'htmlCanvas': str(canvas)})
|
|
198
|
+
exit_code = 0
|
|
199
|
+
else:
|
|
200
|
+
report['stage'] = 'qa'
|
|
201
|
+
cmd = ['node', str(ROOT / 'scripts/rendering/browser-engine.js'), str(canvas),
|
|
202
|
+
'--expected-pages', str(args.expected_pages), '--output', str(pdf)]
|
|
203
|
+
if args.auto_heal:
|
|
204
|
+
cmd.append('--auto-heal')
|
|
205
|
+
process = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
|
206
|
+
try:
|
|
207
|
+
qa = json.loads(process.stdout)
|
|
208
|
+
if not isinstance(qa, dict):
|
|
209
|
+
raise ValueError('QA result must be an object')
|
|
210
|
+
expected_exit = {'PASS': 0, 'FAIL': 1, 'UNVERIFIED': 2}.get(qa.get('status'))
|
|
211
|
+
if expected_exit is None or process.returncode != expected_exit:
|
|
212
|
+
raise ValueError('QA status and exit code disagree')
|
|
213
|
+
if (not isinstance(qa.get('checks'), dict)
|
|
214
|
+
or any(not isinstance(v, dict) for v in qa['checks'].values())
|
|
215
|
+
or any(not isinstance(qa.get(key), list) or any(not isinstance(v, str) for v in qa[key])
|
|
216
|
+
for key in ('errors', 'warnings'))):
|
|
217
|
+
raise ValueError('Malformed QA diagnostics')
|
|
218
|
+
except (ValueError, TypeError) as error:
|
|
219
|
+
report['status'] = 'UNVERIFIED'
|
|
220
|
+
raise ValueError('Browser returned no valid QA result: ' + str(error) + '; ' + process.stderr[:400])
|
|
221
|
+
write_json(run / 'qa.json', qa)
|
|
222
|
+
report['checks']['qa'] = qa
|
|
223
|
+
report['warnings'].extend(qa.get('warnings', []))
|
|
224
|
+
if process.returncode != 0 or qa.get('status') != 'PASS':
|
|
225
|
+
report['status'] = 'UNVERIFIED' if qa.get('status') == 'UNVERIFIED' else 'FAIL'
|
|
226
|
+
raise ValueError('; '.join(qa.get('errors', [])) or 'PDF validation did not pass')
|
|
227
|
+
checks = qa.get('checks', {})
|
|
228
|
+
required_checks = ('input', 'fonts', 'dom', 'pdf', 'text', 'output')
|
|
229
|
+
if qa.get('errors') or any(checks.get(key, {}).get('status') != 'PASS' for key in required_checks):
|
|
230
|
+
report['status'] = 'UNVERIFIED'
|
|
231
|
+
raise ValueError('Incomplete or contradictory QA result; refusing delivery')
|
|
232
|
+
accepted = checks['output']
|
|
233
|
+
if not accepted.get('committed') or accepted.get('path') != str(pdf):
|
|
234
|
+
report['status'] = 'UNVERIFIED'
|
|
235
|
+
raise ValueError('QA did not commit this run PDF')
|
|
236
|
+
if not pdf.is_file() or not pdf.read_bytes().startswith(b'%PDF-'):
|
|
237
|
+
raise ValueError('Verified PDF was not produced by this run')
|
|
238
|
+
if accepted.get('pdfSha256') != file_hash(pdf) or accepted.get('htmlSha256') != file_hash(canvas):
|
|
239
|
+
raise ValueError('Output changed after QA or acceptance hashes are missing')
|
|
240
|
+
report['stage'] = 'publish'
|
|
241
|
+
pairs = []
|
|
242
|
+
if args.html_output:
|
|
243
|
+
pairs.append((canvas, Path(args.html_output).resolve()))
|
|
244
|
+
if args.output:
|
|
245
|
+
pairs.append((pdf, Path(args.output).resolve()))
|
|
246
|
+
publish_aliases(pairs)
|
|
247
|
+
report.update(status='PASS', stage='complete', outputs={
|
|
248
|
+
'master': str(run / 'master.json'), 'variant': str(variant_path),
|
|
249
|
+
'htmlCanvas': str(canvas), 'pdfDelivery': str(pdf), 'qa': str(run / 'qa.json'),
|
|
250
|
+
'copies': [str(target) for _, target in pairs]},
|
|
251
|
+
sha256={'html': file_hash(canvas), 'pdf': file_hash(pdf), 'master': master['profileSha256']})
|
|
252
|
+
exit_code = 0
|
|
253
|
+
except Exception as error:
|
|
254
|
+
if report['status'] == 'RUNNING':
|
|
255
|
+
report['status'] = 'UNVERIFIED' if (report['stage'] == 'qa' and isinstance(error, (subprocess.TimeoutExpired, FileNotFoundError))) or not isinstance(error, (OSError, ValueError, TypeError, RuntimeError)) else 'FAIL'
|
|
256
|
+
report['errors'].append(str(error))
|
|
257
|
+
report['outputs'] = {}
|
|
258
|
+
exit_code = 2 if report['status'] == 'UNVERIFIED' else 1
|
|
259
|
+
finally:
|
|
260
|
+
report['finishedAt'] = datetime.now(timezone.utc).isoformat()
|
|
261
|
+
if run:
|
|
262
|
+
try:
|
|
263
|
+
write_json(run / 'manifest.json', report)
|
|
264
|
+
except OSError as error:
|
|
265
|
+
report.update(status='FAIL', outputs={})
|
|
266
|
+
report['errors'].append('Could not persist manifest: ' + str(error))
|
|
267
|
+
exit_code = 1
|
|
268
|
+
emitted = report
|
|
269
|
+
if args.summary:
|
|
270
|
+
emitted = {key: report[key] for key in ('runId', 'status', 'stage', 'templateUsed', 'fontPreset', 'errors', 'warnings', 'outputs') if key in report}
|
|
271
|
+
emitted['outputs'] = {}
|
|
272
|
+
if report['status'] in ('PASS', 'DRAFT'):
|
|
273
|
+
emitted['outputs']['html'] = str(Path(args.html_output).resolve()) if report['status'] == 'PASS' and args.html_output else report['outputs']['htmlCanvas']
|
|
274
|
+
if report['status'] == 'PASS':
|
|
275
|
+
emitted['outputs']['pdf'] = str(Path(args.output).resolve()) if args.output else report['outputs']['pdfDelivery']
|
|
276
|
+
emitted['manifest'] = str(run / 'manifest.json') if run else None
|
|
277
|
+
print(json.dumps(emitted, ensure_ascii=False, indent=None if args.quiet else 2))
|
|
278
|
+
return exit_code
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
if __name__ == '__main__':
|
|
282
|
+
sys.exit(main())
|