@sushanglewis/lincoln 1.6.2 → 1.7.0

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 (54) hide show
  1. package/framework/.claude/agents/pm.md +2 -2
  2. package/framework/.claude/agents/security-reviewer.md +57 -0
  3. package/framework/.claude/harnesses/codex.yaml +31 -2
  4. package/framework/.claude/harnesses/command-map.yaml +4 -0
  5. package/framework/.claude/harnesses/opencode.yaml +33 -3
  6. package/framework/.claude/hooks/hooks.yaml +37 -0
  7. package/framework/.claude/hooks/on-session-start.sh +20 -5
  8. package/framework/.claude/hooks/on-stop.sh +22 -0
  9. package/framework/.claude/hooks/pre-tool-use.sh +22 -0
  10. package/framework/.claude/mcp/mcp.yaml +34 -0
  11. package/framework/.claude/policies/friction.yaml +18 -0
  12. package/framework/.claude/policies/security.yaml +46 -0
  13. package/framework/.claude/schemas/design-review-feedback.json +32 -0
  14. package/framework/.claude/schemas/stage-definition.schema.json +22 -0
  15. package/framework/.claude/skills/clarify-requirements/SKILL.md +1 -0
  16. package/framework/.claude/skills/clarify-requirements/prompts/main.md +83 -80
  17. package/framework/.claude/skills/draft-product-design/prompts/main.md +27 -14
  18. package/framework/.claude/stages/clarify.yaml +7 -5
  19. package/framework/.claude/stages/phase-1-p0-design.yaml +44 -0
  20. package/framework/.claude/stages/phase-1-p0-implement.yaml +47 -0
  21. package/framework/.claude/stages/phase-2-p1-design.yaml +53 -0
  22. package/framework/.claude/stages/phase-2-p1-implement.yaml +45 -0
  23. package/framework/.claude/stages/product-design-docs.yaml +6 -6
  24. package/framework/.claude/workflows/framework-self-improvement.yaml +100 -0
  25. package/framework/.claude/workflows/interview-to-knowledge.yaml +1 -0
  26. package/framework/.claude-plugin/marketplace.json +2 -2
  27. package/framework/.claude-plugin/plugin.json +1 -1
  28. package/framework/.version-bump.json +1 -1
  29. package/framework/README.en.md +24 -5
  30. package/framework/README.md +24 -5
  31. package/framework/RELEASE.md +41 -0
  32. package/framework/SKILL.md +15 -6
  33. package/framework/manifest.json +96 -56
  34. package/framework/scripts/check-harness-drift.sh +11 -1
  35. package/framework/scripts/lincoln-setup.py +17 -0
  36. package/framework/scripts/lincoln_condenser.py +110 -0
  37. package/framework/scripts/lincoln_friction.py +266 -0
  38. package/framework/scripts/lincoln_harness_adapter.py +185 -13
  39. package/framework/scripts/lincoln_mr_mine.py +146 -0
  40. package/framework/scripts/lincoln_recall.py +142 -0
  41. package/framework/scripts/lincoln_render.py +209 -6
  42. package/framework/scripts/lincoln_trace.py +119 -3
  43. package/framework/scripts/security_analyzer.py +234 -0
  44. package/framework/scripts/stage_loader.py +124 -1
  45. package/framework/tools/lincoln/package-lock.json +2 -2
  46. package/framework/tools/lincoln/package.json +1 -1
  47. package/framework/tools/lincoln-installer/package.json +6 -1
  48. package/package.json +6 -1
  49. package/framework/.claude/agents/external/everything-claude-code/agents/ecc-tdd-guide.md +0 -108
  50. package/framework/.claude/agents/external/oh-my-claudecode/agents/omc-architect.md +0 -134
  51. package/framework/.claude/agents/external/oh-my-claudecode/agents/omc-code-reviewer.md +0 -248
  52. package/framework/.claude/agents/external/oh-my-claudecode/agents/omc-designer.md +0 -122
  53. package/framework/.claude/agents/external/oh-my-claudecode/agents/omc-qa-tester.md +0 -106
  54. package/framework/.claude/agents/external/wshobson-agents/agents/wshobson-frontend-developer.md +0 -173
