From bcca4cfb69f2fa8f3d3d88d41533b656706c3507 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yishun=20Tang=20=C2=B7=20CrazyAnt?= Date: Fri, 11 Sep 2026 13:32:53 +0800 Subject: [PATCH 1/3] fix: measure Chinese capabilities by Chinese rules, not Latin ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Chinese project's README could declare its capabilities and have none of them reach the map. Three places applied rules written for Latin text to Han characters, and each one alone was enough to break the match. The capability filter required three characters, a floor that exists to reject "AI" and "v2". A Chinese term is complete at two, so 选题, 成片, 导出 and 登录 were dropped before anything could match them — in a five-heading fixture only the three-character ones survived. Han runs were then matched greedily, which produced tokens no reader would search for: a whole clause ("给选题出点子"), a function word welded to its term ("从热点选题"), and an eight-character cap slicing 工作台 into 工 + 作台. Worst of the three, the two sides disagreed. The reader kept two-character terms; the matcher filtered them out again, so even a term that survived extraction could not meet the code's own vocabulary. Both sides now share one splitter — the whole run when it is short enough to be a term, plus every 2-gram — and the Latin floor applies only to Latin. The meaningless grams this admits are the deliberate trade: a spurious token can only fail to match, while a missing one loses the capability outright. Proven by mutation: restoring the Latin floor, dropping the 2-grams, or re-applying the length filter each turns the new tests red. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 15 ++++++++ package-lock.json | 1 + packages/analysis-kit/src/index.ts | 36 ++++++++++++++++++ packages/logic-compiler/src/features.ts | 16 ++++++-- packages/project-reader/package.json | 1 + packages/project-reader/src/index.ts | 12 +++++- packages/project-reader/tsconfig.json | 3 +- tests/features.test.ts | 27 ++++++++++++++ tests/project-reader.test.ts | 49 +++++++++++++++++++++++++ 9 files changed, 153 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1631b3f..cc039f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ All notable changes are documented here. +## Unreleased + +### Fixed + +- A Chinese project's documented capabilities could not reach the map. Three + separate places measured Chinese by rules written for Latin: a two-character + heading (`选题`, `成片`, `导出`) fell under the three-character floor meant to + reject `AI` and `v2`; a run of Han characters was matched greedily, so a whole + clause became one token and an eight-character cap cut terms in half; and the + reader kept two-character terms while the matcher filtered them out, leaving the + two sides unable to agree on the same word. Together these meant a Chinese + README's capabilities almost never matched, and features fell back to naming + themselves after code. Both sides now split Han runs the same way — whole term + plus 2-grams — and the length floor applies only where it was meant to. + ## 0.9.2 - 2026-09-03 ### Changed diff --git a/package-lock.json b/package-lock.json index 78893e1..09ca3ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4357,6 +4357,7 @@ "name": "@agent-runtime-map/project-reader", "version": "0.1.0", "dependencies": { + "@agent-runtime-map/analysis-kit": "*", "@agent-runtime-map/schema": "*" } }, diff --git a/packages/analysis-kit/src/index.ts b/packages/analysis-kit/src/index.ts index 899d902..9195334 100644 --- a/packages/analysis-kit/src/index.ts +++ b/packages/analysis-kit/src/index.ts @@ -239,6 +239,42 @@ export function templateVariables(value: string): string[] { return [...new Set([...value.matchAll(/\{\{?\s*([a-zA-Z0-9_.]+)\s*\}?\}/g)].map((match) => match[1]!))]; } +const HAN_RUN = /[㐀-鿿]+/g; + +/** + * Latin text arrives pre-split by spaces and case changes; a run of Han characters + * does not, and matching one greedily yields tokens no one would search for — a + * whole clause ("给选题出点子"), a function word welded to its term ("从热点选题"), + * or a term cut in half by a length cap ("保险短视频编导工" + "作台"). None of those + * meet the code's own vocabulary, so a Chinese document's capabilities never + * matched anything and every feature fell back to naming itself after code. + * + * Emitting every 2-gram alongside a short run recovers the units a reader names — + * 选题, 故事, 成片. The extra grams that mean nothing ("点选") are the deliberate + * cost: a spurious token can only fail to match, while a missing one loses the + * capability outright. Two characters is the floor because that is where a Chinese + * term starts, unlike Latin, where two characters is still an abbreviation. + */ +export function cjkTokens(value: string): string[] { + const tokens: string[] = []; + for (const run of value.match(HAN_RUN) ?? []) { + const characters = [...run]; + if (characters.length < 2) continue; + // A short run is plausibly one term, so keep it whole as well; a long one is a + // sentence, and keeping it whole only re-creates the token nothing matches. + if (characters.length <= 8) tokens.push(run); + for (let index = 0; index + 1 < characters.length; index += 1) { + tokens.push(characters[index]! + characters[index + 1]!); + } + } + return [...new Set(tokens)]; +} + +/** True when a value carries Han characters, which set a different length floor. */ +export function hasHan(value: string): boolean { + return /[㐀-鿿]/.test(value); +} + export function dedupeById(items: T[]): T[] { const seen = new Set(); return items.filter((item) => { diff --git a/packages/logic-compiler/src/features.ts b/packages/logic-compiler/src/features.ts index fd06f2b..aaf734a 100644 --- a/packages/logic-compiler/src/features.ts +++ b/packages/logic-compiler/src/features.ts @@ -13,6 +13,7 @@ import { type ProductMatchKind, type ProjectCapabilityHint, } from "@agent-runtime-map/schema"; +import { cjkTokens } from "@agent-runtime-map/analysis-kit"; /** * Types a step depends on rather than continues into. The model it requests, the @@ -163,10 +164,17 @@ function matchDocumentedCapability( } function semanticTokens(value: string): Set { - const matches = value.toLowerCase().match(/[a-z][a-z0-9-]{2,}|[\u3400-\u9fff]{2,8}/g) ?? []; - return new Set(matches.map((token) => semanticStem(token) - .replace(/^(post|get|put|patch|delete)$/, "")) - .filter((token) => token.length >= 3)); + // Latin and Han need different treatment and used to share one pattern. The + // three-character filter is right for Latin \u2014 stemming can shorten a token into + // noise \u2014 but it deleted every two-character Chinese term (\u9009\u9898, \u6210\u7247, \u5bfc\u51fa) that + // the pattern had just matched, so half the vocabulary of a Chinese project was + // discarded here while the reader kept it. Han runs now go through the same + // splitter both sides use, and are not measured by the Latin floor. + const latin = value.toLowerCase().match(/[a-z][a-z0-9-]{2,}/g) ?? []; + const stemmed = latin + .map((token) => semanticStem(token).replace(/^(post|get|put|patch|delete)$/, "")) + .filter((token) => token.length >= 3); + return new Set([...stemmed, ...cjkTokens(value)]); } function semanticStem(value: string): string { diff --git a/packages/project-reader/package.json b/packages/project-reader/package.json index 9bdb5dd..9dcbaec 100644 --- a/packages/project-reader/package.json +++ b/packages/project-reader/package.json @@ -11,6 +11,7 @@ "clean": "rm -rf dist" }, "dependencies": { + "@agent-runtime-map/analysis-kit": "*", "@agent-runtime-map/schema": "*" } } diff --git a/packages/project-reader/src/index.ts b/packages/project-reader/src/index.ts index 716b364..e3b6ad2 100644 --- a/packages/project-reader/src/index.ts +++ b/packages/project-reader/src/index.ts @@ -12,6 +12,7 @@ import type { ProductEvidenceOrigin, SourceLocation, } from "@agent-runtime-map/schema"; +import { cjkTokens, hasHan } from "@agent-runtime-map/analysis-kit"; const EXCLUDED_DIRECTORIES = new Set([ ".git", @@ -319,7 +320,12 @@ function mergeCapabilities(items: ProjectCapabilityHint[]): ProjectCapabilityHin } function isCapabilityLabel(value: string): boolean { - if (!value || value.length < 3 || value.length > 80 || GENERIC_HEADING_PATTERN.test(value)) return false; + // The floor rejects Latin fragments — "AI", "v2", "UI" — that head a section + // without naming a capability. A Chinese term reaches that status two characters + // in (选题, 成片, 导出, 登录), so measuring it by the Latin floor discarded most of + // a Chinese README's headings before anything could match them. + const minimumLength = hasHan(value) ? 2 : 3; + if (!value || value.length < minimumLength || value.length > 80 || GENERIC_HEADING_PATTERN.test(value)) return false; return !/^(v?\d+(\.\d+)+|https?:|npm |pnpm |yarn )/i.test(value); } @@ -453,7 +459,9 @@ function cleanMarkdown(value: string): string { function keywords(value: string): string[] { const latin = value.toLowerCase().match(/[a-z][a-z0-9-]{2,}/g) ?? []; - const cjk = value.match(/[\u3400-\u9fff]{2,8}/g) ?? []; + // Shared with the compiler's matcher: both sides have to cut Han runs the same + // way, or a capability's keywords and the code's terms can never meet. + const cjk = cjkTokens(value); const stop = new Set([ "agent", "agents", "and", "before", "can", "feature", "features", "for", "from", "into", "its", "later", "project", "returning", "runtime", "system", "that", "the", "this", "through", "using", "with", diff --git a/packages/project-reader/tsconfig.json b/packages/project-reader/tsconfig.json index 06e70db..dd3f580 100644 --- a/packages/project-reader/tsconfig.json +++ b/packages/project-reader/tsconfig.json @@ -7,6 +7,7 @@ }, "include": ["src/**/*.ts"], "references": [ - { "path": "../schema" } + { "path": "../schema" }, + { "path": "../analysis-kit" } ] } diff --git a/tests/features.test.ts b/tests/features.test.ts index f069fa1..9440a24 100644 --- a/tests/features.test.ts +++ b/tests/features.test.ts @@ -59,6 +59,33 @@ describe("feature chain compiler", () => { expect(features[0].variants.slice(1).every((variant) => variant.resultNodeId)).toBe(true); }); + it("names a feature after the Chinese capability its documentation declares", () => { + // The reader keeps Han terms whole and in 2-grams; the matcher used to drop every + // token shorter than three characters, which is every second Chinese term. The two + // sides then tokenized the same words differently and a documented Chinese + // capability could never win, so features fell back to their code names — the + // reason a Chinese project's list read as "(底层)创作库事务" instead of "成片". + const nodes = [ + node("生成成片", "entrypoint"), + node("渲染", "ai_process"), + node("成片文件", "result"), + ]; + const edges = [edge("生成成片", "渲染"), edge("渲染", "成片文件")]; + + const features = compileFeatureScenarios(nodes, edges, [{ + id: "capability_render", + label: "成片", + description: "自动拍成可导进剪映的成片。", + keywords: ["成片", "剪映"], + origin: "readme", + sources: [{ file: "README.md", startLine: 3 }], + confidence: 0.8, + }]); + + expect(features).toHaveLength(1); + expect(features[0].label).toBe("成片"); + }); + it("marks an entry with no downstream chain as a deterministic error", () => { const features = compileFeatureScenarios([node("POST /api/publish", "entrypoint")], []); diff --git a/tests/project-reader.test.ts b/tests/project-reader.test.ts index 9734d6f..2826a3a 100644 --- a/tests/project-reader.test.ts +++ b/tests/project-reader.test.ts @@ -62,6 +62,55 @@ describe("project reader", () => { expect(context.capabilityHints.map((capability) => capability.label)).toEqual(["Safe Feature"]); }); + it("reads a two-character Chinese capability heading the way it reads an English one", async () => { + // A Chinese term is complete at two characters — 选题, 成片, 导出, 登录 are all + // whole business capabilities. The three-character floor exists to reject Latin + // fragments ("AI", "v2"), and applying it to Han characters silently dropped the + // majority of a Chinese README's headings: the capability never reached the + // matcher, so every feature fell back to naming itself after code. + const root = await mkdtemp(path.join(os.tmpdir(), "agent-map-cjk-headings-")); + temporaryDirectories.push(root); + await writeFile(path.join(root, "package.json"), JSON.stringify({ name: "cjk-headings" })); + await writeFile(path.join(root, "README.md"), [ + "# 编导工作台", + "", + "## 选题", + "从热点里找选题。", + "", + "## 成片", + "自动拍成可导进剪映的成片。", + "", + "## 出点子", + "给选题出点子。", + "", + "## AI", + "两个字母的拉丁缩写仍然不算一个能力。", + "", + ].join("\n")); + + const context = await readProjectContext(root); + const labels = context.capabilityHints.map((capability) => capability.label); + + expect(labels).toEqual(expect.arrayContaining(["选题", "成片", "出点子"])); + expect(labels).not.toContain("AI"); + }); + + it("keeps a Chinese term findable instead of gluing it to its neighbours", async () => { + // Han runs have no spaces to split on. Matching a run greedily produced tokens + // like "给选题出点子" (a whole sentence) and cut "工作台" in half at an 8-character + // cap, so a capability's keywords could not meet the code's own terms halfway. + const root = await mkdtemp(path.join(os.tmpdir(), "agent-map-cjk-tokens-")); + temporaryDirectories.push(root); + await writeFile(path.join(root, "package.json"), JSON.stringify({ name: "cjk-tokens" })); + await writeFile(path.join(root, "README.md"), "# 工作台\n\n## 出点子\n\n给选题出点子。\n"); + + const context = await readProjectContext(root); + const hint = context.capabilityHints.find((capability) => capability.label === "出点子"); + + // "选题" is the term a reader would search for; before, only the full sentence survived. + expect(hint?.keywords).toEqual(expect.arrayContaining(["选题"])); + }); + it("feeds project understanding and documented feature names into the generated graph", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "agent-map-understanding-")); temporaryDirectories.push(root); From 7e6dd38a5a9a56788fc2a445b7cd69fe225fea1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yishun=20Tang=20=C2=B7=20CrazyAnt?= Date: Fri, 11 Sep 2026 14:19:23 +0800 Subject: [PATCH 2/3] fix: let only a capability's own name carry an entry match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splitting Han runs into 2-grams put far more words within reach of the matcher, including words that come from a capability's description rather than its name. An entry-term hit weighs 8 and so decides the match on its own, which made a new failure possible: an entry called 自动重试 matched the capability 成片, because that capability's description reads 自动拍成可导进剪映的成片. Presenting a retry loop as a business capability is worse than leaving it named after code — it is wrong, and it looks right, which is the one failure mode this tool exists to avoid. The capability's name alone now carries entry weight. Its description still contributes step evidence, where it is weighed at 1 and has to clear the existing weak-match floor. Found by probing the fix that preceded it, not by a report. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 6 ++++++ packages/logic-compiler/src/features.ts | 9 ++++++++- tests/features.test.ts | 24 ++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc039f9..31ab63c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,12 @@ All notable changes are documented here. README's capabilities almost never matched, and features fell back to naming themselves after code. Both sides now split Han runs the same way — whole term plus 2-grams — and the length floor applies only where it was meant to. +- A feature could borrow a documented capability's name on the strength of a word + from that capability's *description* rather than its name. An entry-term hit + weighs 8 and decides the match, so a retry loop could be presented as `成片` + because the capability's description happened to contain `自动` — wrong, and + wrong in a way that looks right. Only a capability's own name can carry an entry + match now; its description still counts as ordinary step evidence. ## 0.9.2 - 2026-09-03 diff --git a/packages/logic-compiler/src/features.ts b/packages/logic-compiler/src/features.ts index aaf734a..a2a168f 100644 --- a/packages/logic-compiler/src/features.ts +++ b/packages/logic-compiler/src/features.ts @@ -144,7 +144,14 @@ function matchDocumentedCapability( let best: FeatureCapabilityMatch | undefined; for (const capability of capabilities) { const capabilityTokens = semanticTokens(`${capability.label} ${capability.keywords.join(" ")}`); - const entryHits = [...capabilityTokens].filter((token) => entryTokens.has(token)).length; + // An entry hit weighs 8, so a single one decides the match. Only the capability's + // own name may carry that weight: words from its description ("自动" inside + // "自动拍成…可导进剪映的成片") say nothing about whether an entry implements the + // capability, and splitting Han runs into 2-grams puts many such words within + // reach of an unrelated entry name. The description still counts as step + // evidence below, where it is weighed at 1 and has to clear the weak-match floor. + const nameTokens = semanticTokens(capability.label); + const entryHits = [...nameTokens].filter((token) => entryTokens.has(token)).length; const graphHits = [...capabilityTokens].filter((token) => tokens.has(token)).length; const score = entryHits * 8 + graphHits + (documentedCounts.get(capability.id) ?? 0) * 0.5; if (score > 0 && (!best || score > best.score || (score === best.score && capability.confidence > best.capability.confidence))) { diff --git a/tests/features.test.ts b/tests/features.test.ts index 9440a24..e1b5e86 100644 --- a/tests/features.test.ts +++ b/tests/features.test.ts @@ -86,6 +86,30 @@ describe("feature chain compiler", () => { expect(features[0].label).toBe("成片"); }); + it("refuses to borrow a capability name on a word from its description", () => { + // Entry hits weigh 8, so one is decisive. A common word that appears in a + // capability's *description* — 自动, 生成, 处理 — is not evidence that an entry + // implements it, and splitting Han runs into 2-grams puts many such words in + // reach. Naming a retry loop "成片" is worse than leaving it named after code: + // it is wrong and it looks right. Only the capability's own name can carry an + // entry match; its description still contributes ordinary step evidence. + const nodes = [node("自动重试", "entrypoint"), node("等待", "process"), node("重试结果", "result")]; + const edges = [edge("自动重试", "等待"), edge("等待", "重试结果")]; + + const features = compileFeatureScenarios(nodes, edges, [{ + id: "capability_render", + label: "成片", + description: "自动拍成可导进剪映的成片。", + keywords: ["成片", "自动拍成可导进剪映的成片"], + origin: "readme", + sources: [{ file: "README.md", startLine: 3 }], + confidence: 0.8, + }]); + + expect(features[0].label).toBe("自动重试"); + expect(features[0].product).toBeUndefined(); + }); + it("marks an entry with no downstream chain as a deterministic error", () => { const features = compileFeatureScenarios([node("POST /api/publish", "entrypoint")], []); From c253b538d3f2b38cf64831aadc937ddb5b61a44a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yishun=20Tang=20=C2=B7=20CrazyAnt?= Date: Fri, 11 Sep 2026 14:30:11 +0800 Subject: [PATCH 3/3] feat: lead the feature list with what the project says it does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every entrypoint a user action cannot reach becomes its own feature, so a large codebase contributes one per internal transaction, auth check and helper. They are real entries, but the list sorts by health and then alphabetically — not by importance — so in a 744-file project the internal ones surfaced first and the reader scrolled past the capabilities the product actually offers. `product` already carries the distinction: it is set only when a documented capability matched strongly enough to lend its name, which is exactly "the project says this exists". Documented capabilities now lead the list; the rest move behind a disclosure that says what it holds and how many. Nothing is removed and nothing is hidden. A project that documents nothing would otherwise get an empty list and a drawer containing its entire map, which is strictly worse than the flat list it had. With no signal to separate on, the flat list is kept. Matching also had to reach capabilities that arrive without a route: a project whose business logic is exported functions has no entrypoint node to carry the high-weight entry match, so 选题 was rejected even with a step called 热点选题. A capability's own name inside a step is now strong evidence, weighed above the words of its description, which stay weak enough that they still cannot clear the weak-match floor alone. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 16 ++++++++ apps/viewer/src/App.tsx | 5 ++- apps/viewer/src/i18n.ts | 4 ++ apps/viewer/src/interactionModel.ts | 28 +++++++++++++- apps/viewer/src/styles.css | 11 ++++++ packages/logic-compiler/src/features.ts | 9 ++++- tests/features.test.ts | 24 ++++++++++++ tests/interaction-model.test.ts | 50 ++++++++++++++++++++++++- 8 files changed, 142 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31ab63c..efa47ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,22 @@ All notable changes are documented here. because the capability's description happened to contain `自动` — wrong, and wrong in a way that looks right. Only a capability's own name can carry an entry match now; its description still counts as ordinary step evidence. +- A capability named inside a step could not be matched unless the project routed + it through an entrypoint, so a codebase whose logic is exported functions saw + its documented capabilities rejected by the weak-match floor. A capability's own + name appearing in a step now counts as strong evidence, while a word from its + description still counts as weak. + +### Changed + +- The feature list separates the capabilities a project documents from the bare + entry points only the code knows about. Every entrypoint the user cannot reach + becomes a feature, so a large codebase contributes one per internal transaction, + auth check and helper — and since the list sorts by health rather than + importance, those surfaced first and buried the product's own capabilities. + Documented capabilities now lead the list and the rest move behind an "Other + entry points" disclosure that states what it holds. Nothing is removed, and a + project that documents nothing keeps the flat list it had. ## 0.9.2 - 2026-09-03 diff --git a/apps/viewer/src/App.tsx b/apps/viewer/src/App.tsx index f02b7fc..580495c 100644 --- a/apps/viewer/src/App.tsx +++ b/apps/viewer/src/App.tsx @@ -46,7 +46,7 @@ import type { ChainHealth, FeaturePathVariant, FeatureScenario, LogicGraph, LogicNode as LogicGraphNode, ProductEvidence, RawCodeGraph, RawCodeNode, SourceLocation, } from "@agent-runtime-map/schema"; -import { applyLayoutPositions, buildCodeDetailExpansion, canFocusNode, captureLayout, collectFocusIds, compareVariants, matchingNodeIds, parseDetailNodeId, parseLayoutPositions, type LayoutPositions } from "./interactionModel"; +import { applyLayoutPositions, buildCodeDetailExpansion, canFocusNode, captureLayout, collectFocusIds, compareVariants, groupFeatures, matchingNodeIds, parseDetailNodeId, parseLayoutPositions, type LayoutPositions } from "./interactionModel"; import { chainHealthLabel, detectViewerLocale, groupLabels, overviewLabels, overviewCountsLabel, labelSourceLabel, rawEdgeLabel, rawKindLabel, resolveEdgeText, resolveFeatureText, resolveNodeText, inferenceMethodLabel, localizeDiagnostic, localizeFeatureLabel, @@ -121,6 +121,7 @@ function LogicMapViewer() { const nodesInitialized = useNodesInitialized(); const text = messages(locale); const features = graph?.features ?? []; + const featureGroups = useMemo(() => groupFeatures(features), [features]); const selectedFeature = features.find((feature) => feature.id === selectedFeatureId); const selectedVariant = selectedFeature?.variants.find((variant) => variant.id === selectedVariantId) ?? selectedFeature?.variants[0]; const previousVariant = selectedFeature?.variants.find((variant) => variant.id === previousVariantId); @@ -630,7 +631,7 @@ function LogicMapViewer() {