knowme-careerforge-skill 0.0.2 → 0.0.6

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 (127) hide show
  1. package/AGENT.md +276 -0
  2. package/ARCHITECTURE.md +216 -0
  3. package/README.md +34 -54
  4. package/README.zh-CN.md +35 -57
  5. package/SKILL.md +28 -137
  6. package/agents/claude/knowme-careerforge.md +2 -16
  7. package/agents/codex/knowme-careerforge.yaml +5 -7
  8. package/agents/cursor/knowme-careerforge.mdc +4 -9
  9. package/agents/opencode/skill.yaml +1 -1
  10. package/agents/windsurf/knowme-careerforge.rules +2 -12
  11. package/dist/cli/src/commands/init.js +9 -0
  12. package/dist/cli/src/index.js +40 -19
  13. package/dist/scripts/rendering/engine-loader.d.ts +2 -0
  14. package/dist/scripts/rendering/engine-loader.js +46 -0
  15. package/dist/scripts/rendering/render-pdf.d.ts +3 -8
  16. package/dist/scripts/rendering/render-pdf.js +7 -103
  17. package/dist/scripts/rendering/runtime.d.ts +9 -0
  18. package/dist/scripts/rendering/runtime.js +63 -0
  19. package/dist/scripts/validation/validate-ats.d.ts +2 -22
  20. package/dist/scripts/validation/validate-ats.js +6 -168
  21. package/dist/scripts/validation/validate-layout.d.ts +2 -23
  22. package/dist/scripts/validation/validate-layout.js +5 -159
  23. package/package.json +16 -4
  24. package/references/01-evidence-mining.md +5 -0
  25. package/references/02-career-goal.md +38 -0
  26. package/references/03-jd-analysis.md +44 -0
  27. package/references/04-template-selection.md +43 -0
  28. package/references/05-html-canvas-tokens.md +12 -0
  29. package/references/06-qa-and-rendering.md +14 -0
  30. package/references/07-artifact-contract.md +64 -0
  31. package/scripts/Agent.md +9 -136
  32. package/scripts/build/build-gallery.py +66 -56
  33. package/scripts/build/run-all-tests.py +1 -0
  34. package/scripts/build/sync-version.py +37 -10
  35. package/scripts/contracts/profile.py +101 -0
  36. package/scripts/evidence/extract-evidence.py +109 -62
  37. package/scripts/pipeline/forge.py +266 -136
  38. package/scripts/rendering/README.md +65 -0
  39. package/scripts/rendering/browser-engine.d.ts +15 -0
  40. package/scripts/rendering/browser-engine.js +366 -0
  41. package/scripts/rendering/browser_engine.py +39 -0
  42. package/scripts/rendering/engine-loader.ts +11 -0
  43. package/scripts/rendering/render-pdf.py +26 -151
  44. package/scripts/rendering/render-pdf.ts +7 -83
  45. package/scripts/template/instantiate-resume.py +266 -167
  46. package/scripts/template/search-template.py +367 -89
  47. package/scripts/validation/measure-dom.js +5 -0
  48. package/scripts/validation/validate-ats.ts +6 -174
  49. package/scripts/validation/validate-layout.ts +5 -159
  50. package/scripts/validation/validate-resume.py +23 -134
  51. package/skill.json +1 -1
  52. package/src/knowledge/index.json +390 -10
  53. package/src/knowledge/resume-schema.json +377 -71
  54. package/src/knowledge/templates.json +388 -8
  55. package/src/references/01-evidence-mining.md +5 -0
  56. package/src/references/02-career-goal.md +38 -0
  57. package/src/references/03-jd-analysis.md +44 -0
  58. package/src/references/04-template-selection.md +43 -0
  59. package/src/references/05-html-canvas-tokens.md +12 -0
  60. package/src/references/06-qa-and-rendering.md +14 -0
  61. package/src/references/07-artifact-contract.md +64 -0
  62. package/src/templates/Agent.md +11 -0
  63. package/src/templates/academic-research/README.md +25 -0
  64. package/src/templates/academic-research/canvas.html +21 -0
  65. package/src/templates/academic-research/metadata.json +38 -0
  66. package/src/templates/academic-research/sample-profile.json +86 -0
  67. package/src/templates/academic-research/style.css +213 -0
  68. package/src/templates/classic/README.md +9 -0
  69. package/src/templates/classic/canvas.html +21 -0
  70. package/src/templates/classic/metadata.json +3 -0
  71. package/src/templates/classic/sample-profile.json +86 -0
  72. package/src/templates/classic/style.css +35 -54
  73. package/src/templates/common/base.css +182 -0
  74. package/src/templates/common/canvas-bindings.css +41 -0
  75. package/src/templates/common/resume-contract.md +8 -0
  76. package/src/templates/compact-dense/README.md +25 -0
  77. package/src/templates/compact-dense/canvas.html +14 -0
  78. package/src/templates/compact-dense/metadata.json +38 -0
  79. package/src/templates/compact-dense/sample-profile.json +86 -0
  80. package/src/templates/compact-dense/style.css +218 -0
  81. package/src/templates/creative-tech/README.md +26 -0
  82. package/src/templates/creative-tech/canvas.html +15 -0
  83. package/src/templates/creative-tech/metadata.json +39 -0
  84. package/src/templates/creative-tech/sample-profile.json +86 -0
  85. package/src/templates/creative-tech/style.css +257 -0
  86. package/src/templates/data-analyst/README.md +26 -0
  87. package/src/templates/data-analyst/canvas.html +15 -0
  88. package/src/templates/data-analyst/metadata.json +39 -0
  89. package/src/templates/data-analyst/sample-profile.json +86 -0
  90. package/src/templates/data-analyst/style.css +285 -0
  91. package/src/templates/executive/README.md +9 -0
  92. package/src/templates/executive/canvas.html +15 -0
  93. package/src/templates/executive/sample-profile.json +86 -0
  94. package/src/templates/executive/style.css +83 -74
  95. package/src/templates/international-flow/README.md +26 -0
  96. package/src/templates/international-flow/canvas.html +21 -0
  97. package/src/templates/international-flow/metadata.json +37 -0
  98. package/src/templates/international-flow/sample-profile.json +86 -0
  99. package/src/templates/international-flow/style.css +208 -0
  100. package/src/templates/minimal/README.md +9 -0
  101. package/src/templates/minimal/canvas.html +21 -0
  102. package/src/templates/minimal/sample-profile.json +86 -0
  103. package/src/templates/minimal/style.css +54 -72
  104. package/src/templates/modern/README.md +9 -0
  105. package/src/templates/modern/canvas.html +15 -0
  106. package/src/templates/modern/sample-profile.json +86 -0
  107. package/src/templates/modern/style.css +78 -71
  108. package/src/templates/startup-generalist/README.md +25 -0
  109. package/src/templates/startup-generalist/canvas.html +21 -0
  110. package/src/templates/startup-generalist/metadata.json +37 -0
  111. package/src/templates/startup-generalist/sample-profile.json +86 -0
  112. package/src/templates/startup-generalist/style.css +237 -0
  113. package/src/references/ats.md +0 -17
  114. package/src/references/evidence-first.md +0 -22
  115. package/src/references/pdf.md +0 -40
  116. package/src/references/resume-writing.md +0 -25
  117. package/src/references/template-design.md +0 -57
  118. package/src/templates/classic/template.html +0 -133
  119. package/src/templates/executive/template.html +0 -101
  120. package/src/templates/minimal/template.html +0 -114
  121. package/src/templates/modern/template.html +0 -139
  122. package/src/workflows/analyze-job.md +0 -16
  123. package/src/workflows/define-goal.md +0 -16
  124. package/src/workflows/forge-resume.md +0 -25
  125. package/src/workflows/know-me.md +0 -21
  126. package/src/workflows/position.md +0 -19
  127. package/src/workflows/review.md +0 -31
