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
@@ -4,7 +4,13 @@ KnowMe CareerForge — Template Gallery Builder
4
4
  扫描验证 src/templates/ 核心模板,生成 output/templates_gallery/ 静态可视化画廊。
5
5
  """
6
6
 
7
- import os, sys, json, re
7
+ import argparse
8
+ import importlib.util
9
+ import json
10
+ import os
11
+ import sys
12
+ import tempfile
13
+ from html import escape
8
14
  from pathlib import Path
9
15
 
10
16
  def get_project_root() -> Path:
@@ -15,61 +21,55 @@ def get_project_root() -> Path:
15
21
  curr = curr.parent
16
22
  return Path.cwd()
17
23
 
18
- def build_gallery():
19
- base_dir = get_project_root()
20
- templates_dir = base_dir / "src" / "templates"
21
- gallery_dir = base_dir / "output" / "templates_gallery"
24
+ def load_instantiator():
25
+ spec = importlib.util.spec_from_file_location(
26
+ "gallery_instantiator", get_project_root() / "scripts/template/instantiate-resume.py")
27
+ module = importlib.util.module_from_spec(spec)
28
+ spec.loader.exec_module(module)
29
+ return module
30
+
31
+
32
+ def build_gallery(output_dir=None, font_preset="system"):
33
+ """Validate and stage every preview before publishing; publish the index last."""
34
+ gallery_dir = Path(output_dir) if output_dir else get_project_root() / "output/templates_gallery"
22
35
  gallery_dir.mkdir(parents=True, exist_ok=True)
23
-
24
- gallery_items = []
36
+ with tempfile.TemporaryDirectory(prefix=".gallery-", dir=gallery_dir) as scratch:
37
+ stage = Path(scratch)
38
+ _render_gallery(stage, font_preset)
39
+ for preview in sorted(stage.glob("*.html")):
40
+ if preview.name != "index.html":
41
+ os.replace(preview, gallery_dir / preview.name)
42
+ os.replace(stage / "index.html", gallery_dir / "index.html")
43
+ return gallery_dir / "index.html"
25
44
 
26
- print("==============================================================")
27
- print(" KnowMe CareerForge — Template Gallery Builder")
28
- print("==============================================================")
29
45
 
46
+ def _render_gallery(gallery_dir, font_preset):
47
+ templates_dir = get_project_root() / "src/templates"
48
+ binder = load_instantiator()
49
+ gallery_items = []
30
50
  for t_dir in sorted(templates_dir.iterdir()):
31
51
  if not t_dir.is_dir() or t_dir.name == "common":
32
52
  continue
33
-
34
- html_file = t_dir / "template.html"
35
- css_file = t_dir / "style.css"
36
- meta_file = t_dir / "metadata.json"
37
-
38
- if not html_file.exists() or not css_file.exists() or not meta_file.exists():
39
- print(f"[!] Skipping incomplete template: {t_dir.name}")
40
- continue
41
-
42
- try:
43
- meta = json.loads(meta_file.read_text(encoding="utf-8"))
44
- html_content = html_file.read_text(encoding="utf-8")
45
- css_content = css_file.read_text(encoding="utf-8")
46
-
47
- # 编译内联单文件独立预览页面
48
- inlined_preview = html_content.replace(
49
- '<link rel="stylesheet" href="style.css">',
50
- f'<style>\n{css_content}\n </style>'
51
- )
52
-
53
- preview_path = gallery_dir / f"{t_dir.name}.html"
54
- preview_path.write_text(inlined_preview, encoding="utf-8")
55
-
56
- gallery_items.append({
57
- "id": meta.get("id", t_dir.name),
58
- "name": meta.get("name", t_dir.name),
59
- "style": meta.get("style", "single-column"),
60
- "category": meta.get("roleCategory", "engineering-ai"),
61
- "tone": meta.get("visualStyle", {}).get("tone", "modern"),
62
- "accent": meta.get("visualStyle", {}).get("accentColor", "#2563eb"),
63
- "pages": meta.get("layout", {}).get("targetPages", 1),
64
- "density": meta.get("layout", {}).get("density", "balanced"),
65
- "atsTier": meta.get("atsScoreTier", "tier-1-optimal"),
66
- "previewFile": f"{t_dir.name}.html",
67
- "supported": meta.get("supportedRoles", [])
68
- })
69
- print(f"[✓] Generated Gallery Preview -> {preview_path.name}")
70
- except Exception as e:
71
- print(f"[✗] Error processing {t_dir.name}: {e}")
72
-
53
+ for name in ("canvas.html", "sample-profile.json", "style.css", "metadata.json"):
54
+ if not (t_dir / name).is_file():
55
+ raise ValueError(f"Incomplete template {t_dir.name}: missing {name}")
56
+ meta = json.loads((t_dir / "metadata.json").read_text(encoding="utf-8"))
57
+ binder.instantiate_workspace(
58
+ t_dir.name, profile_path=t_dir / "sample-profile.json",
59
+ output_path=gallery_dir / f"{t_dir.name}.html", quiet=True,
60
+ font_preset=font_preset)
61
+ gallery_items.append({
62
+ "name": meta.get("name", t_dir.name),
63
+ "style": meta.get("style", "single-column"),
64
+ "tone": meta.get("visualStyle", {}).get("tone", "modern"),
65
+ "pages": meta.get("layout", {}).get("targetPages", 1),
66
+ "density": meta.get("layout", {}).get("density", "balanced"),
67
+ "atsTier": meta.get("atsScoreTier", "unspecified"),
68
+ "previewFile": f"{t_dir.name}.html",
69
+ "supported": meta.get("supportedRoles", [])
70
+ })
71
+ if not gallery_items:
72
+ raise ValueError("No templates found")
73
73
  # 生成主索引 index.html
74
74
  gallery_html = f"""<!DOCTYPE html>