@@ -1,13 +1,15 @@
1
1
  #!/usr/bin/env python3
2
2
  """Harness adapter generator for Lincoln.
3
3
 
4
- Derives codex/opencode artifacts (AGENTS.md, prompts, agents, commands) from
5
- the single source of truth in `.claude/`, driven by `.claude/harnesses/<name>.yaml`
6
- manifests. Idempotent; `--check` regenerates into a temp dir and reports drift.
4
+ Derives codex/opencode artifacts (AGENTS.md, prompts, agents, commands, hooks,
5
+ MCP config) from the single source of truth in `.claude/`, driven by
6
+ `.claude/harnesses/<name>.yaml` manifests. Idempotent; `--check` regenerates
7
+ into a temp dir and reports drift.
7
8
 
8
9
  Usage:
9
10
  python3 scripts/lincoln_harness_adapter.py --harness opencode
10
11
  python3 scripts/lincoln_harness_adapter.py --harness codex --check
12
+ python3 scripts/lincoln_harness_adapter.py --harness claude-code --check
11
13
  """
12
14
 
13
15
  from __future__ import annotations
@@ -37,6 +39,9 @@ human_gate 阶段必须获得人类 PM 显式确认后才可推进,不得跳过
37
39
 
38
40
  FRONTMATTER_WHITELIST = ("description", "mode", "model", "permission", "tools")
39
41
 
42
+ MCP_SOURCE_PATH = Path(".claude") / "mcp" / "mcp.yaml"
43
+ HOOKS_SOURCE_PATH = Path(".claude") / "hooks" / "hooks.yaml"
44
+
40
45
 
41
46
  class HarnessAdapterError(Exception):
42
47
  """Raised when a harness manifest is missing or invalid."""
@@ -91,9 +96,50 @@ def _header(harness: str, source: str) -> str:
91
96
  return f"<!-- {GENERATED_NOTE} (generated by {GENERATOR}; harness: {harness}; source: {source}) -->"
92
97
 
93
98
 
94
- def _resolve_output(template: str, name: str, project_dir: Path, home_dir: Path) -> Path:
99
+ def _glob_base(root: Path, pattern: str) -> Path:
100
+ """Return the concrete base directory of a glob pattern relative to root.
101
+
102
+ Examples:
103
+ .claude/skills/*/SKILL.md -> root/.claude/skills
104
+ .claude/rules/**/*.md -> root/.claude/rules
105
+ .claude/hooks/*.sh -> root/.claude/hooks
106
+ """
107
+ parts = Path(pattern).parts
108
+ concrete: list[str] = []
109
+ for part in parts:
110
+ if any(c in part for c in "*?["):
111
+ break
112
+ concrete.append(part)
113
+ return root / Path(*concrete)
114
+
115
+
116
+ def _resolve_output(
117
+ template: str,
118
+ source: Path,
119
+ root: Path,
120
+ pattern: str,
121
+ project_dir: Path,
122
+ home_dir: Path,
123
+ ) -> Path:
124
+ """Render an output path template using source path metadata."""
125
+ name = source.stem
126
+ parent = source.parent.name
127
+ base = _glob_base(root, pattern)
128
+ try:
129
+ rel = source.relative_to(base)
130
+ relative_path = str(rel.with_suffix(""))
131
+ relative_full = str(rel)
132
+ except ValueError:
133
+ relative_path = name
134
+ relative_full = source.name
135
+
95
136
  rendered = template.format(
96
- project=str(project_dir), home=str(home_dir), name=name
137
+ project=str(project_dir),
138
+ home=str(home_dir),
139
+ name=name,
140
+ parent=parent,
141
+ relative_path=relative_path,
142
+ relative_full=relative_full,
97
143
  )
98
144
  return Path(rendered)
99
145
 
@@ -162,6 +208,46 @@ def _transform_plugin_json(text: str, harness: str, source: str) -> str:
162
208
  return json.dumps(manifest, indent=2, ensure_ascii=False) + "\n"
163
209
 
164
210
 
