Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,43 @@

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.
- 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.
- 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

### Changed
Expand Down
5 changes: 3 additions & 2 deletions apps/viewer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -630,7 +631,7 @@ function LogicMapViewer() {
<aside className="sidebar">
<div className="sidebar__intro"><span className="eyebrow">{text.projectMap}</span><h1>{localizeGraphTitle(graph, locale)}</h1><p>{localizeGraphDescription(graph, locale)}</p></div>
<div className="stats"><Stat value={features.length} label={text.features} /><Stat value={graph.nodes.length} label={text.logicNodes} /><Stat value={graph.project.filesScanned} label={text.files} /></div>
<section className="feature-circuits" aria-label={text.featureCircuits}><div className="section-heading"><span className="eyebrow">{text.featureCircuits}</span><ListTree size={14} /></div><p className="section-hint">{text.featureHint}</p><button className={`feature-card feature-card--global ${selectedFeatureId === null ? "is-active" : ""}`} onClick={() => selectFeature(null)}><span className="feature-card__icon"><Activity size={14} /></span><span><strong>{text.wholeSystem}</strong><small>{text.globalView}</small></span></button><div className="feature-list">{features.map((feature) => <button className={`feature-card feature-card--${feature.health} ${feature.id === selectedFeatureId ? "is-active" : ""}`} data-feature-id={feature.id} data-health={feature.health} key={feature.id} onClick={() => selectFeature(feature.id)}><span className="feature-card__icon"><HealthIcon health={feature.health} /></span><span><strong>{resolveFeatureText(feature, graph, locale).label}{resolveFeatureText(feature, graph, locale).pending && <em className="pending-flag pending-flag--inline">{text.pendingBadge}</em>}</strong><small>{chainHealthLabel(feature.health, locale)} · {Math.round(feature.confidence * 100)}%</small></span><ChevronRight size={13} /></button>)}</div></section>
<section className="feature-circuits" aria-label={text.featureCircuits}><div className="section-heading"><span className="eyebrow">{text.featureCircuits}</span><ListTree size={14} /></div><p className="section-hint">{text.featureHint}</p><button className={`feature-card feature-card--global ${selectedFeatureId === null ? "is-active" : ""}`} onClick={() => selectFeature(null)}><span className="feature-card__icon"><Activity size={14} /></span><span><strong>{text.wholeSystem}</strong><small>{text.globalView}</small></span></button><div className="feature-list">{featureGroups.primary.map((feature) => <button className={`feature-card feature-card--${feature.health} ${feature.id === selectedFeatureId ? "is-active" : ""}`} data-feature-id={feature.id} data-health={feature.health} key={feature.id} onClick={() => selectFeature(feature.id)}><span className="feature-card__icon"><HealthIcon health={feature.health} /></span><span><strong>{resolveFeatureText(feature, graph, locale).label}{resolveFeatureText(feature, graph, locale).pending && <em className="pending-flag pending-flag--inline">{text.pendingBadge}</em>}</strong><small>{chainHealthLabel(feature.health, locale)} · {Math.round(feature.confidence * 100)}%</small></span><ChevronRight size={13} /></button>)}</div>{featureGroups.other.length > 0 && <details className="other-entries"><summary><span>{text.otherEntries}</span><b>{featureGroups.other.length}</b></summary><p className="section-hint">{text.otherEntriesHint}</p><div className="feature-list">{featureGroups.other.map((feature) => <button className={`feature-card feature-card--${feature.health} ${feature.id === selectedFeatureId ? "is-active" : ""}`} data-feature-id={feature.id} data-health={feature.health} key={feature.id} onClick={() => selectFeature(feature.id)}><span className="feature-card__icon"><HealthIcon health={feature.health} /></span><span><strong>{resolveFeatureText(feature, graph, locale).label}{resolveFeatureText(feature, graph, locale).pending && <em className="pending-flag pending-flag--inline">{text.pendingBadge}</em>}</strong><small>{chainHealthLabel(feature.health, locale)} · {Math.round(feature.confidence * 100)}%</small></span><ChevronRight size={13} /></button>)}</div></details>}</section>
{selectedFeature && selectedVariant ? <FeatureInspector feature={selectedFeature} variant={selectedVariant} graph={graph} locale={locale} playing={playing} speed={speed} frame={frame} cameraFollow={cameraFollow} onVariant={selectVariant} onPlay={play} onPause={() => setPlaying(false)} onNext={next} onReset={reset} onSpeed={setSpeed} onSelectNode={setSelectedId} onResumeFollow={() => setCameraFollow(true)} /> : <div className="feature-empty"><CircleDotDashed size={16} /><span>{text.selectFeature}</span></div>}
<div className="search-wrap"><label className="search-box"><Search size={15} /><input value={query} onChange={(event) => setQuery(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" && searchResults[0]) selectSearchResult(searchResults[0].id); }} placeholder={text.search} />{query && <button onClick={() => setQuery("")} aria-label={text.clearSearch}><X size={14} /></button>}</label>{query && <div className="search-results"><span className="eyebrow">{text.searchResults} · {searchResults.length}</span>{searchResults.length ? searchResults.map((node) => <button key={node.id} onClick={() => selectSearchResult(node.id)}><strong>{resolveNodeText(node, locale, nodesById).label}</strong><small>{node.sources[0]?.file ?? nodeTypeLabel(node.type, locale)}</small></button>) : <p>{text.noSearchResults}</p>}</div>}</div>
<div className="sidebar__footer"><Braces size={14} /> {text.staticAnalysis}</div>
Expand Down
4 changes: 4 additions & 0 deletions apps/viewer/src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ const EN = {
switchLanguage: "切换到中文",
featureCircuits: "FEATURE CIRCUITS",
featureHint: "Choose a feature to simulate its code-backed route on the full Agent graph.",
otherEntries: "Other entry points",
otherEntriesHint: "Reachable entries your documentation does not describe — internal transactions, auth checks, helpers. Nothing is hidden; they are only kept out of the way.",
wholeSystem: "Whole system",
features: "features",
healthy: "Healthy",
Expand Down Expand Up @@ -150,6 +152,8 @@ const ZH: Record<keyof typeof EN, string> = {
switchLanguage: "Switch to English",
featureCircuits: "功能电路",
featureHint: "选择一个功能,在完整 Agent 图上模拟它的代码执行路线。",
otherEntries: "其他入口",
otherEntriesHint: "文档没有描述的入口:内部事务、登录校验、工具函数等。没有隐藏任何东西,只是先收起来。",
wholeSystem: "全局系统",
features: "个功能",
healthy: "链路正常",
Expand Down
28 changes: 27 additions & 1 deletion apps/viewer/src/interactionModel.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,33 @@
import type { Edge, Node, XYPosition } from "@xyflow/react";
import { BLUEPRINT_CODE_NODE_HEIGHT, BLUEPRINT_CODE_NODE_WIDTH } from "@agent-runtime-map/react";
import type { BlueprintCodeNodeData, BlueprintLogicNodeData } from "@agent-runtime-map/react";
import type { FeaturePathVariant, LogicEdge, LogicNode, RawCodeGraph, RawCodeNode } from "@agent-runtime-map/schema";
import type { FeaturePathVariant, FeatureScenario, LogicEdge, LogicNode, RawCodeGraph, RawCodeNode } from "@agent-runtime-map/schema";

export interface FeatureGroups {
primary: FeatureScenario[];
other: FeatureScenario[];
}

/**
* Every entrypoint the user's own actions cannot reach becomes a feature, so a large
* codebase contributes one per internal transaction, auth check and helper. They are
* real entries and stay available, but listing them beside the product's capabilities
* buries the capabilities: the list is sorted by health, not importance, so in
* practice the internal ones surface first and the reader scrolls past the answer.
*
* `product` is the split: it is set only when a documented capability matched
* strongly enough to lend its name, so it already means "the project says this
* exists". Nothing is dropped — the rest moves behind a disclosure.
*
* A project with no documentation at all would otherwise get an empty list and a
* drawer holding everything, which is strictly worse than the flat list it had. When
* nothing is documented there is no signal to separate, so the flat list is kept.
*/
export function groupFeatures(features: FeatureScenario[]): FeatureGroups {
const documented = features.filter((feature) => feature.product);
if (!documented.length) return { primary: features, other: [] };
return { primary: documented, other: features.filter((feature) => !feature.product) };
}

export interface CodeDetailExpansion {
nodes: Node<BlueprintCodeNodeData>[];
Expand Down
11 changes: 11 additions & 0 deletions apps/viewer/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,17 @@ button { color: inherit; }
.section-heading > span:last-child { font: 8px ui-monospace, SFMono-Regular, Menlo, monospace; }
.section-hint { margin: 8px 0 10px; color: #848d98; font-size: 9px; line-height: 1.45; }
.feature-circuits { flex: 0 0 auto; }
/* Entries the documentation does not name stay reachable but stop competing with
the capabilities for the reader's first glance. Muted, not hidden. */
.other-entries { margin-top: 8px; border-top: 1px dashed #dfe4ea; padding-top: 8px; }
.other-entries > summary { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 4px 2px; color: #848d98; font-size: 9px; font-weight: 650; letter-spacing: .04em; text-transform: uppercase; cursor: pointer; list-style: none; }
.other-entries > summary::-webkit-details-marker { display: none; }
.other-entries > summary::before { content: "▸"; margin-right: 2px; color: #a8b2bc; font-size: 8px; transition: transform .16s ease; }
.other-entries[open] > summary::before { transform: rotate(90deg); }
.other-entries > summary:hover { color: #5a6672; }
.other-entries > summary b { flex: 0 0 auto; min-width: 17px; padding: 1px 5px; border-radius: 7px; color: #7d8793; font-size: 8px; font-weight: 650; background: #eef2f5; }
.other-entries > summary > span { flex: 1 1 auto; }
.other-entries .feature-list { max-height: 158px; }
.feature-list { display: grid; gap: 6px; max-height: 214px; margin-top: 6px; padding-right: 2px; overflow: auto; }
.feature-card { display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; align-items: center; gap: 9px; width: 100%; min-height: 45px; padding: 7px 9px; border: 1px solid #dfe4ea; border-radius: 9px; color: #7d8793; background: rgba(255,255,255,.82); text-align: left; cursor: pointer; box-shadow: 0 2px 8px rgba(39,48,58,.025); transition: .16s ease; }
.feature-card:hover { border-color: #b7c5d1; background: #fff; transform: translateX(1px); }
Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

36 changes: 36 additions & 0 deletions packages/analysis-kit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends { id: string }>(items: T[]): T[] {
const seen = new Set<string>();
return items.filter((item) => {
Expand Down
34 changes: 28 additions & 6 deletions packages/logic-compiler/src/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -143,9 +144,23 @@ 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;
// Evidence is weighed by what it is, not only by where it lands. A capability's
// own name inside a step ("选题" within a step called "热点选题") is strong: not
// every project routes its capabilities through HTTP, and one whose logic is
// exported functions has no entrypoint node to carry the entry weight at all.
// A word from the capability's *description* landing in the same place is weak,
// and stays at 1 so it still cannot clear the floor on its own.
const nameHits = [...nameTokens].filter((token) => tokens.has(token)).length;
const graphHits = [...capabilityTokens].filter((token) => tokens.has(token)).length;
const score = entryHits * 8 + graphHits + (documentedCounts.get(capability.id) ?? 0) * 0.5;
const score = entryHits * 8 + nameHits * 3 + graphHits + (documentedCounts.get(capability.id) ?? 0) * 0.5;
if (score > 0 && (!best || score > best.score || (score === best.score && capability.confidence > best.capability.confidence))) {
best = {
capability,
Expand All @@ -163,10 +178,17 @@ function matchDocumentedCapability(
}

function semanticTokens(value: string): Set<string> {
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 {
Expand Down
Loading
Loading