@@ -1,86 +1,10 @@
1
1
  #!/usr/bin/env ts-node
2
- /**
3
- * KnowMe CareerForge — Playwright Deterministic PDF Renderer
4
- * 加载自包含 HTML 中间工作区,等待字体及样式准备就绪,输出标准像素级 A4 PDF。
5
- */
2
+ import engine = require('./engine-loader');
3
+ import type { EngineOptions, EngineResult } from './browser-engine';
6
4
 
7
- import { chromium } from 'playwright';
8
- import * as path from 'path';
9
- import * as fs from 'fs';
10
-
11
- interface RenderOptions {
12
- inputHtml: string;
13
- outputPdf: string;
14
- }
15
-
16
- export async function renderResumePdf(options: RenderOptions): Promise<void> {
17
- const absoluteHtml = path.resolve(options.inputHtml);
18
- const absolutePdf = path.resolve(options.outputPdf);
19
-
20
- if (!fs.existsSync(absoluteHtml)) {
21
- throw new Error(`Input HTML file does not exist: ${absoluteHtml}`);
22
- }
23
-
24
- const outputDir = path.dirname(absolutePdf);
25
- if (!fs.existsSync(outputDir)) {
26
- fs.mkdirSync(outputDir, { recursive: true });
27
- }
28
-
29
- console.log('======================================================');
30
- console.log(' KnowMe CareerForge — Playwright PDF Renderer');
31
- console.log(` Source Canvas : ${absoluteHtml}`);
32
- console.log(` Target PDF : ${absolutePdf}`);
33
- console.log('======================================================');
34
-
35
- const browser = await chromium.launch({ headless: true });
36
- const context = await browser.newContext({
37
- viewport: { width: 794, height: 1123 }, // A4 at 96 DPI
38
- deviceScaleFactor: 2
39
- });
40
-
41
- const page = await context.newPage();
42
- await page.goto(`file://${absoluteHtml}`, { waitUntil: 'networkidle' });
43
- await page.evaluateHandle('document.fonts.ready');
44
-
45
- await page.pdf({
46
- path: absolutePdf,
47
- format: 'A4',
48
- printBackground: true,
49
- preferCSSPageSize: true,
50
- margin: { top: '0px', right: '0px', bottom: '0px', left: '0px' }
51
- });
52
-
53
- await browser.close();
54
- console.log(`[✓] Deterministic PDF Rendered Successfully -> ${absolutePdf}`);
55
- }
56
-
57
- async function main() {
58
- const args = process.argv.slice(2);
59
- let inputHtml = 'workspace/resume.html';
60
- let outputPdf = 'workspace/resume.pdf';
61
-
62
- for (let i = 0; i < args.length; i++) {
63
- if (args[i] === '--input' || args[i] === '-i') {
64
- inputHtml = args[i + 1];
65
- i++;
66
- } else if (args[i] === '--output' || args[i] === '-o') {
67
- outputPdf = args[i + 1];
68
- i++;
69
- } else if (!args[i].startsWith('-')) {
70
- if (i === 0) inputHtml = args[i];
71
- else if (i === 1) outputPdf = args[i];
72
- }
73
- }
74
-
75
- try {
76
- await renderResumePdf({ inputHtml, outputPdf });
77
- } catch (err: unknown) {
78
- const errorMsg = err instanceof Error ? err.message : String(err);
79
- console.error(`[✗] PDF Rendering Failed: ${errorMsg}`);
80
- process.exit(1);
81
- }
82
- }
83
-
84
- if (require.main === module) {
85
- main();
5
+ export async function renderResumePdf(options: EngineOptions & { inputHtml: string; outputPdf: string }): Promise<EngineResult> {
6
+ const result = await engine.run(options.inputHtml, options);
7
+ if (result.status !== 'PASS') throw new Error(JSON.stringify(result));
8
+ return result;
86
9
  }
10
+ if (require.main === module) engine.cli(process.argv.slice(2), { outputPdf: 'workspace/resume.pdf' });
@@ -1,178 +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
-
133
- if not html_file.exists() or not css_file.exists():
134
- raise FileNotFoundError(f"template.html or style.css missing in {template_dir}")
135
-
136
- html_content = html_file.read_text(encoding="utf-8")
137
- css_content = css_file.read_text(encoding="utf-8")
138
-
139
- inlined_html = html_content.replace(
140
- '<link rel="stylesheet" href="style.css">',
141
- f'<style>\n{css_content}\n </style>'
142
- )
143
-
144
- kw_list = [k.strip() for k in keywords.split(",") if k.strip()] if keywords else []
145
-
146
- if profile_path and Path(profile_path).exists():
147
- try:
148
- profile_data = json.loads(Path(profile_path).read_text(encoding="utf-8"))
149
- inlined_html = render_profile_into_html(inlined_html, profile_data, kw_list)
150
- except Exception as e:
151
- print(f"[!] Warning: Failed to inject profile data: {e}")
152
- elif kw_list:
153
- inlined_html = highlight_keywords(inlined_html, kw_list)
154
-
155
- out_file = Path(output_path)
156
- out_file.parent.mkdir(parents=True, exist_ok=True)
157
- out_file.write_text(inlined_html, encoding="utf-8")
158
-
159
- print("==============================================================")
160
- print(f" KnowMe CareerForge — Intermediate Canvas Instantiated!")
161
- print(f" Selected Template : {template_id} ({template_dir.name})")
162
- print(f" Profile Injected : {profile_path or 'Default Template Content'}")
163
- print(f" Working Canvas : {out_file.resolve()}")
164
- print("==============================================================")
165
- 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
+
166
255
 
167
256
  def main():
168
- parser = argparse.ArgumentParser(description="KnowMe CareerForge — Workspace Instantiator")
169
- parser.add_argument("--template", "-t", required=True, help="Template ID (minimal, modern, executive, classic)")
170
- parser.add_argument("--profile", "-p", default=None, help="Path to evidence-master.json candidate profile")
171
- parser.add_argument("--keywords", "-k", default=None, help="Comma-separated keywords to highlight (e.g. 'Python,LLM,RAG')")
172
- 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
173
274
 
174
- args = parser.parse_args()
175
- instantiate_workspace(args.template, profile_path=args.profile, keywords=args.keywords, output_path=args.output)
176
275
 
177
276
  if __name__ == "__main__":
178
- main()
277
+ sys.exit(main())