211
+ def _transform_mcp_config(text: str, harness: str, source: str) -> str:
212
+ """Convert Lincoln's declarative mcp.yaml to a harness-native MCP config.
213
+
214
+ The output is a JSON object keyed by `mcpServers`. Harness overrides declared
215
+ under each server's `harness_overrides` are applied for the target harness.
216
+ Environment variable placeholders (`${VAR}`) are preserved; the runtime
217
+ environment or `.env` file is responsible for resolving them.
218
+ """
219
+ try:
220
+ data = yaml.safe_load(text)
221
+ except yaml.YAMLError as exc:
222
+ raise HarnessAdapterError(f"Invalid MCP config YAML ({source}): {exc}") from exc
223
+ if not isinstance(data, dict):
224
+ raise HarnessAdapterError(f"MCP config must be a mapping: {source}")
225
+
226
+ servers = data.get("servers", {})
227
+ if not isinstance(servers, dict):
228
+ raise HarnessAdapterError(f"MCP config 'servers' must be a mapping: {source}")
229
+
230
+ rendered: dict[str, Any] = {}
231
+ for server_name, server in servers.items():
232
+ if not isinstance(server, dict):
233
+ continue
234
+ overrides = server.get("harness_overrides", {}).get(harness, {})
235
+ enabled = server.get("enabled", True)
236
+ if isinstance(overrides.get("enabled"), bool):
237
+ enabled = overrides["enabled"]
238
+ if not enabled:
239
+ continue
240
+
241
+ entry: dict[str, Any] = {}
242
+ for key in ("command", "args", "env", "type", "url", "headers", "description"):
243
+ if key in server:
244
+ entry[key] = server[key]
245
+ entry.update({k: v for k, v in overrides.items() if k != "enabled"})
246
+ rendered[server_name] = entry
247
+
248
+ return json.dumps({"mcpServers": rendered}, indent=2, ensure_ascii=False) + "\n"
249
+
250
+
165
251
  def _transform_concat(
166
252
  texts: list[tuple[str, str]], harness: str, sources: str, manifest: dict[str, Any]
167
253
  ) -> str:
@@ -195,32 +281,49 @@ def _transform_command_template(cmd_name: str, cmd: dict[str, Any], harness: str
195
281
 
196
282
 
197
283
  def _expand_target(
198
- root: Path, target: dict[str, Any], manifest: dict[str, Any]
199
- ) -> list[tuple[str, str, str]]:
200
- """Expand a target into (output_name, content, source_label) tuples."""
284
+ root: Path,
285
+ target: dict[str, Any],
286
+ manifest: dict[str, Any],
287
+ project_dir: Path,
288
+ home_dir: Path,
289
+ ) -> list[tuple[Path, str, str]]:
290
+ """Expand a target into (output_path, content, source_label) tuples."""
201
291
  kind = target.get("kind")
202
292
  transform = target.get("transform", "copy")
203
293
  source = target.get("source", "")
294
+ output_template = target.get("output", "")
204
295
  harness = manifest["name"]
205
296
 
206
297
  if source == "command_map" or kind == "command":
207
298
  items = []
208
299
  for cmd_name, cmd in manifest.get("command_map", {}).items():
209
300
  content = _transform_command_template(cmd_name, cmd, harness)
210
- items.append((cmd_name, content, "command_map"))
301
+ out_path = _resolve_output(
302
+ output_template,
303
+ Path(f"{cmd_name}.md"),
304
+ root,
305
+ source,
306
+ project_dir=project_dir,
307
+ home_dir=home_dir,
308
+ )
309
+ items.append((out_path, content, "command_map"))
211
310
  return items
212
311
 
213
312
  sources = sorted(root.glob(source))
214
313
  if not sources:
314
+ if target.get("optional"):
315
+ return []
215
316
  raise HarnessAdapterError(f"Target source matched nothing: {source}")
216
317
 
217
318
  if transform == "concat":
218
319
  texts = [(p.stem, p.read_text(encoding="utf-8")) for p in sources]
219
- return [("__concat__", _transform_concat(texts, harness, source, manifest), source)]
320
+ out_path = _resolve_output(output_template, sources[0], root, source, project_dir, home_dir)
321
+ return [(out_path, _transform_concat(texts, harness, source, manifest), source)]
220
322
 
221
323
  items = []
222
324
  for path in sources:
