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.
Files changed (117) hide show
  1. package/AGENT.md +276 -0
  2. package/ARCHITECTURE.md +220 -0
  3. package/README.md +46 -63
  4. package/README.zh-CN.md +47 -66
  5. package/SKILL.md +38 -135
  6. package/agents/claude/knowme-careerforge.md +2 -17
  7. package/agents/codex/knowme-careerforge.yaml +2 -20
  8. package/agents/cursor/knowme-careerforge.mdc +4 -13
  9. package/agents/gemini/knowme-careerforge.json +1 -1
  10. package/agents/opencode/skill.yaml +1 -1
  11. package/agents/windsurf/knowme-careerforge.rules +2 -12
  12. package/dist/cli/src/commands/init.js +51 -32
  13. package/dist/cli/src/index.js +25 -21
  14. package/dist/scripts/rendering/engine-loader.d.ts +2 -0
  15. package/dist/scripts/rendering/engine-loader.js +46 -0
  16. package/dist/scripts/rendering/render-pdf.d.ts +3 -8
  17. package/dist/scripts/rendering/render-pdf.js +7 -103
  18. package/dist/scripts/rendering/runtime.d.ts +9 -0
  19. package/dist/scripts/rendering/runtime.js +63 -0
  20. package/dist/scripts/validation/validate-ats.d.ts +2 -22
  21. package/dist/scripts/validation/validate-ats.js +6 -168
  22. package/dist/scripts/validation/validate-layout.d.ts +2 -23
  23. package/dist/scripts/validation/validate-layout.js +5 -159
  24. package/package.json +15 -4
  25. package/references/01-evidence-mining.md +35 -0
  26. package/references/02-career-goal.md +40 -0
  27. package/references/03-jd-analysis.md +32 -0
  28. package/references/04-template-selection.md +30 -0
  29. package/references/05-html-canvas-tokens.md +12 -0
  30. package/references/06-qa-and-rendering.md +27 -0
  31. package/references/07-artifact-contract.md +83 -0
  32. package/scripts/Agent.md +9 -136
  33. package/scripts/build/build-gallery.py +66 -56
  34. package/scripts/build/run-all-tests.py +1 -0
  35. package/scripts/build/sync-version.py +27 -9
  36. package/scripts/contracts/profile.py +101 -0
  37. package/scripts/evidence/extract-evidence.py +109 -62
  38. package/scripts/pipeline/forge.py +276 -136
  39. package/scripts/rendering/README.md +65 -0
  40. package/scripts/rendering/browser-engine.d.ts +15 -0
  41. package/scripts/rendering/browser-engine.js +408 -0
  42. package/scripts/rendering/browser_engine.py +39 -0
  43. package/scripts/rendering/engine-loader.ts +11 -0
  44. package/scripts/rendering/render-pdf.py +26 -151
  45. package/scripts/rendering/render-pdf.ts +7 -83
  46. package/scripts/template/instantiate-resume.py +266 -173
  47. package/scripts/template/search-template.py +89 -3
  48. package/scripts/validation/measure-dom.js +5 -0
  49. package/scripts/validation/validate-ats.ts +6 -174
  50. package/scripts/validation/validate-layout.ts +5 -159
  51. package/scripts/validation/validate-resume.py +23 -134
  52. package/skill.json +7 -6
  53. package/src/knowledge/index.json +391 -3
  54. package/src/knowledge/resume-schema.json +377 -71
  55. package/src/knowledge/templates.json +389 -1
  56. package/src/references/01-evidence-mining.md +24 -30
  57. package/src/references/02-career-goal.md +4 -2
  58. package/src/references/03-jd-analysis.md +5 -17
  59. package/src/references/04-template-selection.md +20 -33
  60. package/src/references/05-html-canvas-tokens.md +6 -45
  61. package/src/references/06-qa-and-rendering.md +16 -43
  62. package/src/references/07-artifact-contract.md +83 -0
  63. package/src/templates/Agent.md +11 -0
  64. package/src/templates/academic-research/README.md +25 -0
  65. package/src/templates/academic-research/canvas.html +21 -0
  66. package/src/templates/academic-research/metadata.json +38 -0
  67. package/src/templates/academic-research/sample-profile.json +86 -0
  68. package/src/templates/academic-research/style.css +213 -0
  69. package/src/templates/classic/README.md +9 -0
  70. package/src/templates/classic/canvas.html +21 -0
  71. package/src/templates/classic/metadata.json +3 -0
  72. package/src/templates/classic/sample-profile.json +86 -0
  73. package/src/templates/classic/style.css +14 -4
  74. package/src/templates/common/base.css +32 -0
  75. package/src/templates/common/canvas-bindings.css +41 -0
  76. package/src/templates/common/resume-contract.md +8 -0
  77. package/src/templates/compact-dense/README.md +25 -0
  78. package/src/templates/compact-dense/canvas.html +14 -0
  79. package/src/templates/compact-dense/metadata.json +38 -0
  80. package/src/templates/compact-dense/sample-profile.json +86 -0
  81. package/src/templates/compact-dense/style.css +218 -0
  82. package/src/templates/creative-tech/README.md +26 -0
  83. package/src/templates/creative-tech/canvas.html +15 -0
  84. package/src/templates/creative-tech/metadata.json +39 -0
  85. package/src/templates/creative-tech/sample-profile.json +86 -0
  86. package/src/templates/creative-tech/style.css +257 -0
  87. package/src/templates/data-analyst/README.md +26 -0
  88. package/src/templates/data-analyst/canvas.html +15 -0
  89. package/src/templates/data-analyst/metadata.json +39 -0
  90. package/src/templates/data-analyst/sample-profile.json +86 -0
  91. package/src/templates/data-analyst/style.css +285 -0
  92. package/src/templates/executive/README.md +9 -0
  93. package/src/templates/executive/canvas.html +15 -0
  94. package/src/templates/executive/sample-profile.json +86 -0
  95. package/src/templates/executive/style.css +58 -6
  96. package/src/templates/international-flow/README.md +26 -0
  97. package/src/templates/international-flow/canvas.html +21 -0
  98. package/src/templates/international-flow/metadata.json +37 -0
  99. package/src/templates/international-flow/sample-profile.json +86 -0
  100. package/src/templates/international-flow/style.css +208 -0
  101. package/src/templates/minimal/README.md +12 -1
  102. package/src/templates/minimal/canvas.html +21 -0
  103. package/src/templates/minimal/sample-profile.json +86 -0
  104. package/src/templates/minimal/style.css +24 -0
  105. package/src/templates/modern/README.md +9 -0
  106. package/src/templates/modern/canvas.html +15 -0
  107. package/src/templates/modern/sample-profile.json +86 -0
  108. package/src/templates/modern/style.css +48 -1
  109. package/src/templates/startup-generalist/README.md +25 -0
  110. package/src/templates/startup-generalist/canvas.html +21 -0
  111. package/src/templates/startup-generalist/metadata.json +37 -0
  112. package/src/templates/startup-generalist/sample-profile.json +86 -0
  113. package/src/templates/startup-generalist/style.css +237 -0
  114. package/src/templates/classic/template.html +0 -133
  115. package/src/templates/executive/template.html +0 -101
  116. package/src/templates/minimal/template.html +0 -114
  117. package/src/templates/modern/template.html +0 -139