75
75
  <html lang="zh-CN">
@@ -217,18 +217,20 @@ def build_gallery():
217
217
  <div class="gallery-container">
218
218
  <header>
219
219
  <h1>KnowMe CareerForge — Template Gallery</h1>
220
- <p class="subtitle">A4 Deterministic HTML Resume Templates with Design Tokens Calibration</p>
220
+ <p class="subtitle">虚构样例 · 与实际简历共用 canvas.html 和完整样式 · ATS 标签仅代表设计目标</p>
221
221
  </header>
222
222
  <div class="grid">
223
223
  """
224
224
 
225
- for item in gallery_items:
225
+ for raw_item in gallery_items:
226
+ item = {k: ([escape(str(v)) for v in value] if isinstance(value, list) else escape(str(value), quote=True))
227
+ for k, value in raw_item.items()}
226
228
  tags_html = "".join([f'<span class="tag">{r}</span>' for r in item['supported'][:5]])
227
229
  gallery_html += f"""
228
230
  <div class="card">
229
231
  <div class="card-header">
230
232
  <span class="card-title">{item['name']}</span>
231
- <span class="badge optimal">ATS Tier 1</span>
233
+ <span class="badge">{item['atsTier']}</span>
232
234
  </div>
233
235
  <div class="preview-frame-wrapper">
234
236
  <iframe class="preview-frame" src="{item['previewFile']}"></iframe>
@@ -255,8 +257,16 @@ def build_gallery():
255
257
  """
256
258
  gallery_index = gallery_dir / "index.html"
257
259
  gallery_index.write_text(gallery_html, encoding="utf-8")
258
- print(f"\n[✓] Generated Gallery Master Index -> {gallery_index}")
259
- print("==============================================================")
260
+
260
261
 
261
262
  if __name__ == "__main__":
262
- build_gallery()
263
+ parser = argparse.ArgumentParser(description=__doc__)
264
+ parser.add_argument("--output", type=Path, help="Gallery output directory")
265
+ parser.add_argument("--font-preset", choices=("system", "arial-unicode"), default="system")
266
+ args = parser.parse_args()
267
+ try:
268
+ index = build_gallery(args.output, args.font_preset)
269
+ print(json.dumps({"status": "PASS", "index": str(index)}, ensure_ascii=False))
270
+ except Exception as exc:
271
+ print(json.dumps({"status": "FAIL", "error": str(exc)}, ensure_ascii=False), file=sys.stderr)
272
+ sys.exit(1)
@@ -19,6 +19,7 @@ def run_all_test_suites():
19
19
  start_time = time.time()
20
20
  base_dir = get_project_root()
21
21
  tests_dir = base_dir / "tests"
22
+ sys.path.insert(0, str(base_dir))
22
23
 
23
24
  print("==============================================================")
24
25
  print(" KnowMe CareerForge — Full-Chain Automated Test Suite")
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
3
  KnowMe CareerForge — Version Synchronization Engine
4
- 将项目根版本号统一同步至 package.json, pyproject.toml, skill.json, cli/package.json, SKILL.md, agents/*, Agent.md。
4
+ 将项目根版本号统一同步至 package.json, pyproject.toml, skill.json, cli/package.json, npm 锁文件, SKILL.md, agents/*, CLI 与知识索引。
5
5
  """
