Initial commit
This commit is contained in:
113
SKILL.md
Normal file
113
SKILL.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
---
|
||||||
|
name: ks-zl-extract
|
||||||
|
description: Use when 用户要求从 ks-zl 蒸馏库、笔记库、规范库、建议库或文档库中,为当前目标项目提取适用的规范、需求摘要、示例或方案,并写入目标项目文档目录。
|
||||||
|
---
|
||||||
|
|
||||||
|
# ks-zl-extract
|
||||||
|
|
||||||
|
## 定位
|
||||||
|
|
||||||
|
从 `ks-zl` 维护的 distillation library 中读取资产索引,结合 target project 的项目特征,提取当前项目真正需要的规范、示例、需求摘要或方案到目标项目文档目录。
|
||||||
|
|
||||||
|
本 skill 不负责沉淀新资产到蒸馏库;蒸馏库资产创建和维护由 `ks-zl` 完成。本 skill 只读蒸馏库,只写目标项目文档目录,不实现业务代码。
|
||||||
|
|
||||||
|
语言硬规则:本 skill 写入目标项目的导入规范、摘要和 `extraction-report.md` 必须以中文为主体语言;技术术语、产品名、命令、配置键、API 名称、UI 原文、文件路径和代码标识保留英文原文。
|
||||||
|
|
||||||
|
## 与 ks-zl 的边界
|
||||||
|
|
||||||
|
- `ks-zl`:在蒸馏库 B 中创建 `standard`、`requirement-package`、`solution`、`example` 等资产,并维护 `_indexes/artifacts-index.json`。
|
||||||
|
- `ks-zl-extract`:在项目 A 中读取蒸馏库 B,识别项目 A 的特征,选择适用资产,写入项目 A 的规范目录,并生成 `extraction-report.md`。
|
||||||
|
- 如果提取时发现蒸馏库缺少资产或资产元数据不完整,只记录问题;不要在本 skill 中直接改蒸馏库,除非用户明确切换到 `ks-zl` 维护蒸馏库。
|
||||||
|
|
||||||
|
## 输入识别
|
||||||
|
|
||||||
|
用户通常会说:
|
||||||
|
|
||||||
|
```text
|
||||||
|
我的蒸馏库在 B,帮我提取当前项目需要的规范到 ./xxxx
|
||||||
|
```
|
||||||
|
|
||||||
|
必须识别:
|
||||||
|
- distillation library:用户指定的蒸馏库路径;若未指定则询问。
|
||||||
|
- target project:默认当前工作区;用户指定时使用指定路径。
|
||||||
|
- output directory:用户指定的 `./xxxx`;未指定时默认写入 `.ai-specs/imported-standards`。
|
||||||
|
|
||||||
|
## 工作流
|
||||||
|
|
||||||
|
1. 读取蒸馏库的 `AGENTS.md`、`README.md` 和 `_indexes/artifacts-index.json`。
|
||||||
|
2. 如果 `artifacts-index.json` 缺失但存在 `scripts/build-artifact-index.ps1`,先运行该脚本重建索引。
|
||||||
|
3. 扫描目标项目特征;优先使用本 skill 的 `scripts/detect-project-signals.ps1`。
|
||||||
|
4. 根据用户目标、项目特征和索引中的 `appliesWhen`、`tags`、`domain`、`targetOutputs` 匹配资产;可用 `scripts/select-library-assets.ps1` 先生成候选列表。
|
||||||
|
5. 高置信命中可直接提取;命中冲突或候选过多时,先给出候选清单和推荐项,等用户确认。
|
||||||
|
6. 使用 `scripts/extract-library-assets.ps1` 写入 `<output-directory>/<domain>.md` 和 `extraction-report.md`;资产的 `targetOutputs` 作为建议目标记录在输出中,不直接覆盖目标项目既有规范。
|
||||||
|
7. 生成或更新 `<output-directory>/extraction-report.md`,记录来源、命中原因、写入路径和未提取原因。
|
||||||
|
|
||||||
|
## 项目信号
|
||||||
|
|
||||||
|
常用 signals:
|
||||||
|
|
||||||
|
| Signal | 触发依据 |
|
||||||
|
| --- | --- |
|
||||||
|
| `project-has-database` | 存在 SQL、迁移脚本、ORM schema、实体模型、数据库配置或 `.ai-specs/doc-sql` |
|
||||||
|
| `designing-tables` | 用户要求表设计、字段规范、数据对象、字典或数据库文档 |
|
||||||
|
| `data-may-sync-or-recover` | 存在同步、导入、导出、补录、恢复、对账、外部编号等线索 |
|
||||||
|
| `has-api-contract` | 存在 OpenAPI、路由、controller、接口文档或 API 网关配置 |
|
||||||
|
| `has-frontend-ui` | 存在页面、组件、路由、表单、菜单或前端工程 |
|
||||||
|
|
||||||
|
信号只是匹配依据,不是确定结论。若信号来自推断,必须在 `extraction-report.md` 中标记为 inferred。
|
||||||
|
|
||||||
|
## 提取规则
|
||||||
|
|
||||||
|
- 优先提取 `type: standard` 的规范卡;它们是给跨项目复用设计的。
|
||||||
|
- 需求包只提取摘要和链接,不要把完整需求包散写进项目规范,除非用户明确要求。
|
||||||
|
- 示例和方案只作为参考材料,不得覆盖目标项目已有规范。
|
||||||
|
- 不复制整个蒸馏库;只复制命中资产的必要内容,并保留来源路径。
|
||||||
|
- 不覆盖目标项目已有确定规则;冲突时写入 `extraction-report.md` 的冲突记录并询问用户。
|
||||||
|
- 脚本生成的文件带 `generated-by: ks-zl-extract` 标记;遇到没有该标记的同名文件时,必须拒绝覆盖,除非用户明确要求 `-Force`。
|
||||||
|
|
||||||
|
## 输出格式
|
||||||
|
|
||||||
|
每个提取文件建议包含:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# <domain> 提取规范
|
||||||
|
|
||||||
|
来源蒸馏库:<distillation-library-path>
|
||||||
|
提取时间:<YYYY-MM-DD>
|
||||||
|
|
||||||
|
## <资产标题>
|
||||||
|
|
||||||
|
来源:<relative-path-in-library>
|
||||||
|
命中原因:<signals/tags/query>
|
||||||
|
|
||||||
|
<整理后的规范正文>
|
||||||
|
```
|
||||||
|
|
||||||
|
`extraction-report.md` 必须包含:
|
||||||
|
- 扫描到的 target project signals。
|
||||||
|
- 使用的索引路径和查询条件。
|
||||||
|
- 已提取资产:来源路径、写入路径、命中原因。
|
||||||
|
- 未提取候选:原因。
|
||||||
|
- 冲突或待确认事项。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
完成前至少检查:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File <this-skill>\scripts\detect-project-signals.ps1 -Root <target-project>
|
||||||
|
```
|
||||||
|
|
||||||
|
如果蒸馏库提供搜索脚本,优先用选择脚本验证一次命中:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File <this-skill>\scripts\select-library-assets.ps1 -LibraryRoot <library> -TargetRoot <target-project> -Query "database" -Json
|
||||||
|
```
|
||||||
|
|
||||||
|
执行写入时使用:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File <this-skill>\scripts\extract-library-assets.ps1 -LibraryRoot <library> -TargetRoot <target-project> -OutputDirectory .ai-specs\imported-standards -Query "database"
|
||||||
|
```
|
||||||
|
|
||||||
|
回复用户时说明写入了哪些文件、每个文件来自蒸馏库哪个资产、还有哪些候选没有提取。
|
||||||
4
agents/openai.yaml
Normal file
4
agents/openai.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "KS 提取"
|
||||||
|
short_description: "从蒸馏库提取当前项目所需规范"
|
||||||
|
default_prompt: "Use $ks-zl-extract to extract applicable standards from a distillation library into the current target project."
|
||||||
22
references/extraction-report-template.md
Normal file
22
references/extraction-report-template.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# 提取报告
|
||||||
|
|
||||||
|
## 目标项目信号
|
||||||
|
- `<signal>`:<confidence;evidence>
|
||||||
|
|
||||||
|
## 蒸馏库索引
|
||||||
|
- 蒸馏库:<path>
|
||||||
|
- 索引:<path-to-artifacts-index.json>
|
||||||
|
- 查询:<query/signals/domain/type>
|
||||||
|
|
||||||
|
## 已提取资产
|
||||||
|
| 来源资产 | 写入路径 | 命中原因 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| <library-relative-path> | <target-path> | <signals/tags/query> |
|
||||||
|
|
||||||
|
## 未提取候选
|
||||||
|
| 来源资产 | 原因 |
|
||||||
|
| --- | --- |
|
||||||
|
| <library-relative-path> | <not relevant/conflict/low confidence> |
|
||||||
|
|
||||||
|
## 冲突与待确认
|
||||||
|
- 无
|
||||||
117
scripts/detect-project-signals.ps1
Normal file
117
scripts/detect-project-signals.ps1
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
param(
|
||||||
|
[string]$Root = (Get-Location).Path,
|
||||||
|
[switch]$Json
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $Root)) {
|
||||||
|
throw "Root path does not exist: $Root"
|
||||||
|
}
|
||||||
|
|
||||||
|
$rootPath = (Resolve-Path -LiteralPath $Root).Path
|
||||||
|
$excludedDirs = @(".git", "node_modules", "vendor", "dist", "build", "target", ".next", ".idea", ".vscode")
|
||||||
|
|
||||||
|
function Get-RelativePath {
|
||||||
|
param([string]$Path)
|
||||||
|
|
||||||
|
$fullPath = (Resolve-Path -LiteralPath $Path).Path
|
||||||
|
if ($fullPath.StartsWith($rootPath, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||||
|
return ($fullPath.Substring($rootPath.Length).TrimStart([char[]]"\/") -replace "\\", "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
return ($fullPath -replace "\\", "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-IsExcluded {
|
||||||
|
param([string]$Path)
|
||||||
|
|
||||||
|
$relativePath = Get-RelativePath -Path $Path
|
||||||
|
$parts = $relativePath -split "/"
|
||||||
|
foreach ($part in $parts) {
|
||||||
|
if ($excludedDirs -contains $part) {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = @(Get-ChildItem -Path $rootPath -Recurse -File -Force | Where-Object { -not (Test-IsExcluded -Path $_.FullName) })
|
||||||
|
$directories = @(Get-ChildItem -Path $rootPath -Recurse -Directory -Force | Where-Object { -not (Test-IsExcluded -Path $_.FullName) })
|
||||||
|
|
||||||
|
$signals = New-Object System.Collections.Generic.List[object]
|
||||||
|
|
||||||
|
function Add-Signal {
|
||||||
|
param(
|
||||||
|
[string]$Name,
|
||||||
|
[string]$Confidence,
|
||||||
|
[string]$Reason,
|
||||||
|
[string[]]$Evidence
|
||||||
|
)
|
||||||
|
|
||||||
|
$existing = @($signals | Where-Object { $_.name -eq $Name })
|
||||||
|
if ($existing.Count -gt 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
[void]$signals.Add([pscustomobject]@{
|
||||||
|
name = $Name
|
||||||
|
confidence = $Confidence
|
||||||
|
reason = $Reason
|
||||||
|
evidence = @($Evidence)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
$sqlFiles = @($files | Where-Object { $_.Extension -match "^\.(sql|dbml)$" })
|
||||||
|
$ormFiles = @($files | Where-Object { $_.Name -match "(?i)(schema\.prisma|sequelize|typeorm|entity|model|migration)" })
|
||||||
|
$dbDirs = @($directories | Where-Object { $_.Name -match "(?i)^(db|database|migrations|migration|models|entities|dao|mapper|repository|doc-sql)$" })
|
||||||
|
$databaseConfig = @($files | Where-Object { $_.Name -match "(?i)(database|datasource|jdbc|gorm|mybatis|hibernate|prisma|knex|typeorm)" })
|
||||||
|
|
||||||
|
if ($sqlFiles.Count -gt 0 -or $ormFiles.Count -gt 0 -or $dbDirs.Count -gt 0 -or $databaseConfig.Count -gt 0) {
|
||||||
|
$evidence = @($sqlFiles + $ormFiles + $databaseConfig | Select-Object -First 8 | ForEach-Object { Get-RelativePath -Path $_.FullName })
|
||||||
|
$evidence += @($dbDirs | Select-Object -First 5 | ForEach-Object { Get-RelativePath -Path $_.FullName })
|
||||||
|
Add-Signal -Name "project-has-database" -Confidence "high" -Reason "Database files, ORM files, database directories, or database configs were found." -Evidence $evidence
|
||||||
|
}
|
||||||
|
|
||||||
|
$tableDesignDocs = @($files | Where-Object { (Get-RelativePath -Path $_.FullName) -match "(?i)(doc-sql|table|schema|database|data-model)" })
|
||||||
|
if ($tableDesignDocs.Count -gt 0) {
|
||||||
|
Add-Signal -Name "designing-tables" -Confidence "medium" -Reason "Database or table-design documentation paths were found." -Evidence @($tableDesignDocs | Select-Object -First 8 | ForEach-Object { Get-RelativePath -Path $_.FullName })
|
||||||
|
}
|
||||||
|
|
||||||
|
$syncFiles = @($files | Where-Object { $_.Name -match "(?i)(sync|import|export|reconcile|recovery|recover|external|serial|code|number)" })
|
||||||
|
if ($syncFiles.Count -gt 0) {
|
||||||
|
Add-Signal -Name "data-may-sync-or-recover" -Confidence "medium" -Reason "Sync, import/export, recovery, external-code, or number-related names were found." -Evidence @($syncFiles | Select-Object -First 8 | ForEach-Object { Get-RelativePath -Path $_.FullName })
|
||||||
|
}
|
||||||
|
|
||||||
|
$apiFiles = @($files | Where-Object { $_.Name -match "(?i)(openapi|swagger|controller|router|route|api)" })
|
||||||
|
if ($apiFiles.Count -gt 0) {
|
||||||
|
Add-Signal -Name "has-api-contract" -Confidence "medium" -Reason "API contract, router, route, or controller files were found." -Evidence @($apiFiles | Select-Object -First 8 | ForEach-Object { Get-RelativePath -Path $_.FullName })
|
||||||
|
}
|
||||||
|
|
||||||
|
$frontendFiles = @($files | Where-Object { $_.Extension -match "^\.(tsx|jsx|vue|svelte)$" -or $_.Name -match "(?i)(component|page|view|route|menu|form)" })
|
||||||
|
if ($frontendFiles.Count -gt 0) {
|
||||||
|
Add-Signal -Name "has-frontend-ui" -Confidence "medium" -Reason "Frontend page, component, route, menu, or form files were found." -Evidence @($frontendFiles | Select-Object -First 8 | ForEach-Object { Get-RelativePath -Path $_.FullName })
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = [pscustomobject]@{
|
||||||
|
root = $rootPath
|
||||||
|
signals = @($signals.ToArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Json) {
|
||||||
|
ConvertTo-Json -InputObject $result -Depth 8
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($signals.Count -eq 0) {
|
||||||
|
Write-Host "No project signals detected."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($signal in $signals) {
|
||||||
|
Write-Host "$($signal.name) [$($signal.confidence)] - $($signal.reason)"
|
||||||
|
foreach ($item in @($signal.evidence)) {
|
||||||
|
Write-Host " - $item"
|
||||||
|
}
|
||||||
|
}
|
||||||
289
scripts/extract-library-assets.ps1
Normal file
289
scripts/extract-library-assets.ps1
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$LibraryRoot,
|
||||||
|
[string]$TargetRoot = (Get-Location).Path,
|
||||||
|
[string]$OutputDirectory = ".ai-specs\imported-standards",
|
||||||
|
[string]$Query = "",
|
||||||
|
[string]$Type = "",
|
||||||
|
[string]$Domain = "",
|
||||||
|
[int]$Top = 10,
|
||||||
|
[switch]$Force,
|
||||||
|
[switch]$Json
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $LibraryRoot)) {
|
||||||
|
throw "Library root does not exist: $LibraryRoot"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $TargetRoot)) {
|
||||||
|
throw "Target root does not exist: $TargetRoot"
|
||||||
|
}
|
||||||
|
|
||||||
|
$libraryPath = (Resolve-Path -LiteralPath $LibraryRoot).Path
|
||||||
|
$targetPath = (Resolve-Path -LiteralPath $TargetRoot).Path
|
||||||
|
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$selectScript = Join-Path $scriptDir "select-library-assets.ps1"
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $selectScript)) {
|
||||||
|
throw "Missing select-library-assets script: $selectScript"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-TargetChildPath {
|
||||||
|
param(
|
||||||
|
[string]$Root,
|
||||||
|
[string]$ChildPath
|
||||||
|
)
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($ChildPath)) {
|
||||||
|
throw "Child path must not be empty."
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ChildPath -match "^(?:[A-Za-z]:[\\/]|\\\\)") {
|
||||||
|
$candidate = [System.IO.Path]::GetFullPath($ChildPath)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$candidate = [System.IO.Path]::GetFullPath((Join-Path $Root $ChildPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedRoot = [System.IO.Path]::GetFullPath($Root).TrimEnd([char[]]"\/") + [System.IO.Path]::DirectorySeparatorChar
|
||||||
|
if (-not ($candidate + [System.IO.Path]::DirectorySeparatorChar).StartsWith($normalizedRoot, [System.StringComparison]::OrdinalIgnoreCase) -and
|
||||||
|
-not $candidate.StartsWith($normalizedRoot, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||||
|
throw "Resolved output path is outside target project: $candidate"
|
||||||
|
}
|
||||||
|
|
||||||
|
return $candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-RelativePath {
|
||||||
|
param(
|
||||||
|
[string]$Root,
|
||||||
|
[string]$Path
|
||||||
|
)
|
||||||
|
|
||||||
|
$rootFull = [System.IO.Path]::GetFullPath($Root).TrimEnd([char[]]"\/")
|
||||||
|
$pathFull = [System.IO.Path]::GetFullPath($Path)
|
||||||
|
|
||||||
|
if ($pathFull.StartsWith($rootFull, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||||
|
return ($pathFull.Substring($rootFull.Length).TrimStart([char[]]"\/") -replace "\\", "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
return ($pathFull -replace "\\", "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
function Remove-FrontMatter {
|
||||||
|
param([string]$Content)
|
||||||
|
|
||||||
|
return ([regex]::Replace($Content, "(?s)\A---\r?\n.*?\r?\n---\r?\n?", "")).Trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function U {
|
||||||
|
param([string]$Text)
|
||||||
|
return [regex]::Replace($Text, "\\u([0-9A-Fa-f]{4})", {
|
||||||
|
param($match)
|
||||||
|
return [string][char][Convert]::ToInt32($match.Groups[1].Value, 16)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-GeneratedFile {
|
||||||
|
param([string]$Path)
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $Path)) {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = Get-Content -Raw -Encoding UTF8 -LiteralPath $Path
|
||||||
|
return $content -match "generated-by:\s*ks-zl-extract"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-GeneratedFile {
|
||||||
|
param(
|
||||||
|
[string]$Path,
|
||||||
|
[string]$Content,
|
||||||
|
[switch]$Force
|
||||||
|
)
|
||||||
|
|
||||||
|
if ((Test-Path -LiteralPath $Path) -and -not $Force -and -not (Test-GeneratedFile -Path $Path)) {
|
||||||
|
throw "Refusing to overwrite non-generated file: $Path. Re-run with -Force only after reviewing the existing file."
|
||||||
|
}
|
||||||
|
|
||||||
|
$directory = Split-Path -Parent $Path
|
||||||
|
if (-not (Test-Path -LiteralPath $directory)) {
|
||||||
|
New-Item -ItemType Directory -Path $directory -Force | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
Set-Content -LiteralPath $Path -Encoding UTF8 -Value $Content
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConvertTo-SafeFileName {
|
||||||
|
param([string]$Value)
|
||||||
|
|
||||||
|
$safe = ($Value.ToLowerInvariant() -replace "[^a-z0-9-]+", "-").Trim("-")
|
||||||
|
if ([string]::IsNullOrWhiteSpace($safe)) {
|
||||||
|
return "general"
|
||||||
|
}
|
||||||
|
|
||||||
|
return $safe
|
||||||
|
}
|
||||||
|
|
||||||
|
$selectionArgs = @{
|
||||||
|
LibraryRoot = $libraryPath
|
||||||
|
TargetRoot = $targetPath
|
||||||
|
Top = $Top
|
||||||
|
Json = $true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($Query)) {
|
||||||
|
$selectionArgs["Query"] = $Query
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($Type)) {
|
||||||
|
$selectionArgs["Type"] = $Type
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($Domain)) {
|
||||||
|
$selectionArgs["Domain"] = $Domain
|
||||||
|
}
|
||||||
|
|
||||||
|
$selection = & $selectScript @selectionArgs | ConvertFrom-Json
|
||||||
|
$results = @()
|
||||||
|
if ($null -ne $selection -and $null -ne $selection.results) {
|
||||||
|
$results = @($selection.results)
|
||||||
|
}
|
||||||
|
|
||||||
|
$outputRoot = Resolve-TargetChildPath -Root $targetPath -ChildPath $OutputDirectory
|
||||||
|
$generatedMarker = "<!-- generated-by: ks-zl-extract -->"
|
||||||
|
$writtenFiles = New-Object System.Collections.Generic.List[object]
|
||||||
|
|
||||||
|
$groups = $results | Group-Object {
|
||||||
|
$domainValue = $_.domain
|
||||||
|
if ([string]::IsNullOrWhiteSpace($domainValue)) {
|
||||||
|
$domainValue = "general"
|
||||||
|
}
|
||||||
|
|
||||||
|
ConvertTo-SafeFileName $domainValue
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($group in $groups) {
|
||||||
|
$domainFileName = "$($group.Name).md"
|
||||||
|
$outputPath = Join-Path $outputRoot $domainFileName
|
||||||
|
$relativeOutput = Get-RelativePath -Root $targetPath -Path $outputPath
|
||||||
|
$lines = New-Object System.Collections.Generic.List[string]
|
||||||
|
|
||||||
|
[void]$lines.Add($generatedMarker)
|
||||||
|
[void]$lines.Add((U "# $($group.Name) \u63d0\u53d6\u89c4\u8303"))
|
||||||
|
[void]$lines.Add("")
|
||||||
|
[void]$lines.Add((U "\u6765\u6e90\u84b8\u998f\u5e93\uff1a$libraryPath"))
|
||||||
|
[void]$lines.Add((U "\u84b8\u998f\u5e93\u7d22\u5f15\uff1a$($selection.indexPath)"))
|
||||||
|
[void]$lines.Add((U "\u67e5\u8be2\u6761\u4ef6\uff1a$Query"))
|
||||||
|
[void]$lines.Add("")
|
||||||
|
|
||||||
|
foreach ($asset in @($group.Group)) {
|
||||||
|
$sourcePath = $asset.fullPath
|
||||||
|
if (-not (Test-Path -LiteralPath $sourcePath)) {
|
||||||
|
throw "Selected asset file is missing: $sourcePath"
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceContent = Get-Content -Raw -Encoding UTF8 -LiteralPath $sourcePath
|
||||||
|
$body = Remove-FrontMatter -Content $sourceContent
|
||||||
|
|
||||||
|
[void]$lines.Add("## $($asset.title)")
|
||||||
|
[void]$lines.Add("")
|
||||||
|
[void]$lines.Add((U "- \u6765\u6e90\uff1a$($asset.relativePath)"))
|
||||||
|
[void]$lines.Add((U "- \u7c7b\u578b\uff1a$($asset.type)"))
|
||||||
|
if (@($asset.matchedSignals).Count -gt 0) {
|
||||||
|
[void]$lines.Add((U "- \u547d\u4e2d\u4fe1\u53f7\uff1a$(@($asset.matchedSignals) -join ', ')"))
|
||||||
|
}
|
||||||
|
if (@($asset.matchedTerms).Count -gt 0) {
|
||||||
|
[void]$lines.Add((U "- \u547d\u4e2d\u5173\u952e\u8bcd\uff1a$(@($asset.matchedTerms) -join ', ')"))
|
||||||
|
}
|
||||||
|
if (@($asset.targetOutputs).Count -gt 0) {
|
||||||
|
[void]$lines.Add((U "- \u5efa\u8bae\u5199\u5165\u76ee\u6807\uff1a$(@($asset.targetOutputs) -join ', ')"))
|
||||||
|
}
|
||||||
|
[void]$lines.Add("")
|
||||||
|
[void]$lines.Add($body)
|
||||||
|
[void]$lines.Add("")
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-GeneratedFile -Path $outputPath -Content ($lines -join "`n") -Force:$Force
|
||||||
|
|
||||||
|
[void]$writtenFiles.Add([pscustomobject]@{
|
||||||
|
path = $relativeOutput
|
||||||
|
assetCount = @($group.Group).Count
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
$reportPath = Join-Path $outputRoot "extraction-report.md"
|
||||||
|
$reportLines = New-Object System.Collections.Generic.List[string]
|
||||||
|
[void]$reportLines.Add($generatedMarker)
|
||||||
|
[void]$reportLines.Add((U "# \u63d0\u53d6\u62a5\u544a"))
|
||||||
|
[void]$reportLines.Add("")
|
||||||
|
[void]$reportLines.Add((U "## \u76ee\u6807\u9879\u76ee\u4fe1\u53f7"))
|
||||||
|
|
||||||
|
if ($null -ne $selection.signals -and @($selection.signals).Count -gt 0) {
|
||||||
|
foreach ($signal in @($selection.signals)) {
|
||||||
|
[void]$reportLines.Add("- `$($signal.name)`: $($signal.confidence); $($signal.reason)")
|
||||||
|
foreach ($evidence in @($signal.evidence)) {
|
||||||
|
[void]$reportLines.Add(" - $evidence")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
[void]$reportLines.Add((U "- \u672a\u68c0\u6d4b\u5230\u3002"))
|
||||||
|
}
|
||||||
|
|
||||||
|
[void]$reportLines.Add("")
|
||||||
|
[void]$reportLines.Add((U "## \u84b8\u998f\u5e93\u7d22\u5f15"))
|
||||||
|
[void]$reportLines.Add((U "- \u84b8\u998f\u5e93\uff1a$libraryPath"))
|
||||||
|
[void]$reportLines.Add((U "- \u7d22\u5f15\uff1a$($selection.indexPath)"))
|
||||||
|
[void]$reportLines.Add((U "- \u67e5\u8be2\uff1a$Query"))
|
||||||
|
|
||||||
|
[void]$reportLines.Add("")
|
||||||
|
[void]$reportLines.Add((U "## \u5df2\u63d0\u53d6\u8d44\u4ea7"))
|
||||||
|
if ($results.Count -gt 0) {
|
||||||
|
[void]$reportLines.Add((U "| \u6765\u6e90\u8d44\u4ea7 | \u5199\u5165\u6587\u4ef6 | \u547d\u4e2d\u539f\u56e0 |"))
|
||||||
|
[void]$reportLines.Add("| --- | --- | --- |")
|
||||||
|
foreach ($asset in $results) {
|
||||||
|
$outputFile = ($writtenFiles | Where-Object { $_.path -like "*$(ConvertTo-SafeFileName $asset.domain).md" } | Select-Object -First 1).path
|
||||||
|
$matchReason = @()
|
||||||
|
$matchReason += @($asset.matchedSignals)
|
||||||
|
$matchReason += @($asset.matchedTerms)
|
||||||
|
if ($matchReason.Count -eq 0) {
|
||||||
|
$matchReason = @("selected")
|
||||||
|
}
|
||||||
|
[void]$reportLines.Add("| $($asset.relativePath) | $outputFile | $($matchReason -join ', ') |")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
[void]$reportLines.Add((U "- \u65e0\u3002"))
|
||||||
|
}
|
||||||
|
|
||||||
|
[void]$reportLines.Add("")
|
||||||
|
[void]$reportLines.Add((U "## \u51b2\u7a81\u4e0e\u5f85\u786e\u8ba4"))
|
||||||
|
[void]$reportLines.Add((U "- \u811a\u672c\u672a\u68c0\u6d4b\u5230\u51b2\u7a81\u3002\u5408\u5e76\u5bfc\u5165\u89c4\u5219\u524d\uff0c\u4ecd\u9700\u590d\u6838\u76ee\u6807\u9879\u76ee\u5df2\u6709\u6587\u6863\u3002"))
|
||||||
|
|
||||||
|
Write-GeneratedFile -Path $reportPath -Content ($reportLines -join "`n") -Force:$Force
|
||||||
|
[void]$writtenFiles.Add([pscustomobject]@{
|
||||||
|
path = Get-RelativePath -Root $targetPath -Path $reportPath
|
||||||
|
assetCount = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
$writtenFileArray = @($writtenFiles.ToArray())
|
||||||
|
$result = @{
|
||||||
|
libraryRoot = $libraryPath
|
||||||
|
targetRoot = $targetPath
|
||||||
|
outputDirectory = Get-RelativePath -Root $targetPath -Path $outputRoot
|
||||||
|
selectedAssetCount = $results.Count
|
||||||
|
writtenFiles = $writtenFileArray
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Json) {
|
||||||
|
ConvertTo-Json -InputObject $result -Depth 8
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host (U "\u5df2\u4ece\u84b8\u998f\u5e93\u63d0\u53d6 $($results.Count) \u4e2a\u8d44\u4ea7\u3002")
|
||||||
|
foreach ($file in $writtenFiles) {
|
||||||
|
Write-Host (U " \u5199\u5165\uff1a$($file.path)")
|
||||||
|
}
|
||||||
257
scripts/select-library-assets.ps1
Normal file
257
scripts/select-library-assets.ps1
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$LibraryRoot,
|
||||||
|
[string]$TargetRoot = (Get-Location).Path,
|
||||||
|
[string]$Query = "",
|
||||||
|
[string]$Type = "",
|
||||||
|
[string]$Domain = "",
|
||||||
|
[int]$Top = 10,
|
||||||
|
[switch]$Json,
|
||||||
|
[switch]$Rebuild
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $LibraryRoot)) {
|
||||||
|
throw "Library root does not exist: $LibraryRoot"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $TargetRoot)) {
|
||||||
|
throw "Target root does not exist: $TargetRoot"
|
||||||
|
}
|
||||||
|
|
||||||
|
$libraryPath = (Resolve-Path -LiteralPath $LibraryRoot).Path
|
||||||
|
$targetPath = (Resolve-Path -LiteralPath $TargetRoot).Path
|
||||||
|
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$detectScript = Join-Path $scriptDir "detect-project-signals.ps1"
|
||||||
|
$indexPath = Join-Path $libraryPath "_indexes\artifacts-index.json"
|
||||||
|
$buildScript = Join-Path $libraryPath "scripts\build-artifact-index.ps1"
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $detectScript)) {
|
||||||
|
throw "Missing detect-project-signals script: $detectScript"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Rebuild -or -not (Test-Path -LiteralPath $indexPath)) {
|
||||||
|
if (-not (Test-Path -LiteralPath $buildScript)) {
|
||||||
|
throw "Library artifact index is missing and no build script was found: $indexPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
& $buildScript -Root $libraryPath -OutputPath $indexPath -Quiet
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $indexPath)) {
|
||||||
|
throw "Library artifact index does not exist: $indexPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
$signalInfo = & $detectScript -Root $targetPath -Json | ConvertFrom-Json
|
||||||
|
$signalNames = @()
|
||||||
|
if ($null -ne $signalInfo -and $null -ne $signalInfo.signals) {
|
||||||
|
$signalNames = @($signalInfo.signals | ForEach-Object { $_.name } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
|
||||||
|
}
|
||||||
|
|
||||||
|
$signalsValue = $signalNames -join ","
|
||||||
|
|
||||||
|
if ($signalNames.Count -eq 0 -and
|
||||||
|
[string]::IsNullOrWhiteSpace($Query) -and
|
||||||
|
[string]::IsNullOrWhiteSpace($Type) -and
|
||||||
|
[string]::IsNullOrWhiteSpace($Domain)) {
|
||||||
|
throw "No target project signals or search criteria were found. Provide -Query, -Type, or -Domain."
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-Terms {
|
||||||
|
param([string]$Value)
|
||||||
|
|
||||||
|
$terms = New-Object System.Collections.Generic.List[string]
|
||||||
|
foreach ($part in [regex]::Split($Value.ToLowerInvariant(), "[\s,;\uFF0C\uFF1B\u3001]+")) {
|
||||||
|
$trimmed = $part.Trim()
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($trimmed)) {
|
||||||
|
[void]$terms.Add($trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return @($terms)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-TextValue {
|
||||||
|
param($Value)
|
||||||
|
|
||||||
|
if ($null -eq $Value) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Value -is [array]) {
|
||||||
|
return (@($Value) -join " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
return [string]$Value
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-ContainsText {
|
||||||
|
param(
|
||||||
|
[string]$Haystack,
|
||||||
|
[string]$Needle
|
||||||
|
)
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($Haystack) -or [string]::IsNullOrWhiteSpace($Needle)) {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
return $Haystack.ToLowerInvariant().Contains($Needle.ToLowerInvariant())
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-ExactText {
|
||||||
|
param(
|
||||||
|
[string[]]$Values,
|
||||||
|
[string]$Needle
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($value in @($Values)) {
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($value) -and $value.Trim().ToLowerInvariant() -eq $Needle.Trim().ToLowerInvariant()) {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
function Add-Score {
|
||||||
|
param(
|
||||||
|
[int]$Current,
|
||||||
|
[string]$FieldValue,
|
||||||
|
[string]$Term,
|
||||||
|
[int]$Weight,
|
||||||
|
[string]$FieldName,
|
||||||
|
[System.Collections.Generic.List[string]]$MatchedFields
|
||||||
|
)
|
||||||
|
|
||||||
|
if (Test-ContainsText -Haystack $FieldValue -Needle $Term) {
|
||||||
|
[void]$MatchedFields.Add($FieldName)
|
||||||
|
return ($Current + $Weight)
|
||||||
|
}
|
||||||
|
|
||||||
|
return $Current
|
||||||
|
}
|
||||||
|
|
||||||
|
$terms = @(Get-Terms -Value $Query)
|
||||||
|
$rawItems = Get-Content -Raw -Encoding UTF8 -LiteralPath $indexPath | ConvertFrom-Json
|
||||||
|
$items = @()
|
||||||
|
if ($null -ne $rawItems) {
|
||||||
|
if ($rawItems -is [array]) {
|
||||||
|
$items = @($rawItems)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$items = @($rawItems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$rankableResults = New-Object System.Collections.Generic.List[object]
|
||||||
|
|
||||||
|
foreach ($item in $items) {
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($Type) -and ((Get-TextValue $item.type).ToLowerInvariant() -ne $Type.ToLowerInvariant())) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($Domain) -and ((Get-TextValue $item.domain).ToLowerInvariant() -ne $Domain.ToLowerInvariant())) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$score = 0
|
||||||
|
$matchedTerms = New-Object System.Collections.Generic.HashSet[string]
|
||||||
|
$matchedSignals = New-Object System.Collections.Generic.List[string]
|
||||||
|
$matchedFields = New-Object System.Collections.Generic.List[string]
|
||||||
|
|
||||||
|
foreach ($signal in $signalNames) {
|
||||||
|
if (Test-ExactText -Values @($item.appliesWhen) -Needle $signal) {
|
||||||
|
[void]$matchedSignals.Add($signal)
|
||||||
|
$score += 70
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($term in $terms) {
|
||||||
|
$termScore = 0
|
||||||
|
$termScore = Add-Score -Current $termScore -FieldValue (Get-TextValue $item.title) -Term $term -Weight 80 -FieldName "title" -MatchedFields $matchedFields
|
||||||
|
$termScore = Add-Score -Current $termScore -FieldValue (Get-TextValue $item.tags) -Term $term -Weight 60 -FieldName "tags" -MatchedFields $matchedFields
|
||||||
|
$termScore = Add-Score -Current $termScore -FieldValue (Get-TextValue $item.domain) -Term $term -Weight 35 -FieldName "domain" -MatchedFields $matchedFields
|
||||||
|
$termScore = Add-Score -Current $termScore -FieldValue (Get-TextValue $item.category) -Term $term -Weight 25 -FieldName "category" -MatchedFields $matchedFields
|
||||||
|
$termScore = Add-Score -Current $termScore -FieldValue (Get-TextValue $item.appliesWhen) -Term $term -Weight 35 -FieldName "appliesWhen" -MatchedFields $matchedFields
|
||||||
|
$termScore = Add-Score -Current $termScore -FieldValue (Get-TextValue $item.targetOutputs) -Term $term -Weight 25 -FieldName "targetOutputs" -MatchedFields $matchedFields
|
||||||
|
$termScore = Add-Score -Current $termScore -FieldValue (Get-TextValue $item.body) -Term $term -Weight 15 -FieldName "body" -MatchedFields $matchedFields
|
||||||
|
|
||||||
|
if ($termScore -gt 0) {
|
||||||
|
[void]$matchedTerms.Add($term)
|
||||||
|
$score += $termScore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($terms.Count -gt 0 -and $matchedTerms.Count -eq $terms.Count) {
|
||||||
|
$score += 30
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($signalNames.Count -gt 0 -and $matchedSignals.Count -eq $signalNames.Count) {
|
||||||
|
$score += 25
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($score -gt 0 -or ($terms.Count -eq 0 -and $signalNames.Count -eq 0)) {
|
||||||
|
[void]$rankableResults.Add([pscustomobject]@{
|
||||||
|
score = $score
|
||||||
|
type = $item.type
|
||||||
|
title = $item.title
|
||||||
|
domain = $item.domain
|
||||||
|
category = $item.category
|
||||||
|
relativePath = $item.relativePath
|
||||||
|
fullPath = $item.fullPath
|
||||||
|
tags = @($item.tags)
|
||||||
|
appliesWhen = @($item.appliesWhen)
|
||||||
|
targetOutputs = @($item.targetOutputs)
|
||||||
|
updated = $item.updated
|
||||||
|
matchedTerms = @($matchedTerms)
|
||||||
|
matchedSignals = @($matchedSignals | Select-Object -Unique)
|
||||||
|
matchedFields = @($matchedFields | Select-Object -Unique)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$results = @($rankableResults.ToArray() |
|
||||||
|
Sort-Object `
|
||||||
|
@{ Expression = "score"; Descending = $true },
|
||||||
|
@{ Expression = "updated"; Descending = $true },
|
||||||
|
@{ Expression = "relativePath"; Descending = $false } |
|
||||||
|
Select-Object -First $Top)
|
||||||
|
|
||||||
|
$selection = [pscustomobject]@{
|
||||||
|
libraryRoot = $libraryPath
|
||||||
|
targetRoot = $targetPath
|
||||||
|
signals = @($signalInfo.signals)
|
||||||
|
indexPath = $indexPath
|
||||||
|
query = $Query
|
||||||
|
type = $Type
|
||||||
|
domain = $Domain
|
||||||
|
results = @($results)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Json) {
|
||||||
|
ConvertTo-Json -InputObject $selection -Depth 10
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Library: $libraryPath"
|
||||||
|
Write-Host "Target: $targetPath"
|
||||||
|
if ($signalNames.Count -gt 0) {
|
||||||
|
Write-Host "Signals: $($signalNames -join ', ')"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host "Signals: none"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($results.Count -eq 0) {
|
||||||
|
Write-Host "No matching library assets selected."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($result in $results) {
|
||||||
|
Write-Host "[$($result.score)] $($result.type): $($result.title)"
|
||||||
|
Write-Host " path: $($result.relativePath)"
|
||||||
|
if (@($result.targetOutputs).Count -gt 0) {
|
||||||
|
Write-Host " targetOutputs: $(@($result.targetOutputs) -join ', ')"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user