@@ -1,184 +1,277 @@
1
1
  #!/usr/bin/env python3
2
- """
3
- KnowMe CareerForge — Resume Workspace Instantiator (Intermediate Working Canvas)
4
- 将指定模板、候选人结构化事实档案 (evidence-master.json) 与 JD 关键词高亮组装为自包含的单文件 HTML 工作区修改场。
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 sys, os, json, argparse, re
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
- from typing import Dict, Any, Optional, List
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
- curr = Path(__file__).resolve().parent
13
- for _ in range(5):
14
- if (curr / "SKILL.md").exists() or (curr / "package.json").exists() or (curr / "src" / "templates").exists():
15
- return curr
16
- curr = curr.parent
17
- return Path.cwd()
18
-
19
- def highlight_keywords(text: str, keywords: List[str]) -> str:
20
- if not keywords or not text:
21
- return text
22
- for kw in keywords:
23
- kw_clean = kw.strip()
24
- if kw_clean:
25
- pattern = re.compile(rf'(?<!<strong>)(?<![<\w/])({re.escape(kw_clean)})(?![>\w])(?!</strong>)', re.IGNORECASE)
26
- text = pattern.sub(r"<strong>\1</strong>", text)
27
- return text
28
-
29
- def render_profile_into_html(html_str: str, profile: Dict[str, Any], keywords: Optional[List[str]] = None) -> str:
30
- basics = profile.get("basics", {})
31
- name = basics.get("name", "")
32
- title = basics.get("title", "")
33
- phone = basics.get("phone", "")
34
- email = basics.get("email", "")
35
- location = basics.get("location", "")
36
- github = basics.get("github", "")
37
-
38
- # 1. 替换基础信息
39
- if name:
40
- html_str = re.sub(r'(<h1[^>]*class="[^"]*candidate-name[^"]*"[^>]*>).*?(</h1>)', rf'\g<1>{name}\g<2>', html_str)
41
- html_str = re.sub(r'(<title>).*?(</title>)', f'<title>{name} - 个人简历</title>', html_str)
42
-
43
- if title:
44
- html_str = re.sub(r'(<(?:p|span)[^>]*class="[^"]*(?:job-target|target-title)[^"]*"[^>]*>).*?(</(?:p|span)>)', rf'\g<1>求职意向:{title}\g<2>', html_str)
45
-
46
- if phone:
47
- html_str = re.sub(r'138-0000-0000|\b1[3-9]\d{9}\b|\+?\d{1,4}[-\s]?\d{7,11}', phone, html_str)
48
-
49
- if email:
50
- html_str = re.sub(r'[a-zA-Z0-9_.+-]+@example\.com|[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+', email, html_str)
51
-
52
- if location:
53
- html_str = re.sub(r'北京\s*[·•-]\s*海淀(?:区)?', location, html_str)
54
-
55
- if github:
56
- html_str = re.sub(r'github\.com/[a-zA-Z0-9_-]+', github.replace("https://", "").replace("http://", ""), html_str)
57
-
58
- # 2. 注入经历
59
- experiences = profile.get("experience", [])
60
- if experiences and '<section class="resume-section" id="experience">' in html_str:
61
- exp_html_items = []
62
- for exp in experiences:
63
- bullets_html = "".join([f"<li>{highlight_keywords(b.get('text', ''), keywords or [])}</li>" for b in exp.get("bullets", [])])
64
- exp_html_items.append(f"""
65
- <div class="experience-item">
66
- <div class="item-header">
67
- <div class="item-title-group">
68
- <span class="org-name">{exp.get('company', '')}</span>
69
- <span class="role-badge">{exp.get('role', '')}</span>
70
- </div>
71
- <span class="date-range">{exp.get('startDate', '')} - {exp.get('endDate', '')}</span>
72
- </div>
73
- <ul class="bullet-list">
74
- {bullets_html}
75
- </ul>
76
- </div>""")
77
-
78
- exp_block_pattern = re.compile(r'(<section[^>]*id="experience"[^>]*>.*?<h2[^>]*>.*?</h2>\s*).*?(</section>)', re.DOTALL)
79
- if exp_block_pattern.search(html_str):
80
- html_str = exp_block_pattern.sub(rf'\g<1>{"".join(exp_html_items)}\n \g<2>', html_str)
81
-
82
- # 3. 注入项目
83
- projects = profile.get("projects", [])
84
- if projects and '<section class="resume-section" id="projects">' in html_str:
85
- proj_html_items = []
86
- for proj in projects:
87
- bullets_html = "".join([f"<li>{highlight_keywords(b.get('text', ''), keywords or [])}</li>" for b in proj.get("bullets", [])])
88
- tech_tags_html = "".join([f'<span class="tech-tag">{t}</span>' for t in proj.get("techStack", [])])
89
- proj_html_items.append(f"""
90
- <div class="project-item">
91
- <div class="item-header">
92
- <div class="item-title-group">
93
- <span class="project-name">{proj.get('name', '')}</span>
94
- <span class="role-badge">{proj.get('role', '')}</span>
95
- </div>
96
- <span class="date-range">{proj.get('startDate', '')} - {proj.get('endDate', '')}</span>
97
- </div>
98
- <div class="tech-stack-tags">
99
- {tech_tags_html}
100
- </div>
101
- <ul class="bullet-list">
102
- {bullets_html}
103
- </ul>
104
- </div>""")
105
-
106
- proj_block_pattern = re.compile(r'(<section[^>]*id="projects"[^>]*>.*?<h2[^>]*>.*?</h2>\s*).*?(</section>)', re.DOTALL)
107
- if proj_block_pattern.search(html_str):
108
- html_str = proj_block_pattern.sub(rf'\g<1>{"".join(proj_html_items)}\n \g<2>', html_str)
109
-
110
- return html_str
111
-
112
- def instantiate_workspace(template_id: str, profile_path: Optional[str] = None, keywords: Optional[str] = None, output_path: str = "workspace/resume.html") -> Path:
113
- base_dir = get_project_root()
114
- template_dir = base_dir / "src" / "templates" / template_id
115
-
116
- if not template_dir.exists():
117
- for sub in (base_dir / "src" / "templates").iterdir():
118
- if sub.is_dir() and (sub / "metadata.json").exists():
119
- try:
120
- meta = json.loads((sub / "metadata.json").read_text(encoding="utf-8"))
121
- if meta.get("id") == template_id:
122
- template_dir = sub
123
- break
124
- except Exception:
125
- pass
126
-
127
- if not template_dir.exists():
128
- raise ValueError(f"Template '{template_id}' not found in src/templates/")
129
-
130
- html_file = template_dir / "template.html"
131
- css_file = template_dir / "style.css"
132
- base_css_file = base_dir / "src" / "templates" / "common" / "base.css"
133
-
134
- if not html_file.exists() or not css_file.exists():
135
- raise FileNotFoundError(f"template.html or style.css missing in {template_dir}")
136
-
137
- html_content = html_file.read_text(encoding="utf-8")
138
- css_content = css_file.read_text(encoding="utf-8")
139
-
140
- combined_css = ""
141
- if base_css_file.exists():
142
- combined_css = base_css_file.read_text(encoding="utf-8") + "\n\n/* === TEMPLATE SPECIFIC STYLES === */\n" + css_content
143
- else:
144
- combined_css = css_content
145
-
146
- inlined_html = html_content.replace(
147
- '<link rel="stylesheet" href="style.css">',
148
- f'<style>\n{combined_css}\n </style>'
149
- )
150
- kw_list = [k.strip() for k in keywords.split(",") if k.strip()] if keywords else []
151
-
152
- if profile_path and Path(profile_path).exists():
153
- try:
154
- profile_data = json.loads(Path(profile_path).read_text(encoding="utf-8"))
155
- inlined_html = render_profile_into_html(inlined_html, profile_data, kw_list)
156
- except Exception as e:
157
- print(f"[!] Warning: Failed to inject profile data: {e}")
158
- elif kw_list:
159
- inlined_html = highlight_keywords(inlined_html, kw_list)
160
-
161
- out_file = Path(output_path)
162
- out_file.parent.mkdir(parents=True, exist_ok=True)
163
- out_file.write_text(inlined_html, encoding="utf-8")
164
-
165
- print("==============================================================")
166
- print(f" KnowMe CareerForge — Intermediate Canvas Instantiated!")
167
- print(f" Selected Template : {template_id} ({template_dir.name})")
168
- print(f" Profile Injected : {profile_path or 'Default Template Content'}")
169
- print(f" Working Canvas : {out_file.resolve()}")
170
- print("==============================================================")
171
- return out_file
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 = argparse.ArgumentParser(description="KnowMe CareerForge — Workspace Instantiator")
175
- parser.add_argument("--template", "-t", required=True, help="Template ID (minimal, modern, executive, classic)")
176
- parser.add_argument("--profile", "-p", default=None, help="Path to evidence-master.json candidate profile")
177
- parser.add_argument("--keywords", "-k", default=None, help="Comma-separated keywords to highlight (e.g. 'Python,LLM,RAG')")
178
- parser.add_argument("--output", "-o", default="workspace/resume.html", help="Output canvas path (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 ["ai", "agent", "llm", "frontend", "backend", "fullstack", "devops", "engineer"]) and "engineering" in cat:
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)
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ const engine = require('../rendering/browser-engine.js');
4
+ module.exports = engine;
5
+ if (require.main === module) engine.cli();