6
6
 
7
7
  import sys, os, json, re, argparse
@@ -18,7 +18,11 @@ def get_project_root() -> Path:
18
18
  def sync_version(new_version: str):
19
19
  base_dir = get_project_root()
20
20
  # 去除可能传入的 'v' 前缀
21
- semver = new_version.lstrip("v")
21
+ semver = new_version.removeprefix("v")
22
+ core = r"(?:0|[1-9][0-9]*)"
23
+ prerelease = r"(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)"
24
+ if not re.fullmatch(rf"{core}\.{core}\.{core}(?:-{prerelease}(?:\.{prerelease})*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?", semver):
25
+ raise ValueError(f"Invalid semantic version: {new_version}")
22
26
 
23
27
  print("==============================================================")
24
28
  print(f" KnowMe CareerForge — Synchronizing Version to v{semver}")
@@ -40,6 +44,17 @@ def sync_version(new_version: str):
40
44
  cli_pkg_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
41
45
  print(f"[✓] Synced cli/package.json -> {semver}")
42
46
 
47
+ # Lockfiles carry the package version separately from dependency versions.
48
+ for relative in ("package-lock.json", "cli/package-lock.json"):
49
+ lock_path = base_dir / relative
50
+ if lock_path.exists():
51
+ data = json.loads(lock_path.read_text(encoding="utf-8"))
52
+ data["version"] = semver
53
+ if "" in data.get("packages", {}):
54
+ data["packages"][""]["version"] = semver
55
+ lock_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
56
+ print(f"[✓] Synced {relative} -> {semver}")
57
+
43
58
  # 3. Update skill.json
44
59
  skill_json_path = base_dir / "skill.json"
45
60
  if skill_json_path.exists():
@@ -60,7 +75,7 @@ def sync_version(new_version: str):
60
75
  skill_md_path = base_dir / "SKILL.md"
61
76
  if skill_md_path.exists():
62
77
  text = skill_md_path.read_text(encoding="utf-8")
63
- new_text = re.sub(r'version:\s*[\d.]+', f'version: {semver}', text)
78
+ new_text = re.sub(r'(?m)^(\s*version:)[ \t]*[^\r\n]+$', lambda m: f'{m[1]} {semver}', text)
64
79
  skill_md_path.write_text(new_text, encoding="utf-8")
65
80
  print(f"[✓] Synced SKILL.md -> {semver}")
66
81
 
@@ -68,7 +83,7 @@ def sync_version(new_version: str):
68
83
  codex_yaml = base_dir / "agents" / "codex" / "knowme-careerforge.yaml"
69
84
  if codex_yaml.exists():
70
85
  text = codex_yaml.read_text(encoding="utf-8")
71
- new_text = re.sub(r'version:\s*[\d.]+', f'version: {semver}', text)
86
+ new_text = re.sub(r'(?m)^(\s*version:)[ \t]*[^\r\n]+$', lambda m: f'{m[1]} {semver}', text)
72
87
  codex_yaml.write_text(new_text, encoding="utf-8")
73
88
  print(f"[✓] Synced agents/codex/knowme-careerforge.yaml -> {semver}")
74
89
 
@@ -76,11 +91,20 @@ def sync_version(new_version: str):
76
91
  opencode_yaml = base_dir / "agents" / "opencode" / "skill.yaml"
77
92
  if opencode_yaml.exists():
