From 9204ae36fd3d26567864c72cee994ea4cc84078b Mon Sep 17 00:00:00 2001 From: wdh-home <243823965@qq.com> Date: Sun, 17 May 2026 17:24:03 +0800 Subject: [PATCH] Initial commit --- SKILL.md | 113 +++++++++ agents/openai.yaml | 4 + references/extraction-report-template.md | 22 ++ scripts/detect-project-signals.ps1 | 117 +++++++++ scripts/extract-library-assets.ps1 | 289 +++++++++++++++++++++++ scripts/select-library-assets.ps1 | 257 ++++++++++++++++++++ 6 files changed, 802 insertions(+) create mode 100644 SKILL.md create mode 100644 agents/openai.yaml create mode 100644 references/extraction-report-template.md create mode 100644 scripts/detect-project-signals.ps1 create mode 100644 scripts/extract-library-assets.ps1 create mode 100644 scripts/select-library-assets.ps1 diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..bdb5ba5 --- /dev/null +++ b/SKILL.md @@ -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` 写入 `/.md` 和 `extraction-report.md`;资产的 `targetOutputs` 作为建议目标记录在输出中,不直接覆盖目标项目既有规范。 +7. 生成或更新 `/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 +# 提取规范 + +来源蒸馏库: +提取时间: + +## <资产标题> + +来源: +命中原因: + +<整理后的规范正文> +``` + +`extraction-report.md` 必须包含: +- 扫描到的 target project signals。 +- 使用的索引路径和查询条件。 +- 已提取资产:来源路径、写入路径、命中原因。 +- 未提取候选:原因。 +- 冲突或待确认事项。 + +## 验证 + +完成前至少检查: + +```powershell +powershell -ExecutionPolicy Bypass -File \scripts\detect-project-signals.ps1 -Root +``` + +如果蒸馏库提供搜索脚本,优先用选择脚本验证一次命中: + +```powershell +powershell -ExecutionPolicy Bypass -File \scripts\select-library-assets.ps1 -LibraryRoot -TargetRoot -Query "database" -Json +``` + +执行写入时使用: + +```powershell +powershell -ExecutionPolicy Bypass -File \scripts\extract-library-assets.ps1 -LibraryRoot -TargetRoot -OutputDirectory .ai-specs\imported-standards -Query "database" +``` + +回复用户时说明写入了哪些文件、每个文件来自蒸馏库哪个资产、还有哪些候选没有提取。 diff --git a/agents/openai.yaml b/agents/openai.yaml new file mode 100644 index 0000000..ad9947a --- /dev/null +++ b/agents/openai.yaml @@ -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." diff --git a/references/extraction-report-template.md b/references/extraction-report-template.md new file mode 100644 index 0000000..0698fc3 --- /dev/null +++ b/references/extraction-report-template.md @@ -0,0 +1,22 @@ +# 提取报告 + +## 目标项目信号 +- ``: + +## 蒸馏库索引 +- 蒸馏库: +- 索引: +- 查询: + +## 已提取资产 +| 来源资产 | 写入路径 | 命中原因 | +| --- | --- | --- | +| | | | + +## 未提取候选 +| 来源资产 | 原因 | +| --- | --- | +| | | + +## 冲突与待确认 +- 无 diff --git a/scripts/detect-project-signals.ps1 b/scripts/detect-project-signals.ps1 new file mode 100644 index 0000000..4a5244c --- /dev/null +++ b/scripts/detect-project-signals.ps1 @@ -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" + } +} diff --git a/scripts/extract-library-assets.ps1 b/scripts/extract-library-assets.ps1 new file mode 100644 index 0000000..d429de0 --- /dev/null +++ b/scripts/extract-library-assets.ps1 @@ -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 = "" +$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)") +} diff --git a/scripts/select-library-assets.ps1 b/scripts/select-library-assets.ps1 new file mode 100644 index 0000000..e6e1886 --- /dev/null +++ b/scripts/select-library-assets.ps1 @@ -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 ', ')" + } +}