223
325
  text = path.read_text(encoding="utf-8")
326
+ out_path = _resolve_output(output_template, path, root, source, project_dir, home_dir)
224
327
  if transform == "frontmatter":
225
328
  fields = tuple(target.get("fields", FRONTMATTER_WHITELIST))
226
329
  content = _transform_frontmatter(
@@ -230,9 +333,13 @@ def _expand_target(
230
333
  content = _transform_plugin_json(
231
334
  text, harness, str(path.relative_to(root))
232
335
  )
336
+ elif transform == "mcp-config":
337
+ content = _transform_mcp_config(
338
+ text, harness, str(path.relative_to(root))
339
+ )
233
340
  else:
234
341
  content = _transform_copy(text, harness, str(path.relative_to(root)))
235
- items.append((path.stem, content, str(path.relative_to(root))))
342
+ items.append((out_path, content, str(path.relative_to(root))))
236
343
  return items
237
344
 
238
345
 
@@ -245,8 +352,9 @@ def generate(
245
352
  staging: list[tuple[Path, str]] = []
246
353
 
247
354
  for target in manifest["targets"]:
248
- for name, content, _source in _expand_target(root, target, manifest):
249
- out_path = _resolve_output(target["output"], name, project_dir, home_dir)
355
+ for out_path, content, _source in _expand_target(
356
+ root, target, manifest, project_dir, home_dir
357
+ ):
250
358
  staging.append((out_path, content))
251
359
 
252
360
  # Write via temp files then move (atomic per file).
@@ -259,10 +367,55 @@ def generate(
259
367
  return written
260
368
 
261
369
 
370
+ def _check_claude_code_sources(root: Path) -> list[str]:
371
+ """Validate that .claude/ source files referenced by hooks.yaml exist."""
372
+ diffs: list[str] = []
373
+ hooks_yaml = root / HOOKS_SOURCE_PATH
374
+ if not hooks_yaml.exists():
375
+ diffs.append(f"missing: {hooks_yaml}")
376
+ return diffs
377
+
378
+ try:
379
+ hooks_data = yaml.safe_load(hooks_yaml.read_text(encoding="utf-8")) or {}
380
+ except yaml.YAMLError as exc:
381
+ diffs.append(f"invalid YAML: {hooks_yaml}: {exc}")
382
+ return diffs
383
+
384
+ hooks = hooks_data.get("hooks", [])
385
+ if not isinstance(hooks, list):
386
+ diffs.append("invalid hooks.yaml: 'hooks' is not a list")
387
+ return diffs
388
+
389
+ for hook in hooks:
390
+ if not isinstance(hook, dict):
391
+ continue
392
+ file_name = hook.get("file")
393
+ if not file_name:
394
+ continue
395
+ hook_file = root / ".claude" / "hooks" / file_name
396
+ if not hook_file.exists():
397
+ diffs.append(f"missing hook: {hook_file}")
398
+ elif not hook_file.stat().st_mode & 0o111:
399
+ diffs.append(f"not executable: {hook_file}")
400
+
401
+ mcp_yaml = root / MCP_SOURCE_PATH
402
+ if not mcp_yaml.exists():
403
+ diffs.append(f"missing: {mcp_yaml}")
404
+
405
+ risk_policy = root / ".claude" / "policies" / "security.yaml"
406
+ if not risk_policy.exists():
407
+ diffs.append(f"missing: {risk_policy}")
408
+
409
+ return diffs
410
+
411
+
262
412
  def check_drift(
263
413
  root: Path, harness: str, project_dir: Path, home_dir: Path
264
414
  ) -> list[str]:
265
415
  """Regenerate into a temp dir and compare with existing artifacts."""
416
+ if harness == "claude-code":
417
+ return _check_claude_code_sources(root)
418
+
266
419
  diffs: list[str] = []
267
420
  with tempfile.TemporaryDirectory() as tmp:
268
421
  tmp_project = Path(tmp) / "project"
@@ -282,6 +435,19 @@ def check_drift(
282
435
  return diffs
283
436
 
284
437
 
438
+ def _load_dotenv(root: Path) -> None:
439
+ """Load environment variables from project root .env if python-dotenv is available."""
440
+ env_file = root / ".env"
441
+ if not env_file.exists():
442
+ return
443
+ try:
444
+ from dotenv import load_dotenv
445
+
446
+ load_dotenv(dotenv_path=env_file, override=False, verbose=False)
447
+ except ImportError:
448
+ pass
449
+
450
+
285
451
  def main(argv: list[str] | None = None) -> int:
286
452
  parser = argparse.ArgumentParser(description="Lincoln harness adapter generator")
287
453
  parser.add_argument("--harness", required=True, help="Harness name (codex|opencode|claude-code)")
@@ -291,6 +457,8 @@ def main(argv: list[str] | None = None) -> int:
291
457
  parser.add_argument("--check", action="store_true", help="Drift check only; no writes")
292
458
  args = parser.parse_args(argv)
293
459
 
460
+ _load_dotenv(args.root)
461
+
294
462
  try:
295
463
  if args.check:
296
464
  diffs = check_drift(args.root, args.harness, args.project_dir, args.home_dir)
@@ -305,6 +473,10 @@ def main(argv: list[str] | None = None) -> int:
305
473
  return 1
306
474
  print(f"No drift for harness '{args.harness}'.")
307
475
  return 0
476
+ if args.harness == "claude-code":
477
+ # Claude Code uses .claude/ directly; nothing to generate.
478
+ print("Harness 'claude-code' uses .claude/ as the source of truth; no artifacts generated.")
479
+ return 0
308
480
  written = generate(args.root, args.harness, args.project_dir, args.home_dir)
309
481
  print(f"Generated {len(written)} artifacts for harness '{args.harness}':")
310
482
  for path in written:
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env python3
2
+ """Lincoln MR / PR knowledge miner.
3
+
4
+ Extracts decision-related review comments from a merged pull request and writes
5
+ structured decision notes to the issue work package. These notes stay in
6
+ issue-<N>/docs/decisions/ until the sync-knowledge stage promotes them to
7
+ knowledge/.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import re
15
+ import subprocess
16
+ import sys
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ DECISION_KEYWORDS = ["decision", "decided", "agreed", "rejected", "note:", "approve", "approved"]
21
+
22
+
23
+ def _run_gh(args: list[str]) -> dict[str, Any] | list[Any]:
24
+ """Call the gh CLI and return parsed JSON."""
25
+ cmd = ["gh", *args]
26
+ result = subprocess.run(cmd, capture_output=True, text=True, check=False)
27
+ if result.returncode != 0:
28
+ raise RuntimeError(result.stderr.strip() or "gh CLI failed")
29
+ return json.loads(result.stdout or "[]")
30
+
31
+
32
+ def _extract_decision_snippets(text: str) -> list[str]:
33
+ """Pull decision-oriented sentences or paragraphs from a comment body."""
34
+ if not text:
35
+ return []
36
+ snippets = []
37
+ for paragraph in re.split(r"\n\s*\n", text.strip()):
38
+ paragraph = paragraph.strip()
39
+ if not paragraph:
40
+ continue
41
+ lowered = paragraph.lower()
42
+ if any(keyword in lowered for keyword in DECISION_KEYWORDS):
43
+ snippets.append(paragraph)
44
+ return snippets
45
+
46
+
47
+ def _sanitize_filename(text: str) -> str:
48
+ """Reduce a comment snippet to a safe filename slug."""
49
+ text = text.lower()
50
+ text = re.sub(r"[^a-z0-9]+", "-", text)
51
+ text = text.strip("-")
52
+ return text[:60]
53
+
54
+
55
+ def mine_pr_decisions(
56
+ owner: str,
57
+ repo: str,
58
+ pr_number: int,
59
+ issue_number: int | None = None,
60
+ output_dir: Path | None = None,
61
+ ) -> list[Path]:
62
+ """Mine decision comments from a PR and write them to the issue package."""
63
+ if output_dir is None:
64
+ if issue_number is None:
65
+ raise ValueError("issue_number or output_dir is required")
66
+ output_dir = Path(f"issue-{issue_number}") / "docs" / "decisions"
67
+ output_dir = Path(output_dir)
68
+ output_dir.mkdir(parents=True, exist_ok=True)
69
+
70
+ comments = _run_gh(
71
+ ["api", f"repos/{owner}/{repo}/pulls/{pr_number}/comments"]
72
+ )
73
+ review_comments = _run_gh(
74
+ ["api", f"repos/{owner}/{repo}/pulls/{pr_number}/reviews"]
75
+ )
76
+
77
+ if isinstance(comments, dict):
78
+ comments = [comments]
79
+ if isinstance(review_comments, dict):
80
+ review_comments = [review_comments]
81
+
82
+ all_comments = list(comments) + list(review_comments)
83
+
84
+ written: list[Path] = []
85
+ seen: set[str] = set()
86
+
87
+ for comment in all_comments:
88
+ body = comment.get("body") or ""
89
+ snippets = _extract_decision_snippets(body)
90
+ for snippet in snippets:
91
+ if snippet in seen:
92
+ continue
93
+ seen.add(snippet)
94
+ slug = _sanitize_filename(snippet)
95
+ filename = f"pr-{pr_number}-{slug}.md"
96
+ path = output_dir / filename
97
+ if path.exists():
98
+ continue
99
+
100
+ content = f"""# PR {pr_number} Decision Note
101
+
102
+ **Source:** {owner}/{repo}#{pr_number}
103
+ **Author:** {comment.get('author', {}).get('login', 'unknown')}
104
+ **Created:** {comment.get('createdAt', 'unknown')}
105
+ **Comment ID:** {comment.get('id', 'unknown')}
106
+
107
+ ## Snippet
108
+
109
+ {snippet}
110
+
111
+ ## Keywords
112
+
113
+ {', '.join(DECISION_KEYWORDS)}
114
+
115
+ ---
116
+ *Auto-generated by scripts/lincoln_mr_mine.py*
117
+ """
118
+ path.write_text(content, encoding="utf-8")
119
+ written.append(path)
120
+
121
+ return written
122
+
123
+
124
+ def main(argv: list[str] | None = None) -> int:
125
+ parser = argparse.ArgumentParser(description="Mine PR review decisions")
126
+ parser.add_argument("--owner", required=True)
127
+ parser.add_argument("--repo", required=True)
128
+ parser.add_argument("--pr", type=int, required=True)
129
+ parser.add_argument("--issue", type=int, default=None)
130
+ parser.add_argument("--output-dir", type=Path, default=None)
131
+ args = parser.parse_args(argv)
132
+
133
+ paths = mine_pr_decisions(
134
+ owner=args.owner,
135
+ repo=args.repo,
136
+ pr_number=args.pr,
137
+ issue_number=args.issue,
138
+ output_dir=args.output_dir,
139
+ )
140
+ for path in paths:
141
+ print(path)
142
+ return 0
143
+
144
+
145
+ if __name__ == "__main__":
146
+ sys.exit(main())
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env python3
2
+ """Lincoln recall precheck.
3
+
4
+ Given a task description, scores knowledge base documents by keyword overlap
5
+ (and optionally by embedding similarity) and returns the top-K matches.
6
+ Designed to be called from on-session-start.sh before injecting context.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import re
14
+ import sys
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ DEFAULT_KNOWLEDGE_DIR = "knowledge"
19
+ DEFAULT_TOP_K = 5
20
+
21
+
22
+ def _tokenize(text: str) -> set[str]:
23
+ """Extract lowercase alphanumeric tokens."""
24
+ return set(re.findall(r"[a-zA-Z0-9一-鿿]+", text.lower()))
25
+
26
+
27
+ def _load_documents(knowledge_dir: Path) -> list[dict[str, Any]]:
28
+ docs: list[dict[str, Any]] = []
29
+ if not knowledge_dir.exists():
30
+ return docs
31
+ for path in sorted(knowledge_dir.rglob("*.md")):
32
+ try:
33
+ text = path.read_text(encoding="utf-8")
34
+ docs.append(
35
+ {
36
+ "path": str(path.relative_to(knowledge_dir)),
37
+ "tokens": _tokenize(text),
38
+ "title": path.stem,
39
+ "_text": text[:2000],
40
+ }
41
+ )
42
+ except Exception:
43
+ continue
44
+ return docs
45
+
46
+
47
+ def _score_by_keywords(query_tokens: set[str], doc: dict[str, Any]) -> float:
48
+ if not query_tokens:
49
+ return 0.0
50
+ doc_tokens = doc["tokens"]
51
+ if not doc_tokens:
52
+ return 0.0
53
+ overlap = len(query_tokens & doc_tokens)
54
+ return overlap / max(len(query_tokens), len(doc_tokens))
55
+
56
+
57
+ def _try_embedding_score(query: str, docs: list[dict[str, Any]]) -> list[float] | None:
58
+ """Attempt embedding-based scoring if sentence-transformers is installed."""
59
+ try:
60
+ from sentence_transformers import SentenceTransformer, util # type: ignore
61
+
62
+ model = SentenceTransformer("all-MiniLM-L6-v2")
63
+ query_embedding = model.encode(query, convert_to_tensor=True)
64
+ doc_texts = [doc.get("_text", "") for doc in docs]
65
+ doc_embeddings = model.encode(doc_texts, convert_to_tensor=True)
66
+ scores = util.cos_sim(query_embedding, doc_embeddings)[0].tolist()
67
+ return list(scores)
68
+ except Exception:
69
+ return None
70
+
71
+
72
+ def recall(
73
+ query: str,
74
+ knowledge_dir: Path | None = None,
75
+ top_k: int = DEFAULT_TOP_K,
76
+ use_embedding: bool = True,
77
+ ) -> dict[str, Any]:
78
+ """Return top-K knowledge documents for a query."""
79
+ knowledge_dir = knowledge_dir or Path(DEFAULT_KNOWLEDGE_DIR)
80
+ docs = _load_documents(knowledge_dir)
81
+ query_tokens = _tokenize(query)
82
+
83
+ if use_embedding:
84
+ embedding_scores = _try_embedding_score(query, docs)
85
+ else:
86
+ embedding_scores = None
87
+
88
+ results = []
89
+ for idx, doc in enumerate(docs):
90
+ keyword_score = _score_by_keywords(query_tokens, doc)
91
+ if embedding_scores is not None:
92
+ score = 0.5 * keyword_score + 0.5 * embedding_scores[idx]
93
+ else:
94
+ score = keyword_score
95
+ results.append(
96
+ {
97
+ "path": doc["path"],
98
+ "title": doc["title"],
99
+ "score": round(score, 4),
100
+ "method": "embedding+keywords" if embedding_scores else "keywords",
101
+ }
102
+ )
103
+
104
+ results.sort(key=lambda r: r["score"], reverse=True)
105
+ return {
106
+ "query": query,
107
+ "top_k": top_k,
108
+ "docs": [r for r in results[:top_k] if r["score"] > 0],
109
+ "method": "embedding+keywords" if embedding_scores else "keywords",
110
+ }
111
+
112
+
113
+ def main(argv: list[str] | None = None) -> int:
114
+ parser = argparse.ArgumentParser(description="Recall relevant knowledge documents")
115
+ parser.add_argument("--query", required=True)
116
+ parser.add_argument("--knowledge-dir", type=Path, default=None)
117
+ parser.add_argument("--top-k", type=int, default=DEFAULT_TOP_K)
118
+ parser.add_argument("--format", choices=["json", "markdown"], default="json")
119
+ parser.add_argument("--no-embedding", action="store_true", default=False)
120
+ args = parser.parse_args(argv)
121
+
122
+ result = recall(
123
+ query=args.query,
124
+ knowledge_dir=args.knowledge_dir,
125
+ top_k=args.top_k,
126
+ use_embedding=not args.no_embedding,
127
+ )
128
+
129
+ if args.format == "json":
130
+ print(json.dumps(result, ensure_ascii=False, indent=2))
131
+ else:
132
+ print(f"# 相关知识({result['method']})\n")
133
+ if result["docs"]:
134
+ for doc in result["docs"]:
135
+ print(f"- [{doc['title']}]({doc['path']}) — score {doc['score']}")
136
+ else:
137
+ print("_未找到相关知识。_")
138
+ return 0
139
+
140
+
141
+ if __name__ == "__main__":
142
+ sys.exit(main())