78
93
  text = opencode_yaml.read_text(encoding="utf-8")
79
- new_text = re.sub(r'version:\s*[\d.]+', f'version: {semver}', text)
94
+ new_text = re.sub(r'(?m)^(\s*version:)[ \t]*[^\r\n]+$', lambda m: f'{m[1]} {semver}', text)
80
95
  opencode_yaml.write_text(new_text, encoding="utf-8")
81
96
  print(f"[✓] Synced agents/opencode/skill.yaml -> {semver}")
97
+ # 8. Update cli/src/index.ts fallback version
98
+ cli_ts_path = base_dir / "cli" / "src" / "index.ts"
99
+ if cli_ts_path.exists():
100
+ text = cli_ts_path.read_text(encoding="utf-8")
101
+ new_text = re.sub(r"return\s+data\.version\s*\|\|\s*'[^']+'", f"return data.version || '{semver}'", text)
102
+ new_text = re.sub(r"return\s+'\d+\.\d+\.\d+[^']*'\s*;", f"return '{semver}';", new_text)
103
+ cli_ts_path.write_text(new_text, encoding="utf-8")
104
+ print(f"[✓] Synced cli/src/index.ts -> {semver}")
105
+
82
106
 
83
- # 8. Update scripts/Agent.md
107
+ # 9. Update scripts/Agent.md
84
108
  agent_md = base_dir / "scripts" / "Agent.md"
85
109
  if agent_md.exists():
86
110
  text = agent_md.read_text(encoding="utf-8")
@@ -88,20 +112,23 @@ def sync_version(new_version: str):
88
112
  agent_md.write_text(new_text, encoding="utf-8")
89
113
  print(f"[✓] Synced scripts/Agent.md -> v{semver}")
90
114
 
91
- # 9. Recompile knowledge index to update index.json version
115
+ # 10. Recompile knowledge index to update index.json version
92
116
  kb_script = base_dir / "scripts" / "build" / "build-knowledge.py"
93
117
  if kb_script.exists():
94
118
  import subprocess
95
- subprocess.run([sys.executable, str(kb_script)], stdout=subprocess.DEVNULL)
119
+ subprocess.run([sys.executable, str(kb_script)], stdout=subprocess.DEVNULL, check=True)
96
120
  print(f"[✓] Recompiled knowledge index -> v{semver}")
97
121
 
98
122
  print("\n[✓] All package versions successfully synchronized!")
99
123
 
100
124
  def main():
101
125
  parser = argparse.ArgumentParser(description="Synchronize project version")
102
- parser.add_argument("version", nargs="?", default="0.0.2", help="New semantic version (e.g. 0.0.2)")
126
+ parser.add_argument("version", help="Required target semantic version (e.g. 0.0.6)")
103
127
  args = parser.parse_args()
104
- sync_version(args.version)
128
+ try:
129
+ sync_version(args.version)
130
+ except (ValueError, OSError) as error:
131
+ parser.exit(1, f"Version synchronization failed: {error}\n")
105
132
 
106
133
  if __name__ == "__main__":
107
134
  main()
