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
|
@@ -1,184 +1,277 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
"""Bind supplied facts to independent blank canvases; publish only complete output.
|
|
3
|
+
|
|
4
|
+
canvas.html owns layout, not candidate content. Each <!-- resume:NAME --> slot
|
|
5
|
+
must occur exactly once. sample-profile.json is gallery-only input, never an automatic fallback.
|
|
6
|
+
The shared profile contract validates and normalizes every input document.
|
|
7
|
+
Omitting --profile deliberately produces a blank canvas.
|
|
5
8
|
"""
|
|
6
9
|
|
|
7
|
-
import
|
|
10
|
+
import argparse
|
|
11
|
+
from collections import Counter
|
|
12
|
+
from html import escape
|
|
13
|
+
from html.parser import HTMLParser
|
|
14
|
+
import json
|
|
15
|
+
import importlib.util
|
|
16
|
+
import os
|
|
8
17
|
from pathlib import Path
|
|
9
|
-
|
|
18
|
+
import re
|
|
19
|
+
import sys
|
|
20
|
+
import tempfile
|
|
21
|
+
|
|
22
|
+
SLOTS = {"styles", "document-title", "basics", "summary", "skills",
|
|
23
|
+
"experience", "projects", "education", "certifications", "language"}
|
|
24
|
+
SLOT = re.compile(r"<!-- resume:([a-z-]+) -->|{{ resume:(language) }}")
|
|
25
|
+
LABELS = {
|
|
26
|
+
"zh": {"document-title": "个人简历", "summary": "个人简介", "skills": "专业技能",
|
|
27
|
+
"experience": "工作经历", "projects": "项目经历", "education": "教育背景", "certifications": "证书"},
|
|
28
|
+
"en": {"document-title": "Resume", "summary": "Summary", "skills": "Skills",
|
|
29
|
+
"experience": "Experience", "projects": "Projects", "education": "Education", "certifications": "Certifications"},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
FONT_PRESETS = {
|
|
35
|
+
"system": "",
|
|
36
|
+
"arial-unicode": """/* Explicit local font: a missing face must fail font QA, not silently pass. */
|
|
37
|
+
@font-face { font-family: "KnowMe Unicode"; src: local("Arial Unicode MS"); font-weight: 400; font-style: normal; }
|
|
38
|
+
:root { --primitive-font-sans: "KnowMe Unicode", Arial, sans-serif; --resume-font-body: "KnowMe Unicode", Arial, sans-serif; }
|
|
39
|
+
""",
|
|
40
|
+
}
|
|
41
|
+
|
|
10
42
|
|
|
11
43
|
def get_project_root() -> Path:
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def
|
|
30
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
'<
|
|
148
|
-
|
|
149
|
-
)
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
44
|
+
return Path(__file__).resolve().parents[2]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CanvasContract(HTMLParser):
|
|
48
|
+
"""Reject absent, duplicate, unknown or non-content-context binding slots."""
|
|
49
|
+
|
|
50
|
+
def __init__(self):
|
|
51
|
+
super().__init__(convert_charrefs=True)
|
|
52
|
+
self.stack = []
|
|
53
|
+
self.slots = []
|
|
54
|
+
|
|
55
|
+
def handle_starttag(self, tag, attrs):
|
|
56
|
+
if tag == "html" and not self.stack and attrs.count(("lang", "{{ resume:language }}")) == 1:
|
|
57
|
+
self.slots.append("language")
|
|
58
|
+
if tag not in {"meta", "link", "br", "hr", "img", "input", "wbr"}:
|
|
59
|
+
self.stack.append(tag)
|
|
60
|
+
|
|
61
|
+
def handle_endtag(self, tag):
|
|
62
|
+
if not self.stack or self.stack.pop() != tag:
|
|
63
|
+
raise ValueError(f"Malformed canvas closing tag: {tag}")
|
|
64
|
+
|
|
65
|
+
def handle_comment(self, data):
|
|
66
|
+
if not data.strip().startswith("resume:"):
|
|
67
|
+
return
|
|
68
|
+
name = data.strip()[7:]
|
|
69
|
+
parent = self.stack[-1] if self.stack else None
|
|
70
|
+
valid = (parent == "head" if name == "styles" else
|
|
71
|
+
parent == "title" if name == "document-title" else
|
|
72
|
+
"body" in self.stack and parent in {"main", "aside", "div", "header", "section", "td", "body"})
|
|
73
|
+
if not valid:
|
|
74
|
+
raise ValueError(f"Invalid canvas slot context: {name}")
|
|
75
|
+
self.slots.append(name)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def validate_canvas(canvas):
|
|
79
|
+
parser = CanvasContract()
|
|
80
|
+
parser.feed(canvas)
|
|
81
|
+
parser.close()
|
|
82
|
+
expected = Counter(SLOTS)
|
|
83
|
+
if parser.stack or Counter(parser.slots) != expected or Counter(m.group(1) or m.group(2) for m in SLOT.finditer(canvas)) != expected:
|
|
84
|
+
raise ValueError("Canvas must contain every named resume slot exactly once")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def profile_contract():
|
|
88
|
+
path = get_project_root() / "scripts" / "contracts" / "profile.py"
|
|
89
|
+
spec = importlib.util.spec_from_file_location("careerforge_profile_contract", path)
|
|
90
|
+
module = importlib.util.module_from_spec(spec)
|
|
91
|
+
spec.loader.exec_module(module)
|
|
92
|
+
return module
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def text_field(obj, key):
|
|
96
|
+
value = obj.get(key)
|
|
97
|
+
return "" if value is None else str(value)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def records(obj, key):
|
|
101
|
+
return obj.get(key) or []
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def strings(obj, key):
|
|
105
|
+
return obj.get(key) or []
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def highlight_keywords(text, keywords):
|
|
109
|
+
"""Match plain text once, then escape each slice; never search generated HTML."""
|
|
110
|
+
words = sorted({kw.strip() for kw in keywords if kw.strip()}, key=lambda kw: (-len(kw), kw))
|
|
111
|
+
if not words:
|
|
112
|
+
return escape(text, quote=True)
|
|
113
|
+
alternatives = [
|
|
114
|
+
(r"(?<!\w)" if re.match(r"\w", word[0]) else "") + re.escape(word)
|
|
115
|
+
+ (r"(?!\w)" if re.match(r"\w", word[-1]) else "")
|
|
116
|
+
for word in words
|
|
117
|
+
]
|
|
118
|
+
pattern = re.compile("|".join(alternatives), re.I)
|
|
119
|
+
parts, offset = [], 0
|
|
120
|
+
for match in pattern.finditer(text):
|
|
121
|
+
parts.extend((escape(text[offset:match.start()]), "<strong>", escape(match.group()), "</strong>"))
|
|
122
|
+
offset = match.end()
|
|
123
|
+
parts.append(escape(text[offset:]))
|
|
124
|
+
return "".join(parts)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def render_profile_into_html(html_str, profile, keywords=None, *, css=""):
|
|
128
|
+
validate_canvas(html_str)
|
|
129
|
+
profile = profile_contract().normalize_profile(profile)
|
|
130
|
+
language = profile.get("language") or "zh-CN"
|
|
131
|
+
if not re.fullmatch(r"(?:en|zh)(?:-[A-Za-z0-9]{1,8})*", language, re.I):
|
|
132
|
+
raise ValueError("Unsupported canvas language; use an en or zh language tag")
|
|
133
|
+
labels = LABELS[language.split("-")[0].lower()]
|
|
134
|
+
basics = profile.get("basics") or {}
|
|
135
|
+
keywords = keywords or []
|
|
136
|
+
|
|
137
|
+
def element(tag, cls, value):
|
|
138
|
+
return f'<{tag} class="{cls}">{highlight_keywords(value, keywords)}</{tag}>' if value else ""
|
|
139
|
+
|
|
140
|
+
def field(obj, key, cls="fact-text", tag="p"):
|
|
141
|
+
return element(tag, cls, text_field(obj, key))
|
|
142
|
+
|
|
143
|
+
def section(key, content):
|
|
144
|
+
label = labels[key]
|
|
145
|
+
if not content:
|
|
146
|
+
return ""
|
|
147
|
+
return f'<section class="resume-section" id="{key}"><h2 class="section-title">{label}</h2>{content}</section>'
|
|
148
|
+
|
|
149
|
+
def bullets(obj):
|
|
150
|
+
content = "".join(field(b, "text", "fact-bullet", "li") for b in records(obj, "bullets"))
|
|
151
|
+
return f'<ul class="bullet-list">{content}</ul>' if content else ""
|
|
152
|
+
|
|
153
|
+
def dates(obj):
|
|
154
|
+
return element("span", "date-range", " – ".join(filter(None, (text_field(obj, "startDate"), text_field(obj, "endDate")))))
|
|
155
|
+
|
|
156
|
+
def entries(key, name_key):
|
|
157
|
+
rendered = []
|
|
158
|
+
for item in records(profile, key):
|
|
159
|
+
heading = field(item, name_key, "org-name", "span")
|
|
160
|
+
heading += "".join(field(item, k, "role-badge", "span") for k in ("role", "degree", "field"))
|
|
161
|
+
heading += dates(item)
|
|
162
|
+
content = f'<div class="item-header">{heading}</div>' if heading else ""
|
|
163
|
+
content += field(item, "location") + field(item, "summary")
|
|
164
|
+
gpa = item.get("gpa")
|
|
165
|
+
if gpa is not None and gpa != "":
|
|
166
|
+
content += element("p", "edu-detail", f"GPA: {gpa}")
|
|
167
|
+
content += field(item, "repoUrl") + field(item, "demoUrl")
|
|
168
|
+
content += "".join(element("span", "tech-tag", t) for t in strings(item, "techStack"))
|
|
169
|
+
content += "".join(element("p", "edu-detail", honor) for honor in strings(item, "honors"))
|
|
170
|
+
content += bullets(item)
|
|
171
|
+
if content:
|
|
172
|
+
rendered.append(f'<div class="{key[:-1] if key == "projects" else key}-item">{content}</div>')
|
|
173
|
+
return "".join(rendered)
|
|
174
|
+
|
|
175
|
+
name = text_field(basics, "name")
|
|
176
|
+
basic_html = element("h1", "candidate-name", name) + field(basics, "title", "job-target")
|
|
177
|
+
contacts = "".join(field(basics, k, "contact-item", "span") for k in ("phone", "email", "location", "github", "website"))
|
|
178
|
+
if contacts:
|
|
179
|
+
basic_html += f'<div class="contact-grid">{contacts}</div>'
|
|
180
|
+
skill_rows = []
|
|
181
|
+
for skill in records(profile, "skills"):
|
|
182
|
+
category = field(skill, "category", "skill-category", "span")
|
|
183
|
+
items = strings(skill, "items")
|
|
184
|
+
# highlighted is metadata selecting emphasis, never an additional source of facts.
|
|
185
|
+
content = category + element("span", "skill-items", ("; " if language.lower().startswith("en") else "、").join(items))
|
|
186
|
+
if content:
|
|
187
|
+
skill_rows.append(f'<div class="skill-row">{content}</div>')
|
|
188
|
+
values = {
|
|
189
|
+
"styles": f"<style>\n{css}\n</style>",
|
|
190
|
+
"language": escape(language, quote=True),
|
|
191
|
+
"document-title": escape(name + " - " + labels["document-title"] if name else labels["document-title"]),
|
|
192
|
+
"basics": f'<header class="resume-header">{basic_html}</header>' if basic_html else "",
|
|
193
|
+
"summary": section("summary", field(basics, "summary", "value-prop")),
|
|
194
|
+
"skills": section("skills", "".join(skill_rows)),
|
|
195
|
+
"experience": section("experience", entries("experience", "company")),
|
|
196
|
+
"projects": section("projects", entries("projects", "name")),
|
|
197
|
+
"education": section("education", entries("education", "institution")),
|
|
198
|
+
"certifications": section("certifications", "".join(element("p", "fact-text", item) for item in strings(profile, "certifications"))),
|
|
199
|
+
}
|
|
200
|
+
# One substitution pass: user text resembling a slot is never interpreted.
|
|
201
|
+
return SLOT.sub(lambda match: values[match.group(1) or match.group(2)], html_str)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def resolve_template(root, template_id):
|
|
205
|
+
templates = root / "src" / "templates"
|
|
206
|
+
for folder in sorted(templates.iterdir()):
|
|
207
|
+
if not folder.is_dir() or folder.name == "common":
|
|
208
|
+
continue
|
|
209
|
+
if folder.name == template_id:
|
|
210
|
+
return folder
|
|
211
|
+
metadata = folder / "metadata.json"
|
|
212
|
+
if metadata.exists() and json.loads(metadata.read_text(encoding="utf-8")).get("id") == template_id:
|
|
213
|
+
return folder
|
|
214
|
+
raise ValueError(f"Template '{template_id}' not found")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def instantiate_workspace(template_id, profile_path=None, keywords=None, output_path="workspace/resume.html", quiet=False, font_preset="system"):
|
|
218
|
+
if font_preset not in FONT_PRESETS:
|
|
219
|
+
raise ValueError("Unknown font preset: " + str(font_preset))
|
|
220
|
+
root = get_project_root()
|
|
221
|
+
folder = resolve_template(root, template_id)
|
|
222
|
+
canvas = (folder / "canvas.html").read_text(encoding="utf-8")
|
|
223
|
+
common = root / "src" / "templates" / "common"
|
|
224
|
+
css = "\n".join(path.read_text(encoding="utf-8") for path in
|
|
225
|
+
(common / "base.css", folder / "style.css", common / "canvas-bindings.css"))
|
|
226
|
+
css += "\n" + FONT_PRESETS[font_preset]
|
|
227
|
+
profile = json.loads(Path(profile_path).read_text(encoding="utf-8")) if profile_path is not None else {}
|
|
228
|
+
profile = profile_contract().load_document(profile)["profile"]
|
|
229
|
+
rendered = render_profile_into_html(canvas, profile, keywords.split(",") if keywords else [], css=css)
|
|
230
|
+
output = Path(output_path)
|
|
231
|
+
if profile_path is not None and output.resolve() == Path(profile_path).resolve():
|
|
232
|
+
raise ValueError("Output must not overwrite the input profile")
|
|
233
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
234
|
+
temporary = None
|
|
235
|
+
try:
|
|
236
|
+
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=output.parent,
|
|
237
|
+
prefix=f".{output.name}.", suffix=".tmp", delete=False) as stream:
|
|
238
|
+
temporary = Path(stream.name)
|
|
239
|
+
stream.write(rendered)
|
|
240
|
+
stream.flush()
|
|
241
|
+
os.fsync(stream.fileno())
|
|
242
|
+
os.replace(temporary, output)
|
|
243
|
+
finally:
|
|
244
|
+
if temporary is not None:
|
|
245
|
+
temporary.unlink(missing_ok=True)
|
|
246
|
+
if not quiet:
|
|
247
|
+
print(f"Canvas written: {output.resolve()}")
|
|
248
|
+
return output
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class CLIParser(argparse.ArgumentParser):
|
|
252
|
+
def error(self, message):
|
|
253
|
+
raise ValueError(message)
|
|
254
|
+
|
|
172
255
|
|
|
173
256
|
def main():
|
|
174
|
-
parser =
|
|
175
|
-
parser.add_argument("--template", "-t", required=True, help="Template
|
|
176
|
-
parser.add_argument("--profile", "-p",
|
|
177
|
-
parser.add_argument("--keywords", "-k",
|
|
178
|
-
parser.add_argument("--output", "-o", default="workspace/resume.html"
|
|
257
|
+
parser = CLIParser(description=__doc__)
|
|
258
|
+
parser.add_argument("--template", "-t", required=True, help="Template directory name or metadata ID")
|
|
259
|
+
parser.add_argument("--profile", "-p", help="Candidate JSON; omitted produces a blank canvas")
|
|
260
|
+
parser.add_argument("--keywords", "-k", help="Comma-separated plain-text keywords")
|
|
261
|
+
parser.add_argument("--output", "-o", default="workspace/resume.html")
|
|
262
|
+
parser.add_argument("--font-preset", choices=sorted(FONT_PRESETS), default="system")
|
|
263
|
+
parser.add_argument("--quiet", "-q", action="store_true", help="Suppress human diagnostics; JSON result is always emitted")
|
|
264
|
+
result = {"status": "FAIL", "errors": [], "warnings": [], "checks": {"canvasWritten": False}}
|
|
265
|
+
try:
|
|
266
|
+
args = parser.parse_args()
|
|
267
|
+
output = instantiate_workspace(args.template, args.profile, args.keywords, args.output, quiet=True, font_preset=args.font_preset)
|
|
268
|
+
result.update(status="PASS", output=str(output.resolve()))
|
|
269
|
+
result["checks"]["canvasWritten"] = True
|
|
270
|
+
except (OSError, ValueError, TypeError) as error:
|
|
271
|
+
result["errors"].append(str(error))
|
|
272
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
273
|
+
return 0 if result["status"] == "PASS" else 1
|
|
179
274
|
|
|
180
|
-
args = parser.parse_args()
|
|
181
|
-
instantiate_workspace(args.template, profile_path=args.profile, keywords=args.keywords, output_path=args.output)
|
|
182
275
|
|
|
183
276
|
if __name__ == "__main__":
|
|
184
|
-
main()
|
|
277
|
+
sys.exit(main())
|
|
@@ -105,15 +105,24 @@ class WeightedRuleScorer(BaseTemplateScorer):
|
|
|
105
105
|
|
|
106
106
|
if role_score < 0.5:
|
|
107
107
|
cat = template.get("roleCategory", "")
|
|
108
|
-
if any(k in query_lower for k in ["
|
|
108
|
+
if any(k in query_lower for k in ["research", "scientist", "algorithm", "phd", "academic", "postdoc", "算法", "科研", "学者", "研究员"]) and "research" in cat:
|
|
109
|
+
role_score = 0.90
|
|
110
|
+
elif any(k in query_lower for k in ["remote", "international", "global", "overseas", "foreign", "外企", "海外", "跨国", "远程"]) and "international" in cat:
|
|
111
|
+
role_score = 0.90
|
|
112
|
+
elif any(k in query_lower for k in ["creative", "ui", "ux", "design", "interaction", "动效", "交互", "视觉"]) and "creative" in cat:
|
|
113
|
+
role_score = 0.90
|
|
114
|
+
elif any(k in query_lower for k in ["data", "analyst", "bi", "analytics", "metric", "数据", "商业智能", "数仓"]) and "data" in cat:
|
|
115
|
+
role_score = 0.90
|
|
116
|
+
elif any(k in query_lower for k in ["startup", "indie", "growth", "founder", "hacker", "初创", "增长", "独立开发"]) and "startup" in cat:
|
|
117
|
+
role_score = 0.90
|
|
118
|
+
elif any(k in query_lower for k in ["ai", "agent", "llm", "frontend", "backend", "fullstack", "devops", "engineer"]) and "engineering" in cat:
|
|
109
119
|
role_score = 0.85
|
|
110
|
-
elif any(k in query_lower for k in ["manager", "product", "lead", "director", "architect", "cto"]) and "management" in cat:
|
|
120
|
+
elif any(k in query_lower for k in ["manager", "product", "lead", "director", "architect", "cto", "高管", "总监"]) and "management" in cat:
|
|
111
121
|
role_score = 0.85
|
|
112
122
|
elif any(k in query_lower for k in ["bank", "fintech", "state", "corp", "government"]) and "corporate" in cat:
|
|
113
123
|
role_score = 0.85
|
|
114
124
|
else:
|
|
115
125
|
role_score = 0.60
|
|
116
|
-
|
|
117
126
|
score += role_score * self.weights["role"]
|
|
118
127
|
|
|
119
128
|
# 2. Style Match
|
|
@@ -238,6 +247,70 @@ class HybridTemplateScorer(BaseTemplateScorer):
|
|
|
238
247
|
return round(blended, 1)
|
|
239
248
|
|
|
240
249
|
|
|
250
|
+
PALETTE_PRESETS: Dict[str, Dict[str, Any]] = {
|
|
251
|
+
"--palette-tech-blue": {
|
|
252
|
+
"name": "经典科技蓝",
|
|
253
|
+
"token": "--palette-tech-blue",
|
|
254
|
+
"hex": "#2563eb",
|
|
255
|
+
"tone": "tech-blue",
|
|
256
|
+
"keywords": ["engineering", "frontend", "backend", "fullstack", "devops", "cloud", "software", "前端", "后端", "全栈"]
|
|
257
|
+
},
|
|
258
|
+
"--palette-deep-navy": {
|
|
259
|
+
"name": "深邃海航蓝",
|
|
260
|
+
"token": "--palette-deep-navy",
|
|
261
|
+
"hex": "#1e3a8a",
|
|
262
|
+
"tone": "oxford-navy",
|
|
263
|
+
"keywords": ["research", "academic", "algorithm", "phd", "scientist", "ai-agent", "llm", "cv", "nlp", "算法", "科研", "学者", "博士"]
|
|
264
|
+
},
|
|
265
|
+
"--palette-teal-modern": {
|
|
266
|
+
"name": "现代松石绿",
|
|
267
|
+
"token": "--palette-teal-modern",
|
|
268
|
+
"hex": "#0f766e",
|
|
269
|
+
"tone": "executive-formal",
|
|
270
|
+
"keywords": ["executive", "management", "cto", "director", "lead", "product", "architect", "高管", "总监", "架构师", "产品经理"]
|
|
271
|
+
},
|
|
272
|
+
"--palette-emerald-fresh": {
|
|
273
|
+
"name": "活力翡翠绿",
|
|
274
|
+
"token": "--palette-emerald-fresh",
|
|
275
|
+
"hex": "#059669",
|
|
276
|
+
"tone": "emerald-green",
|
|
277
|
+
"keywords": ["startup", "generalist", "indie", "growth", "founder", "hacker", "初创", "独立开发", "增长"]
|
|
278
|
+
},
|
|
279
|
+
"--palette-violet-creative": {
|
|
280
|
+
"name": "创意极光紫",
|
|
281
|
+
"token": "--palette-violet-creative",
|
|
282
|
+
"hex": "#7c3aed",
|
|
283
|
+
"tone": "tech-violet",
|
|
284
|
+
"keywords": ["creative", "ui", "ux", "design", "interaction", "threejs", "webgl", "motion", "动效", "视觉", "体验"]
|
|
285
|
+
},
|
|
286
|
+
"--palette-slate-minimal": {
|
|
287
|
+
"name": "极简碳素灰",
|
|
288
|
+
"token": "--palette-slate-minimal",
|
|
289
|
+
"hex": "#334155",
|
|
290
|
+
"tone": "neutral-slate",
|
|
291
|
+
"keywords": ["international", "remote", "global", "overseas", "minimal", "compact", "dense", "外企", "海外", "远程", "极简"]
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
def recommend_palette(role: str, template: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
296
|
+
"""Recommend official palette preset based on role keywords and template style."""
|
|
297
|
+
role_lower = (role or "").lower()
|
|
298
|
+
template_tone = (template.get("visualStyle", {}).get("tone", "") if template else "").lower()
|
|
299
|
+
template_cat = (template.get("roleCategory", "") if template else "").lower()
|
|
300
|
+
|
|
301
|
+
# 1. Match against palette keywords
|
|
302
|
+
for token, p in PALETTE_PRESETS.items():
|
|
303
|
+
if any(kw in role_lower for kw in p["keywords"]):
|
|
304
|
+
return p
|
|
305
|
+
|
|
306
|
+
# 2. Match template visual tone
|
|
307
|
+
for token, p in PALETTE_PRESETS.items():
|
|
308
|
+
if p["tone"] in template_tone or template_tone in p["tone"]:
|
|
309
|
+
return p
|
|
310
|
+
|
|
311
|
+
# 3. Default fallback
|
|
312
|
+
return PALETTE_PRESETS["--palette-tech-blue"]
|
|
313
|
+
|
|
241
314
|
# ==============================================================================
|
|
242
315
|
# 2. PUBLIC FACADE INTERFACE (DEEP MODULE)
|
|
243
316
|
# ==============================================================================
|
|
@@ -289,6 +362,7 @@ def search_templates(
|
|
|
289
362
|
results = []
|
|
290
363
|
for t in templates:
|
|
291
364
|
t_copy = dict(t)
|
|
365
|
+
t_copy["recommendedPalette"] = recommend_palette(role, t)
|
|
292
366
|
score_val = scorer.score(t, query)
|
|
293
367
|
t_copy["matchScore"] = score_val
|
|
294
368
|
t_copy["score"] = score_val
|
|
@@ -308,6 +382,7 @@ def main():
|
|
|
308
382
|
parser.add_argument("--engine", "-e", default="hybrid", choices=["hybrid", "weighted", "bm25"], help="Scoring engine")
|
|
309
383
|
parser.add_argument("--json", action="store_true", help="Output raw JSON array")
|
|
310
384
|
|
|
385
|
+
parser.add_argument("--summary", action="store_true", help="Compact JSON with the top three ranked layouts; no full metadata")
|
|
311
386
|
args = parser.parse_args()
|
|
312
387
|
target_role = args.named_role or args.role
|
|
313
388
|
if not target_role:
|
|
@@ -322,6 +397,14 @@ def main():
|
|
|
322
397
|
engine=args.engine
|
|
323
398
|
)
|
|
324
399
|
|
|
400
|
+
if args.summary:
|
|
401
|
+
print(json.dumps({"totalMatches": len(results), "candidates": [
|
|
402
|
+
{"id": item["id"], "name": item.get("name"), "score": item["matchScore"],
|
|
403
|
+
"layout": item.get("layout", {})}
|
|
404
|
+
for item in results[:3]
|
|
405
|
+
]}, ensure_ascii=False, indent=2))
|
|
406
|
+
return
|
|
407
|
+
|
|
325
408
|
if args.json:
|
|
326
409
|
print(json.dumps(results, ensure_ascii=False, indent=2))
|
|
327
410
|
return
|
|
@@ -341,6 +424,9 @@ def main():
|
|
|
341
424
|
print(f" Category: {r.get('roleCategory')} | Style: {r.get('style')} | ATS Tier: {r.get('atsScoreTier')}")
|
|
342
425
|
print(f" Target Pages: {r.get('layout', {}).get('targetPages', 1)} | Density: {r.get('layout', {}).get('density')}")
|
|
343
426
|
print(f" Directory: {r.get('_directory')}")
|
|
427
|
+
pal = r.get("recommendedPalette")
|
|
428
|
+
if pal:
|
|
429
|
+
print(f" Palette : {pal.get('name')} ({pal.get('hex')}) [Token: {pal.get('token')}]")
|
|
344
430
|
if tokens_preview:
|
|
345
431
|
print(f" Tokens : {tokens_preview}")
|
|
346
432
|
print("-" * 80)
|