@@ -0,0 +1,101 @@
1
+ """Shared input contract. Structural validity does not certify candidate facts."""
2
+ import copy
3
+ import hashlib
4
+ import json
5
+ from pathlib import Path
6
+
7
+ ROOT = Path(__file__).resolve().parents[2]
8
+ SCHEMA = json.loads((ROOT / 'src/knowledge/resume-schema.json').read_text(encoding='utf-8'))
9
+
10
+ class ContractError(ValueError):
11
+ pass
12
+
13
+
14
+ def _normalize(value, schema, path):
15
+ types = schema.get('type', [])
16
+ types = [types] if isinstance(types, str) else types
17
+ if value is None:
18
+ if 'null' not in types:
19
+ raise ContractError(path + ': null is not allowed')
20
+ return [] if 'array' in types else {} if 'object' in types else None
21
+ if isinstance(value, str):
22
+ if 'string' not in types:
23
+ raise ContractError(path + ': expected ' + '/'.join(types))
24
+ value = value.strip() or None
25
+ if value is not None and 'enum' in schema and value not in schema['enum']:
26
+ raise ContractError(path + ': invalid value')
27
+ return value
28
+ if isinstance(value, dict) and 'object' in types:
29
+ properties = schema.get('properties', {})
30
+ unknown = set(value) - set(properties)
31
+ if unknown:
32
+ raise ContractError(path + ': unsupported fields: ' + ', '.join(sorted(unknown)))
33
+ result = {}
34
+ for key, child in properties.items():
35
+ if key in value:
36
+ item = _normalize(value[key], child, path + '.' + key)
37
+ if item is not None and item != {} and item != []:
38
+ result[key] = item
39
+ return result
40
+ if isinstance(value, list) and 'array' in types:
41
+ values = [_normalize(item, schema['items'], path + '[' + str(i) + ']') for i, item in enumerate(value)]
42
+ return [item for item in values if item is not None and item != '' and item != {}]
43
+ raise ContractError(path + ': expected ' + '/'.join(types))
44
+
45
+
46
+ def normalize_profile(raw):
47
+ result = _normalize(raw, SCHEMA, 'profile')
48
+ result.setdefault('basics', {})
49
+ for field in ('skills', 'experience', 'projects', 'education', 'certifications'):
50
+ result.setdefault(field, [])
51
+ return result
52
+
53
+
54
+ def digest(value):
55
+ return hashlib.sha256(json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
56
+
57
+
58
+ def missing_fields(profile):
59
+ # Informational gaps; optional omissions never turn into invented defaults.
60
+ return ['basics.' + field for field in ('name', 'title', 'email', 'phone')
61
+ if not profile.get('basics', {}).get(field)]
62
+
63
+
64
+ def delivery_errors(profile):
65
+ errors = []
66
+ if not profile['basics'].get('name'):
67
+ errors.append('A non-empty candidate name is required for delivery')
68
+ if not any(profile.get(key) for key in ('experience', 'projects', 'education', 'skills', 'certifications')):
69
+ errors.append('At least one substantive resume section is required for delivery')
70
+ return errors
71
+
72
+
73
+ def document(kind, profile, source=None):
74
+ if kind not in ('draft', 'master', 'variant'):
75
+ raise ContractError('Unknown document kind: ' + str(kind))
76
+ profile = normalize_profile(profile)
77
+ return {'schemaVersion': '1.0', 'kind': kind, 'profile': profile,
78
+ 'profileSha256': digest(profile), 'missingFields': missing_fields(profile),
79
+ 'source': copy.deepcopy(source or {})}
80
+
81
+
82
+ def load_document(raw):
83
+ if not isinstance(raw, dict):
84
+ raise ContractError('Profile document must be an object')
85
+ if 'profile' not in raw:
86
+ # Existing --profile-json inputs are explicit user/Agent supplied facts.
87
+ return document('master', raw, {'type': 'legacy-user-input'})
88
+ unknown = set(raw) - {'schemaVersion', 'kind', 'profile', 'profileSha256', 'missingFields', 'source'}
89
+ if unknown:
90
+ raise ContractError('Unsupported document fields: ' + ', '.join(sorted(unknown)))
91
+ if raw.get('schemaVersion') != '1.0':
92
+ raise ContractError('Unsupported profile schemaVersion')
93
+ source = raw.get('source', {})
94
+ if not isinstance(source, dict):
95
+ raise ContractError('Document source must be an object')
96
+ result = document(raw.get('kind'), raw['profile'], source)
97
+ if raw.get('profileSha256') and result['profileSha256'] != raw['profileSha256']:
98
+ raise ContractError('Profile hash mismatch; regenerate metadata after editing facts')
99
+ if result['kind'] == 'variant' and not source.get('masterSha256'):
100
+ raise ContractError('Variant requires source.masterSha256')
101
+ return result
@@ -225,86 +225,133 @@ def scan_repo(repo_path: Path) -> Dict[str, Any]:
225
225
  return facts
226
226
 
227
227
  def build_master_profile(facts: Dict[str, Any], user_overrides: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
228
+ """Build structured candidate profile from codebase facts + optional user overrides.
229
+
230
+ Placeholder discipline:
231
+ - repo_mode=True : facts dict is populated from real repo scan; safe to synthesize
232
+ experience/education skeletons because they'll be user-reviewed before use.
233
+ - repo_mode=False: only user_overrides carry real data. NEVER emit fabricated placeholder
234
+ strings (fake company, fake school, fake dates). Use null / empty list instead.
235
+ """
228
236
  overrides = user_overrides or {}
229
237
  author = facts.get("author_info", {})
230
-
231
- name = overrides.get("name") or author.get("name") or "求职者"
232
- title = overrides.get("title") or "资深工程师 / 技术专家"
233
- email = overrides.get("email") or author.get("email") or "candidate@example.com"
234
- phone = overrides.get("phone") or "138-0000-0000"
235
- github = overrides.get("github") or author.get("git_remote") or "https://github.com"
236
-
238
+ repo_mode: bool = bool(facts.get("detected_tech_stacks") or facts.get("evidence_items"))
239
+
240
+ # ── Basics ────────────────────────────────────────────────────────────────
241
+ name = overrides.get("name") or author.get("name") or (None if not repo_mode else "候选人")
242
+ title = overrides.get("title") or (None if not repo_mode else "资深工程师 / 技术专家")
243
+ email = overrides.get("email") or author.get("email") or (None if not repo_mode else "candidate@example.com")
244
+ phone = overrides.get("phone") or (None if not repo_mode else None)
245
+ github = overrides.get("github") or author.get("git_remote") or None
246
+ location = overrides.get("location") or (None if not repo_mode else None)
247
+
237
248
  tech_stacks = facts.get("detected_tech_stacks", [])
238
- languages = list(facts.get("detected_languages", {}).keys())
239
-
240
- profile = {
241
- "basics": {
242
- "name": name,
243
- "title": title,
244
- "email": email,
245
- "phone": phone,
246
- "location": overrides.get("location") or "北京 / 远程",
247
- "github": github,
248
- "summary": f"拥有坚实的技术研发与工程化落地实战经验,主导过多个基于 {', '.join((tech_stacks + languages)[:3])} 的核心系统设计与交付。"
249
- },
250
- "skills": [
251
- {
252
- "category": "核心技术栈",
253
- "items": tech_stacks if tech_stacks else ["Python", "TypeScript", "FastAPI", "React", "Docker"],
254
- "highlighted": tech_stacks[:4]
255
- },
256
- {
257
- "category": "工程化与架构",
258
- "items": facts.get("architectural_signals", ["CI/CD", "微服务架构", "自动化测试", "性能优化"]),
259
- "highlighted": ["CI/CD", "性能优化"]
260
- }
261
- ],
262
- "experience": [
249
+ languages = list(facts.get("detected_languages", {}).keys())
250
+
251
+ # ── Skills ────────────────────────────────────────────────────────────────
252
+ all_tech = tech_stacks + [l for l in languages if l not in tech_stacks]
253
+ arch_signals = facts.get("architectural_signals", [])
254
+ skills: list = []
255
+ if all_tech:
256
+ skills.append({"category": "核心技术栈", "items": all_tech, "highlighted": all_tech[:4]})
257
+ if arch_signals:
258
+ skills.append({"category": "工程化与架构", "items": arch_signals, "highlighted": arch_signals[:2]})
259
+
260
+ # ── Experience ────────────────────────────────────────────────────────────
261
+ bullets = [
262
+ {
263
+ "text": item["claim"],
264
+ "evidenceLevel": item["evidenceLevel"],
265
+ "evidenceSource": item["evidenceSource"],
266
+ "keywords": item.get("keywords", [])
267
+ }
268
+ for item in facts.get("evidence_items", [])
269
+ ]
270
+
271
+ if repo_mode and bullets:
272
+ # Repo mode: synthesize a single experience entry from code evidence.
273
+ # Company / dates are placeholders that the user MUST review.
274
+ experience = [
263
275
  {
264
- "company": overrides.get("company") or "创新科技企业",
265
- "role": title,
266
- "startDate": "2022.06",
267
- "endDate": "至今",
268
- "location": "北京",
276
+ "company": overrides.get("company") or "[请填写:公司名称]",
277
+ "role": title or "[请填写:职位名称]",
278
+ "startDate": overrides.get("startDate") or "[请填写:开始时间]",
279
+ "endDate": overrides.get("endDate") or "至今",
280
+ "location": location or "[请填写:工作地点]",
269
281
  "summary": "负责核心产品研发与关键架构攻坚",
270
- "bullets": [
271
- {
272
- "text": item["claim"],
273
- "evidenceLevel": item["evidenceLevel"],
274
- "evidenceSource": item["evidenceSource"],
275
- "keywords": item["keywords"]
276
- }
277
- for item in facts.get("evidence_items", [])
278
- ]
282
+ "bullets": bullets
279
283
  }
280
- ],
281
- "projects": [
284
+ ]
285
+ elif overrides.get("experience"):
286
+ experience = overrides["experience"]
287
+ else:
288
+ experience = []
289
+
290
+ # ── Projects ──────────────────────────────────────────────────────────────
291
+ if repo_mode and tech_stacks:
292
+ projects = [
282
293
  {
283
- "name": facts.get("repo_name", "CoreSystem"),
294
+ "name": facts.get("repo_name") or "[请填写:项目名称]",
284
295
  "role": "核心负责人 / 主力开发者",
285
296
  "techStack": tech_stacks[:5],
286
297
  "repoUrl": github,
287
- "startDate": "2023.01",
298
+ "startDate": "[请填写:开始时间]",
288
299
  "endDate": "至今",
289
300
  "bullets": [
290
- {
291
- "text": item["claim"],
292
- "evidenceLevel": item["evidenceLevel"],
293
- "evidenceSource": item["evidenceSource"]
294
- }
301
+ {"text": item["claim"], "evidenceLevel": item["evidenceLevel"], "evidenceSource": item["evidenceSource"]}
295
302
  for item in facts.get("evidence_items", [])
296
303
  ]
297
304
  }
298
- ],
299
- "education": [
305
+ ]
306
+ elif overrides.get("projects"):
307
+ projects = overrides["projects"]
308
+ else:
309
+ projects = []
310
+
311
+ # ── Education ─────────────────────────────────────────────────────────────
312
+ if overrides.get("education"):
313
+ education = overrides["education"]
314
+ elif overrides.get("school") or overrides.get("degree"):
315
+ education = [
316
+ {
317
+ "institution": overrides.get("school") or "[请填写:学校名称]",
318
+ "degree": overrides.get("degree") or "[请填写:学历与专业]",
319
+ "startDate": overrides.get("edu_start") or "[请填写:入学时间]",
320
+ "endDate": overrides.get("edu_end") or "[请填写:毕业时间]"
321
+ }
322
+ ]
323
+ elif repo_mode:
324
+ # Repo mode: emit a reminder skeleton so the user knows to fill it in.
325
+ education = [
300
326
  {
301
- "institution": overrides.get("school") or "重点大学",
302
- "degree": overrides.get("degree") or "计算机科学与技术 本科",
303
- "startDate": "2016.09",
304
- "endDate": "2020.06"
327
+ "institution": "[请填写:学校名称]",
328
+ "degree": "[请填写:学历与专业]",
329
+ "startDate": "[请填写:入学时间]",
330
+ "endDate": "[请填写:毕业时间]"
305
331
  }
306
332
  ]
307
- }
333
+ else:
334
+ education = []
335
+
336
+ # ── Assemble ──────────────────────────────────────────────────────────────
337
+ basics: Dict[str, Any] = {}
338
+ if name: basics["name"] = name
339
+ if title: basics["title"] = title
340
+ if email: basics["email"] = email
341
+ if phone: basics["phone"] = phone
342
+ if location: basics["location"] = location
343
+ if github: basics["github"] = github
344
+ if all_tech:
345
+ basics["summary"] = (
346
+ f"拥有坚实的技术研发与工程化落地实战经验,"
347
+ f"主导过多个基于 {', '.join(all_tech[:3])} 的核心系统设计与交付。"
348
+ )
349
+
350
+ profile: Dict[str, Any] = {"basics": basics}
351
+ if skills: profile["skills"] = skills
352
+ if experience: profile["experience"] = experience
353
+ if projects: profile["projects"] = projects
354
+ if education: profile["education"] = education
308
355
  return profile
309
356
 
310
357
  def main():