From 78c1faea215f3a74dfddb937709c1e6ad3ac39b9 Mon Sep 17 00:00:00 2001 From: RaghavChamadiya Date: Sun, 12 Jul 2026 18:52:22 +0530 Subject: [PATCH] fix(onboarding): rank Key Concepts by symbol importance and ground citations Key Concepts chose its "concepts" by file PageRank, so every public symbol in a high-ranked file inherited that file's rank. On this repo that surfaced five methods of one registry class (__init__, get, from_extension, import_support_map) from a single leaf cluster as the whole codebase's core concepts, and leaked pytest fixtures out of the candidate list into the prose. Rank symbols directly instead: cross-file caller count, symbol-level PageRank, and the export marker. Prefer domain-noun kinds (class, interface, enum, struct, ...) over functions and methods, drop dunders, constructors, and trivial accessors, exclude test files, and spread the page across clusters and files with per-file and per-cluster caps so no single corner owns it. Candidates are drawn from the full symbol graph rather than the parsed-files list, so the page stays correct on an incremental update where only changed files are re-parsed. The chosen concepts' real dependency edges are passed to the template, so the "How they connect" section states links that exist instead of inventing them. Add a reusable grounding post-check that strips ungrounded backticked path and symbol citations from onboarding output (demoted to plain text, reported for logging). It runs on fresh and reused content, so a cached page carrying a stale fabrication is cleaned on the next docs update. Add ONBOARDING_GENERATION_VERSION, folded into each onboarding page's cross-run reuse hash through a new source_salt parameter, so a builder or template change forces a one-time regeneration of the collection for existing docs-enabled users. --- .../core/generation/onboarding/__init__.py | 11 +- .../core/generation/onboarding/grounding.py | 219 +++++++++ .../core/generation/onboarding/slots.py | 8 + .../onboarding/subkinds/key_concepts.py | 445 ++++++++++++++---- .../core/generation/page_generator/core.py | 12 +- .../core/generation/page_generator/pertype.py | 21 +- .../templates/onboarding/key_concepts.j2 | 23 +- .../test_onboarding_key_concepts.py | 443 +++++++++++++++++ 8 files changed, 1085 insertions(+), 97 deletions(-) create mode 100644 packages/core/src/repowise/core/generation/onboarding/grounding.py create mode 100644 tests/unit/generation/test_onboarding_key_concepts.py diff --git a/packages/core/src/repowise/core/generation/onboarding/__init__.py b/packages/core/src/repowise/core/generation/onboarding/__init__.py index 0c42172cc..ab98df48e 100644 --- a/packages/core/src/repowise/core/generation/onboarding/__init__.py +++ b/packages/core/src/repowise/core/generation/onboarding/__init__.py @@ -18,9 +18,13 @@ - :mod:`subkinds` — subkind modules that register themselves on import. """ +# Side-effect import: registers every implemented subkind. +from . import subkinds # noqa: F401 +from .grounding import check_grounding from .registry import SubkindSpec, get_spec, iter_specs, register from .signals import OnboardingSignals from .slots import ( + ONBOARDING_GENERATION_VERSION, ONBOARDING_ORDER, PROMOTED_SLOTS, SLOT_ACTIVE_LANDSCAPE, @@ -36,12 +40,9 @@ target_path, ) -# Side-effect import: registers every implemented subkind. -from . import subkinds # noqa: E402, F401 - __all__ = [ + "ONBOARDING_GENERATION_VERSION", "ONBOARDING_ORDER", - "OnboardingSignals", "PROMOTED_SLOTS", "SLOT_ACTIVE_LANDSCAPE", "SLOT_ARCHITECTURE_GUIDE", @@ -53,7 +54,9 @@ "SLOT_KEY_CONCEPTS", "SLOT_PROJECT_OVERVIEW", "SLOT_TITLES", + "OnboardingSignals", "SubkindSpec", + "check_grounding", "get_spec", "iter_specs", "register", diff --git a/packages/core/src/repowise/core/generation/onboarding/grounding.py b/packages/core/src/repowise/core/generation/onboarding/grounding.py new file mode 100644 index 000000000..90dfd65b5 --- /dev/null +++ b/packages/core/src/repowise/core/generation/onboarding/grounding.py @@ -0,0 +1,219 @@ +"""Post-generation grounding check for onboarding pages. + +Onboarding prose is grounded ONLY by prompt instruction ("do not invent +file paths or symbol names"). Nothing verified the model obeyed, so a +fabricated citation - a file the payload never mentioned, a symbol that +does not exist - reached the reader as an authoritative backticked +reference. + +This module closes that gap deterministically. It collects the paths and +symbols actually present in a subkind's context object, then scans the +generated markdown for backticked tokens that *look* like a file path or a +code symbol. A token that is clearly one of those shapes but is absent from +the context is "ungrounded": its backticks are stripped so it is no longer +presented as a verified reference, and it is reported for logging. + +Design choices, all in the safe direction (never mangle a good page): + - Only backticked spans are examined; prose is untouched. + - A token is checked only when its shape is unambiguous - a path with a + source-code extension, or an identifier that is CamelCase / snake_case / + dotted / ``::``-qualified. Lowercase single words (enum values like + ``full``) are left alone. + - "Grounded" matching is generous (suffix / basename for paths, membership + for symbols) so legitimate abbreviations survive. + - Ungrounded tokens are demoted to plain text, not deleted, so sentences + stay intact. + +Run this on the content returned by the provider whether it was freshly +generated or reused from a prior run, so an existing user's cached page is +cleaned on their next docs update even when the prompt is unchanged. +""" + +from __future__ import annotations + +import re +from dataclasses import fields, is_dataclass +from typing import Any + +# Backticked span: `...` with no backtick or newline inside. +_BACKTICK = re.compile(r"`([^`\n]+)`") + +# Source-code file extensions. A backticked token ending in one of these +# (optionally with a member suffix) is treated as a path citation. +_CODE_EXTENSIONS = frozenset( + { + "py", + "pyi", + "ts", + "tsx", + "js", + "jsx", + "mjs", + "cjs", + "go", + "rs", + "java", + "kt", + "kts", + "scala", + "rb", + "php", + "cs", + "cpp", + "cc", + "cxx", + "c", + "h", + "hpp", + "swift", + "dart", + "sql", + "sh", + "lua", + "ex", + "exs", + "clj", + "vue", + "svelte", + "m", + "mm", + } +) + +# A bare identifier, optionally dotted or ``::``-qualified (e.g. ``LanguageSpec``, +# ``get_session``, ``foo.Bar.baz``, ``path.py::Name``). +_IDENT = re.compile( + r"^[A-Za-z_][A-Za-z0-9_]*(?:(?:\.|::)[A-Za-z_][A-Za-z0-9_]*)+$|^[A-Za-z_][A-Za-z0-9_]*$" +) + + +def _looks_like_path(token: str) -> bool: + """True when *token* is shaped like a source file path we can verify.""" + head = token.split("::", 1)[0] + head = head.split("#", 1)[0].strip() + if "." not in head: + return False + ext = head.rsplit(".", 1)[-1].lower() + return ext in _CODE_EXTENSIONS + + +def _looks_like_symbol(token: str) -> bool: + """True when *token* is an unambiguous code identifier worth checking. + + Skips lowercase single words (``full``, ``none``) - too likely to be an + enum value or an English word the model legitimately quoted. + """ + if not _IDENT.match(token): + return False + if "." in token or "::" in token: + return True + if "_" in token: + return True + # CamelCase / has an internal capital: an uppercase letter after the first + # character marks it as a type-like identifier rather than a plain word. + return any(ch.isupper() for ch in token[1:]) + + +def _iter_strings(obj: Any, _depth: int = 0) -> Any: + """Yield every string reachable inside a (possibly nested) context object.""" + if _depth > 6: + return + if isinstance(obj, str): + yield obj + elif is_dataclass(obj) and not isinstance(obj, type): + for f in fields(obj): + yield from _iter_strings(getattr(obj, f.name), _depth + 1) + elif isinstance(obj, dict): + for k, v in obj.items(): + yield from _iter_strings(k, _depth + 1) + yield from _iter_strings(v, _depth + 1) + elif isinstance(obj, (list, tuple, set, frozenset)): + for item in obj: + yield from _iter_strings(item, _depth + 1) + + +def collect_known(ctx: Any) -> tuple[set[str], set[str]]: + """Collect the known paths and symbols from a subkind context object. + + Returns ``(known_paths, known_symbols)``. ``known_paths`` includes each + path plus its basename so a page that cites ``builder.py`` still matches a + payload path of ``.../builder.py``. ``known_symbols`` also includes the + last dotted / ``::`` segment of each identifier so ``Foo.bar`` grounds a + citation of ``bar``. + """ + known_paths: set[str] = set() + known_symbols: set[str] = set() + for s in _iter_strings(ctx): + token = s.strip() + if not token: + continue + if _looks_like_path(token): + head = token.split("::", 1)[0].split("#", 1)[0].strip() + known_paths.add(head) + known_paths.add(head.rsplit("/", 1)[-1]) + # A string can carry both a path and a symbol vocabulary; also mine + # bare identifiers as known symbols. + if _looks_like_symbol(token): + known_symbols.add(token) + for part in re.split(r"\.|::", token): + if part: + known_symbols.add(part) + return known_paths, known_symbols + + +def _path_grounded(token: str, known_paths: set[str]) -> bool: + head = token.split("::", 1)[0].split("#", 1)[0].strip() + if head in known_paths: + return True + base = head.rsplit("/", 1)[-1] + if base in known_paths: + return True + # Cited path is a suffix of a known path (or vice versa) - same file, + # different depth of qualification. + return any(kp.endswith("/" + head) or head.endswith("/" + kp) for kp in known_paths) + + +def _symbol_grounded(token: str, known_symbols: set[str]) -> bool: + if token in known_symbols: + return True + # Grounded if any qualified segment is known (``Registry.get`` grounds on + # ``Registry``; a member of a known concept is acceptable). + parts = [p for p in re.split(r"\.|::", token) if p] + return any(p in known_symbols for p in parts) + + +def check_grounding(content: str, ctx: Any) -> tuple[str, list[str]]: + """Strip ungrounded path/symbol citations from *content*. + + Returns ``(cleaned_content, ungrounded_tokens)``. Each ungrounded token + keeps its text but loses its backticks, so it reads as prose rather than a + verified code reference. ``ungrounded_tokens`` is deduplicated in first-seen + order for logging. + """ + if not content: + return content, [] + known_paths, known_symbols = collect_known(ctx) + ungrounded: list[str] = [] + seen: set[str] = set() + + def replace(match: re.Match[str]) -> str: + token = match.group(1).strip() + is_path = _looks_like_path(token) + is_symbol = (not is_path) and _looks_like_symbol(token) + if not is_path and not is_symbol: + return match.group(0) + grounded = ( + _path_grounded(token, known_paths) + if is_path + else _symbol_grounded(token, known_symbols) + ) + if grounded: + return match.group(0) + if token not in seen: + seen.add(token) + ungrounded.append(token) + # Demote to plain text (keep the token, drop the code-span backticks). + return match.group(1) + + cleaned = _BACKTICK.sub(replace, content) + return cleaned, ungrounded diff --git a/packages/core/src/repowise/core/generation/onboarding/slots.py b/packages/core/src/repowise/core/generation/onboarding/slots.py index 973c2adab..15d2dd9c3 100644 --- a/packages/core/src/repowise/core/generation/onboarding/slots.py +++ b/packages/core/src/repowise/core/generation/onboarding/slots.py @@ -16,6 +16,14 @@ from __future__ import annotations +# Generation version for onboarding pages. Folded into every onboarding +# page's source_hash, so bumping it forces a one-time regeneration of all +# onboarding slots on an existing user's next docs update - even when the +# rendered prompt is byte-identical. Bump when a builder or template change +# should reach already-cached pages. (File pages have an equivalent in +# ``_generation_fingerprint``; onboarding pages had none until this.) +ONBOARDING_GENERATION_VERSION = "2" + # ---- Slot identifiers ---- SLOT_PROJECT_OVERVIEW = "project_overview" diff --git a/packages/core/src/repowise/core/generation/onboarding/subkinds/key_concepts.py b/packages/core/src/repowise/core/generation/onboarding/subkinds/key_concepts.py index f3f85257c..02d8bbaec 100644 --- a/packages/core/src/repowise/core/generation/onboarding/subkinds/key_concepts.py +++ b/packages/core/src/repowise/core/generation/onboarding/subkinds/key_concepts.py @@ -1,17 +1,26 @@ """Onboarding subkind: Key Concepts. -The abstractions and vocabulary this codebase uses — the mental model +The abstractions and vocabulary this codebase uses - the mental model needed to read the code. Not a glossary dump but a narrative that -identifies 4–6 load-bearing types/functions and the architectural -clusters they belong to. +identifies 4-6 load-bearing concepts and the architectural clusters they +belong to. -Gate: at least 4 public symbols clear the PageRank P90 threshold. Below -that, the page would be guessing at "core concepts." +A concept is something many *other* files depend on, so importance is +measured on the SYMBOL graph (cross-file caller count, symbol-level +PageRank, export marker), not on the PageRank of the file a symbol +happens to live in. Ranking a symbol by its file's importance surfaced +whole leaf clusters (every method of one registry class) as "the core +concepts"; symbol-level signals surface the types the rest of the system +actually reaches for. + +Gate: at least 4 symbols survive filtering and ranking. Below that, the +page would be guessing at "core concepts." """ from __future__ import annotations -from collections import Counter +import math +import os from dataclasses import dataclass, field from typing import Any @@ -20,11 +29,33 @@ from ..slots import SLOT_KEY_CONCEPTS, SLOT_TITLES _GATE_MIN_CONCEPTS = 4 -_PAGERANK_PERCENTILE = 0.90 # top 10% of file PageRank scores -_TOP_SYMBOLS = 10 +_TOP_CONCEPTS = 6 _MAX_DECISION_RECORDS = 6 _MAX_COMMUNITY_LABELS = 6 +# Symbol kinds that read as domain nouns - a concept is usually a thing, +# not an action. Preferred over functions/methods when both are available, +# but never required: in function-first languages (Go, C) the fallback +# fills concept slots with the most-depended-on functions instead. +_NOUN_KINDS = frozenset( + {"class", "interface", "enum", "type_alias", "trait", "struct", "protocol", "dataclass"} +) +# Kinds that are never concepts: the synthetic per-file ``__module__`` +# symbol, and loose values (re-exported constants, module globals). +_SKIP_KINDS = frozenset({"module", "variable", "constant"}) +# Verb-shaped names that are plumbing, not concepts, regardless of how many +# callers they have. Kept deliberately small and generic so it does not +# swallow real domain functions in a function-first codebase. +_TRIVIAL_NAMES = frozenset( + {"get", "set", "run", "main", "new", "init", "of", "to", "call", "setup", "wrap"} +) +# Symbol-graph edge types that mean "depends on": a call, or a heritage +# link (subclass / interface implementation). +_CONCEPT_EDGE_TYPES = frozenset({"calls", "extends", "implements"}) +# Relationship edge types rendered into "How they connect" - heritage and +# calls plus resolved imports, so the LLM states real links, not guesses. +_RELATION_EDGE_TYPES = frozenset({"calls", "extends", "implements", "imports"}) + @dataclass class ConceptSymbol: @@ -35,39 +66,30 @@ class ConceptSymbol: kind: str file_path: str docstring: str = "" - pagerank: float = 0.0 - community_label: str = "" + cluster: str = "" + cross_file_callers: int = 0 + + +@dataclass +class ConceptRelation: + """One grounded edge between two chosen concepts, drawn from the graph + so "How they connect" states real links instead of inventing them.""" + + source: str + target: str + kind: str # calls | extends | implements | imports @dataclass class KeyConceptsContext: repo_name: str concept_symbols: list[ConceptSymbol] = field(default_factory=list) + relations: list[ConceptRelation] = field(default_factory=list) community_labels: list[str] = field(default_factory=list) decision_titles: list[str] = field(default_factory=list) layer_order: list[str] = field(default_factory=list) -def _pagerank_threshold(pagerank: dict[str, float]) -> float: - """Return the file-level PageRank cutoff at the configured percentile. - - The percentile is tightened to whichever yields the *more permissive* - cutoff between the configured P90 and "top N files" (where N is the - gate minimum). On a 5-file fixture pure P90 would only admit one - file — too tight to express the gate's intent. - """ - scores = sorted(pagerank.values(), reverse=True) - if not scores: - return 0.0 - idx_percentile = int(len(scores) * (1.0 - _PAGERANK_PERCENTILE)) - # Allow at least the top N files in (N-1 is the highest index that - # should still pass the threshold). Higher index = lower score = - # more permissive. - idx_min_admission = _GATE_MIN_CONCEPTS - 1 - idx = max(idx_percentile, idx_min_admission) - return scores[min(idx, len(scores) - 1)] - - def _resolve_community_labels(graph_builder: Any) -> dict[int, str]: labels: dict[int, str] = {} try: @@ -82,77 +104,331 @@ def _resolve_community_labels(graph_builder: Any) -> dict[int, str]: return labels -def _build(signals: OnboardingSignals) -> KeyConceptsContext | None: - threshold = _pagerank_threshold(signals.pagerank) - if threshold <= 0.0: - return None +def _file_to_layer(signals: OnboardingSignals) -> dict[str, str]: + """Map each file path to its KG layer name (the human-facing cluster). - labels_by_cid = _resolve_community_labels(signals.graph_builder) + Empty when the repo has no curated knowledge graph; callers fall back + to community labels, then to the file's directory. + """ + out: dict[str, str] = {} + for layer in signals.kg_layers: + name = str(layer.get("name", "")).strip() + if not name: + continue + for nid in layer.get("nodeIds", []) or []: + if isinstance(nid, str) and nid.startswith("file:"): + out[nid[len("file:") :]] = name + return out + + +@dataclass +class _GraphSignals: + """Per-symbol importance signals plus node metadata read off the graph. + + ``nodes`` maps each symbol node id to its attributes, so the builder can + draw candidates straight from the full graph rather than depending on the + ``parsed_files`` passed into generation. That list is only the CHANGED + files on an incremental ``update`` (empty when nothing changed), so a + parsed-files-only builder would produce a partial concept set (or none) on + every update; the graph always carries the whole repo. + """ + + callers: dict[str, int] = field(default_factory=dict) + pagerank: dict[str, float] = field(default_factory=dict) + nodes: dict[str, dict] = field(default_factory=dict) + test_files: set[str] = field(default_factory=set) + + +def _symbol_signals(graph_builder: Any) -> _GraphSignals: + """Pull per-symbol importance signals + node metadata off the graph. + + Degrades to empty on any failure (a rehydrated fast-mode graph may carry + no symbol nodes); the builder then falls back to ``parsed_files`` and + file-level PageRank so small repos still get a page. + """ + out = _GraphSignals() + try: + graph = graph_builder.graph() + except Exception: + return out + + file_of: dict[str, str] = {} + for node_id, data in graph.nodes(data=True): + node_type = data.get("node_type") + if node_type == "file": + if data.get("is_test"): + out.test_files.add(node_id) + continue + if node_type != "symbol": + continue + path = data.get("file_path", "") + file_of[node_id] = path + out.nodes[node_id] = { + "name": data.get("name", ""), + "kind": str(data.get("kind", "symbol")), + "file_path": path, + "visibility": data.get("visibility", "public"), + "docstring": (data.get("docstring") or "").strip()[:300], + "is_exported": bool(data.get("is_exported_symbol", False)), + } + + # Cross-file caller count = in-edges of a call/heritage type whose source + # lives in a different file. "Many OTHER files depend on it" is the + # signal that separates a concept from a busy local helper. + for src, dst, data in graph.edges(data=True): + if data.get("edge_type") not in _CONCEPT_EDGE_TYPES: + continue + if src not in file_of or dst not in file_of: + continue + if file_of[src] != file_of[dst]: + out.callers[dst] = out.callers.get(dst, 0) + 1 + + try: + out.pagerank = dict(graph_builder.symbol_pagerank()) + except Exception: + out.pagerank = {} + return out + + +def _relations_among( + graph_builder: Any, chosen: list[ConceptSymbol], id_by_name: dict[str, str] +) -> list[ConceptRelation]: + """Extract the real edge subgraph among the chosen concepts. + + Only edges where both endpoints are chosen concepts are kept, so the + "How they connect" section is grounded in dependencies that exist. + """ + chosen_ids = {id_by_name[c.name] for c in chosen if c.name in id_by_name} + name_by_id = {id_by_name[c.name]: c.name for c in chosen if c.name in id_by_name} + if len(chosen_ids) < 2: + return [] + try: + graph = graph_builder.graph() + except Exception: + return [] + seen: set[tuple[str, str, str]] = set() + relations: list[ConceptRelation] = [] + for src, dst, data in graph.edges(data=True): + etype = data.get("edge_type") + if etype not in _RELATION_EDGE_TYPES: + continue + if src not in chosen_ids or dst not in chosen_ids or src == dst: + continue + key = (name_by_id[src], name_by_id[dst], etype) + if key in seen: + continue + seen.add(key) + relations.append( + ConceptRelation(source=name_by_id[src], target=name_by_id[dst], kind=etype) + ) + return relations + + +def _is_trivial(name: str, kind: str) -> bool: + """Drop dunders, constructors, tiny names, and trivial accessors.""" + if name.startswith("__") and name.endswith("__"): + return True + if name == "__init__" or len(name) <= 2: + return True + if name.lower() in _TRIVIAL_NAMES: + return True + # Classic getter/setter accessors on a class are not concepts. Scoped to + # methods so module-level functions like ``get_session`` stay eligible in + # function-first codebases. + return kind == "method" and ( + name.startswith(("get_", "set_")) or (name[:3] in ("get", "set") and name[3:4].isupper()) + ) + + +def _select( + primary: list[ConceptSymbol], + top: int, + *, + filler: list[ConceptSymbol] | None = None, + min_count: int = 0, +) -> list[ConceptSymbol]: + """Pick up to *top* concepts, importance-ordered, spreading across + clusters and files so no single corner owns the page. + + Only *primary* candidates (the ones with a real importance signal) count + toward *top*; a concept page of five load-bearing types beats one padded + to six with a leaf helper. *filler* candidates (zero-signal symbols) are + pulled in only if fewer than *min_count* primaries exist, so a small or + thinly-connected repo still clears the gate. + + Progressive relaxation: tight caps first (prefer spread), then loosen so a + single-layer or single-file small repo still fills. Nouns are offered + before functions/methods within each pass. + """ + layer_cap = max(math.ceil(top / 2), 1) + chosen: list[ConceptSymbol] = [] + picked: set[int] = set() + per_file: dict[str, int] = {} + per_cluster: dict[str, int] = {} + + def take(pool: list[ConceptSymbol], limit: int, file_cap: int, cluster_cap: int) -> None: + for c in pool: + if len(chosen) >= limit: + return + if id(c) in picked: + continue + if per_file.get(c.file_path, 0) >= file_cap: + continue + if per_cluster.get(c.cluster, 0) >= cluster_cap: + continue + chosen.append(c) + picked.add(id(c)) + per_file[c.file_path] = per_file.get(c.file_path, 0) + 1 + per_cluster[c.cluster] = per_cluster.get(c.cluster, 0) + 1 + + def fill(pool: list[ConceptSymbol], limit: int) -> None: + nouns = [c for c in pool if c.kind in _NOUN_KINDS] + verbs = [c for c in pool if c.kind not in _NOUN_KINDS] + # Pass 1-2: one per file, at most half the page from one cluster. + take(nouns, limit, file_cap=1, cluster_cap=layer_cap) + take(verbs, limit, file_cap=1, cluster_cap=layer_cap) + # Pass 3: still one per file, but let a genuinely dominant cluster fill + # up (a single-layer repo has nowhere else to spread to). + take(nouns, limit, file_cap=1, cluster_cap=limit) + take(verbs, limit, file_cap=1, cluster_cap=limit) + # Pass 4 (last resort, tiny repos): allow a second concept per file. + take(nouns, limit, file_cap=2, cluster_cap=limit) + take(verbs, limit, file_cap=2, cluster_cap=limit) + + fill(primary, top) + if len(chosen) < min_count and filler: + fill(filler, min_count) + return chosen + + +def _iter_raw_symbols(signals: OnboardingSignals, gs: _GraphSignals) -> list[dict]: + """Yield the raw symbol records to rank, keyed by node id. - # Score: file PageRank lifts every public symbol it contains. Symbol - # frequency (defined more than once) acts as a tiebreaker, modelling - # the heuristic that re-implemented concepts tend to be foundational. - name_freq: Counter[str] = Counter() + Prefer the full symbol graph (it holds the WHOLE repo, unlike the possibly + partial ``parsed_files`` on an incremental update). Fall back to + ``parsed_files`` only when the graph carries no symbol nodes. + """ + if gs.nodes: + return [{"id": nid, **meta} for nid, meta in gs.nodes.items()] + raw: list[dict] = [] for pf in signals.parsed_files: for sym in pf.symbols: - if getattr(sym, "visibility", "public") == "public": - name_freq[sym.name] += 1 + raw.append( + { + "id": sym.id, + "name": sym.name, + "kind": str(getattr(sym, "kind", "symbol")), + "file_path": pf.file_info.path, + "visibility": getattr(sym, "visibility", "public"), + "docstring": (getattr(sym, "docstring", "") or "").strip()[:300], + "is_exported": bool(getattr(sym, "is_exported_symbol", False)), + } + ) + return raw + + +def _build(signals: OnboardingSignals) -> KeyConceptsContext | None: + labels_by_cid = _resolve_community_labels(signals.graph_builder) + layer_of = _file_to_layer(signals) + gs = _symbol_signals(signals.graph_builder) + test_files = set(gs.test_files) + test_files |= { + pf.file_info.path for pf in signals.parsed_files if getattr(pf.file_info, "is_test", False) + } + + def cluster_of(path: str) -> str: + if path in layer_of: + return layer_of[path] + cid = signals.community.get(path) + if cid is not None: + label = labels_by_cid.get(int(cid)) + if label: + return label + # No graph clusters: the parent directory is a coarse but real + # anti-concentration axis. + return os.path.dirname(path) or path + + # One candidate per public, non-trivial symbol, joined to its symbol-graph + # signals. Test files are excluded - their helpers are not repo concepts. candidates: list[ConceptSymbol] = [] - for pf in signals.parsed_files: - path = pf.file_info.path - pr = signals.pagerank.get(path, 0.0) - if pr < threshold: + id_by_name: dict[str, str] = {} + seen_names: set[str] = set() + scored: list[tuple[int, float, int, int, ConceptSymbol]] = [] + any_symbol_signal = False + for raw in _iter_raw_symbols(signals, gs): + path = raw["file_path"] + if path in test_files: continue - cid = signals.community.get(path) - community_label = labels_by_cid.get(int(cid)) if cid is not None else "" - for sym in pf.symbols: - if getattr(sym, "visibility", "public") != "public": - continue - # Skip near-noise: tiny utility functions and lowercase-only names - # that almost never represent real concepts. - if len(sym.name) <= 2: - continue - candidates.append( - ConceptSymbol( - name=sym.name, - kind=str(getattr(sym, "kind", "symbol")), - file_path=path, - docstring=(getattr(sym, "docstring", "") or "").strip()[:300], - pagerank=pr, - community_label=community_label or "", - ) + if raw["visibility"] != "public": + continue + kind = raw["kind"] + if kind in _SKIP_KINDS: + continue + name = raw["name"] + if not name or _is_trivial(name, kind): + continue + sym_id = raw["id"] + xcallers = gs.callers.get(sym_id, 0) + spr = gs.pagerank.get(sym_id, 0.0) + if xcallers or spr: + any_symbol_signal = True + concept = ConceptSymbol( + name=name, + kind=kind, + file_path=path, + docstring=raw["docstring"], + cluster=cluster_of(path), + cross_file_callers=xcallers, + ) + scored.append( + ( + xcallers, + spr, + 1 if raw["is_exported"] else 0, + 1 if concept.docstring else 0, + concept, ) + ) + id_by_name.setdefault(name, sym_id) - if len(candidates) < _GATE_MIN_CONCEPTS: + if not scored: return None - # Rank by (pagerank, name_frequency, has_docstring) — docstrings often - # mark the deliberately-public surface. - candidates.sort( - key=lambda c: ( - c.pagerank, - name_freq.get(c.name, 0), - 1 if c.docstring else 0, - ), - reverse=True, - ) + if any_symbol_signal: + # Importance: cross-file callers, then symbol PageRank, then export + # marker, then presence of a docstring (a deliberate public surface). + scored.sort(key=lambda t: (t[0], t[1], t[2], t[3]), reverse=True) + else: + # No resolved symbol edges (thin / rehydrated graph): fall back to the + # file's PageRank so a page still generates on small repos. + scored.sort( + key=lambda t: (signals.pagerank.get(t[4].file_path, 0.0), t[3]), + reverse=True, + ) - # De-duplicate by symbol name to avoid the same concept appearing - # multiple times when re-exported from several files. - seen: set[str] = set() - concept_symbols: list[ConceptSymbol] = [] - for c in candidates: - if c.name in seen: + # De-duplicate by concept name (re-exports) before selection. When symbol + # signals are available, split off "filler" - symbols with no cross-file + # callers and no export marker are not something the rest of the system + # depends on, so they pad the gate but never a full page. + filler: list[ConceptSymbol] = [] + for _, _, exported_flag, _, concept in scored: + if concept.name in seen_names: continue - seen.add(c.name) - concept_symbols.append(c) - if len(concept_symbols) >= _TOP_SYMBOLS: - break + seen_names.add(concept.name) + if any_symbol_signal and concept.cross_file_callers == 0 and not exported_flag: + filler.append(concept) + else: + candidates.append(concept) + concept_symbols = _select( + candidates, _TOP_CONCEPTS, filler=filler, min_count=_GATE_MIN_CONCEPTS + ) if len(concept_symbols) < _GATE_MIN_CONCEPTS: return None + relations = _relations_among(signals.graph_builder, concept_symbols, id_by_name) + community_labels = sorted(set(labels_by_cid.values()))[:_MAX_COMMUNITY_LABELS] decision_titles = [ str(d.get("title", "")).strip() @@ -163,6 +439,7 @@ def _build(signals: OnboardingSignals) -> KeyConceptsContext | None: return KeyConceptsContext( repo_name=signals.repo_name, concept_symbols=concept_symbols, + relations=relations, community_labels=community_labels, decision_titles=decision_titles, layer_order=list(signals.layer_order), diff --git a/packages/core/src/repowise/core/generation/page_generator/core.py b/packages/core/src/repowise/core/generation/page_generator/core.py index 1f090549e..9762ed271 100644 --- a/packages/core/src/repowise/core/generation/page_generator/core.py +++ b/packages/core/src/repowise/core/generation/page_generator/core.py @@ -395,8 +395,16 @@ async def _call_provider( request_id: str, target_path: str | None = None, content_hash: str = "", + source_salt: str = "", ) -> GeneratedResponse: - """Call the provider with caching, optionally prefixing a language instruction.""" + """Call the provider with caching, optionally prefixing a language instruction. + + *source_salt* is folded into the source_hash used for cross-run reuse + without changing the prompt sent to the model. Onboarding pages pass a + generation-version salt so a builder/template upgrade forces a one-time + regen even when the rendered prompt is byte-identical. Empty for every + other page type, so their reuse hashes are unchanged. + """ # Persistent cross-run cache: if the page exists from a prior run, was # produced by the same model, and either the documented file's bytes # (content_hash) or the prompt's source_hash matches, reuse the stored @@ -409,7 +417,7 @@ async def _call_provider( if prior is not None and prior.model_name == self._provider.model_name: reuse = bool(content_hash) and prior.content_hash == content_hash if not reuse: - reuse = prior.source_hash == compute_source_hash(user_prompt) + reuse = prior.source_hash == compute_source_hash(user_prompt + source_salt) if reuse: self._reuse_count += 1 log.debug( diff --git a/packages/core/src/repowise/core/generation/page_generator/pertype.py b/packages/core/src/repowise/core/generation/page_generator/pertype.py index 5e1cb88f2..3eda035c1 100644 --- a/packages/core/src/repowise/core/generation/page_generator/pertype.py +++ b/packages/core/src/repowise/core/generation/page_generator/pertype.py @@ -9,6 +9,7 @@ from __future__ import annotations import uuid +from dataclasses import replace from typing import Any import structlog @@ -281,15 +282,31 @@ async def generate_onboarding_page( template_name = f"onboarding/{spec.template}" user_prompt = self._render(template_name, ctx=ctx, slot=spec.slot) target = _onboarding.target_path(spec.slot) + # Fold the onboarding generation version into the reuse hash so a + # builder/template upgrade forces a one-time regen of cached pages. + salt = _onboarding.ONBOARDING_GENERATION_VERSION response = await self._call_provider( - "onboarding", user_prompt, str(uuid.uuid4()), target_path=target + "onboarding", user_prompt, str(uuid.uuid4()), target_path=target, source_salt=salt ) + # Grounding post-check: strip ungrounded path/symbol citations from the + # output. Runs on fresh AND reused content (``response.content`` carries + # the prior page's bytes on a cache hit), so an existing user's cached + # page is cleaned on their next docs update. + cleaned, ungrounded = _onboarding.check_grounding(response.content, ctx) + if ungrounded: + log.info( + "onboarding.grounding_stripped", + slot=spec.slot, + count=len(ungrounded), + tokens=ungrounded[:20], + ) + response = replace(response, content=cleaned) page = self._build_generated_page( "onboarding", target, spec.title, response, - compute_source_hash(user_prompt), + compute_source_hash(user_prompt + salt), GENERATION_LEVELS["onboarding"], ) # Subkind discriminator lives in metadata; page_type alone is shared diff --git a/packages/core/src/repowise/core/generation/templates/onboarding/key_concepts.j2 b/packages/core/src/repowise/core/generation/templates/onboarding/key_concepts.j2 index 84321366f..d4012898a 100644 --- a/packages/core/src/repowise/core/generation/templates/onboarding/key_concepts.j2 +++ b/packages/core/src/repowise/core/generation/templates/onboarding/key_concepts.j2 @@ -3,15 +3,24 @@ narrative that gives the reader the mental model needed to read the code. Not a glossary dump. Identify 3–6 load-bearing concepts and explain how they fit together. -## Candidate concepts (top public symbols by PageRank — ground every -## concept you name in this list; do not invent new ones) +## Candidate concepts (the symbols the rest of the system depends on most — +## ground every concept you name in this list; do not invent new ones) {% for c in ctx.concept_symbols %} -- **`{{ c.name }}`** ({{ c.kind }}) — `{{ c.file_path }}`{% if c.community_label %}, in cluster *{{ c.community_label }}*{% endif %} +- **`{{ c.name }}`** ({{ c.kind }}) — `{{ c.file_path }}`{% if c.cluster %}, in cluster *{{ c.cluster }}*{% endif %} {% if c.docstring %} > {{ c.docstring }} {% endif %} {% endfor %} +{% if ctx.relations %} +## How these concepts actually connect (from the dependency graph) +These are the real edges between the concepts above. Base "How they +connect" on these; do not assert a relationship that is not listed here. +{% for r in ctx.relations %} +- `{{ r.source }}` {% if r.kind == 'calls' %}calls{% elif r.kind == 'extends' %}extends{% elif r.kind == 'implements' %}implements{% else %}imports from{% endif %} `{{ r.target }}` +{% endfor %} +{% endif %} + {% if ctx.layer_order %} ## Architectural layers (top → bottom by dependency direction) Group the concepts by which layer they belong to when it clarifies the mental @@ -56,8 +65,12 @@ Produce a Key Concepts markdown page. Required structure: it's awkward. 3. **## How they connect** — one paragraph (4–7 sentences) tracing how the concepts collaborate. This is the section that turns the list - into a mental model. Be specific: name which concept calls / wraps / - produces which other. + into a mental model. Ground every "X calls / extends / implements / + imports Y" claim in the dependency edges listed above; if two + concepts have no listed edge, describe their relationship structurally + (same layer, shared cluster) rather than asserting a direct call. If + no edges were listed, keep this to how the clusters relate, not + symbol-to-symbol calls. Hard rules: - Every concept name must appear in the candidate list above. If a diff --git a/tests/unit/generation/test_onboarding_key_concepts.py b/tests/unit/generation/test_onboarding_key_concepts.py new file mode 100644 index 000000000..1eb34dee1 --- /dev/null +++ b/tests/unit/generation/test_onboarding_key_concepts.py @@ -0,0 +1,443 @@ +"""Symbol-level Key Concepts selection + grounding + self-heal. + +These cover the reworked Key Concepts builder (rank by symbol-graph signals, +prefer domain nouns, spread across clusters, ground relationships), the +cross-cutting grounding post-check, and the generation-version self-heal. +None of them need an API key. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from types import SimpleNamespace + +import networkx as nx + +from repowise.core.generation import onboarding +from repowise.core.generation.models import compute_source_hash +from repowise.core.generation.onboarding.grounding import check_grounding, collect_known +from repowise.core.generation.onboarding.signals import OnboardingSignals +from repowise.core.generation.onboarding.slots import ( + ONBOARDING_GENERATION_VERSION, + SLOT_KEY_CONCEPTS, +) +from repowise.core.generation.onboarding.subkinds.key_concepts import ( + ConceptSymbol, + KeyConceptsContext, +) +from repowise.core.ingestion.models import FileInfo, ParsedFile, RepoStructure, Symbol + +# --------------------------------------------------------------------------- +# Fixture builders: a ParsedFile + a real networkx symbol graph so the builder +# exercises its symbol-signal path (cross-file callers, symbol pagerank). +# --------------------------------------------------------------------------- + + +def _sym(path: str, name: str, kind: str, *, exported: bool = False, doc: str = "") -> Symbol: + return Symbol( + id=f"{path}::{name}", + name=name, + qualified_name=f"{path.replace('/', '.')}::{name}", + kind=kind, + signature=f"{kind} {name}", + start_line=1, + end_line=10, + docstring=doc or None, + decorators=[], + visibility="public", + is_async=False, + complexity_estimate=1, + language="python", + parent_name="Owner" if kind == "method" else None, + is_exported_symbol=exported, + ) + + +def _file(path: str, symbols: list[Symbol], *, is_test: bool = False) -> ParsedFile: + fi = FileInfo( + path=path, + abs_path=f"/repo/{path}", + language="python", + size_bytes=512, + git_hash="abc", + last_modified=datetime(2026, 1, 1, tzinfo=UTC), + is_test=is_test, + is_config=False, + is_api_contract=False, + is_entry_point=False, + ) + return ParsedFile( + file_info=fi, + symbols=symbols, + imports=[], + exports=[s.name for s in symbols], + docstring=None, + parse_errors=[], + content_hash="abc", + ) + + +def _graph_builder(files: list[ParsedFile], edges: list[tuple[str, str, str]]): + """Build a fake graph_builder backed by a real nx.DiGraph. + + *edges* are ``(source_id, target_id, edge_type)`` triples; symbol PageRank + is computed on the call/heritage subgraph, matching production. + """ + g = nx.DiGraph() + for pf in files: + g.add_node(pf.file_info.path, node_type="file", is_test=pf.file_info.is_test) + for s in pf.symbols: + g.add_node( + s.id, + node_type="symbol", + kind=s.kind, + name=s.name, + file_path=pf.file_info.path, + is_exported_symbol=s.is_exported_symbol, + docstring=s.docstring or "", + ) + for src, dst, et in edges: + g.add_edge(src, dst, edge_type=et) + + concept_edges = [ + (u, v) + for u, v, d in g.edges(data=True) + if d.get("edge_type") in ("calls", "extends", "implements") + ] + sub = nx.DiGraph() + sub.add_nodes_from(n for n, d in g.nodes(data=True) if d.get("node_type") == "symbol") + sub.add_edges_from(concept_edges) + pr = nx.pagerank(sub) if sub.number_of_edges() else {n: 0.0 for n in sub.nodes()} + + return SimpleNamespace( + graph=lambda: g, + symbol_pagerank=lambda: pr, + community_info=lambda: {}, + execution_flows=lambda: SimpleNamespace(flows=[]), + ) + + +def _signals( + files, graph_builder, *, kg_layers=(), layer_order=(), community=None +) -> OnboardingSignals: + paths = [f.file_info.path for f in files] + return OnboardingSignals( + repo_name="testrepo", + repo_structure=RepoStructure( + is_monorepo=False, + packages=[], + root_language_distribution={"python": 1.0}, + total_files=len(files), + total_loc=len(files) * 50, + entry_points=[], + ), + parsed_files=tuple(files), + source_map={}, + graph_builder=graph_builder, + pagerank={p: 0.1 for p in paths}, + betweenness={p: 0.0 for p in paths}, + community=community or dict.fromkeys(paths, 0), + sccs=(), + kg_layers=kg_layers, + layer_order=layer_order, + ) + + +# --------------------------------------------------------------------------- +# Item 1: symbol-level ranking + filtering + spread +# --------------------------------------------------------------------------- + + +def _repo_with_layers(): + """Two layers, one containing a class with many cross-file callers plus a + pile of methods/dunders from a single file (the old failure mode).""" + core = _file( + "core/registry.py", + [ + _sym("core/registry.py", "LanguageRegistry", "class", doc="Central registry."), + _sym("core/registry.py", "__init__", "method"), + _sym("core/registry.py", "get", "method"), + _sym("core/registry.py", "from_extension", "method"), + _sym("core/registry.py", "import_support_map", "method"), + ], + ) + spec = _file( + "core/spec.py", [_sym("core/spec.py", "LanguageSpec", "class", doc="One language.")] + ) + parser = _file( + "core/parser.py", [_sym("core/parser.py", "ASTParser", "class", doc="Parses source.")] + ) + store = _file( + "store/db.py", [_sym("store/db.py", "VectorStore", "class", doc="Persists vectors.")] + ) + search = _file("store/search.py", [_sym("store/search.py", "FullTextSearch", "class")]) + files = [core, spec, parser, store, search] + + # Callers from many other files → high cross-file in-degree on the classes. + edges: list[tuple[str, str, str]] = [] + for i in range(9): + caller = f"caller/c{i}.py" + files.append(_file(caller, [_sym(caller, f"use{i}", "function")])) + edges.append((f"{caller}::use{i}", "core/registry.py::LanguageRegistry", "calls")) + if i < 7: + edges.append((f"{caller}::use{i}", "core/spec.py::LanguageSpec", "calls")) + if i < 6: + edges.append((f"{caller}::use{i}", "core/parser.py::ASTParser", "calls")) + if i < 4: + edges.append((f"{caller}::use{i}", "store/db.py::VectorStore", "calls")) + if i < 3: + edges.append((f"{caller}::use{i}", "store/search.py::FullTextSearch", "calls")) + # The registry's own methods are only called locally (same file) → 0 cross-file. + edges.append(("core/registry.py::LanguageRegistry", "core/registry.py::get", "calls")) + + kg_layers = ( + { + "name": "Core", + "nodeIds": ["file:core/registry.py", "file:core/spec.py", "file:core/parser.py"], + }, + {"name": "Storage", "nodeIds": ["file:store/db.py", "file:store/search.py"]}, + ) + gb = _graph_builder(files, edges) + return _signals(files, gb, kg_layers=kg_layers, layer_order=("Core", "Storage")) + + +def test_key_concepts_ranks_classes_over_methods() -> None: + ctx = onboarding.get_spec(SLOT_KEY_CONCEPTS).build_context(_repo_with_layers()) + assert ctx is not None + names = [c.name for c in ctx.concept_symbols] + kinds = {c.kind for c in ctx.concept_symbols} + # No constructor, dunder, or trivial accessor survived. + assert "__init__" not in names + assert "get" not in names + assert "from_extension" not in names + # Every chosen concept is a class (a domain noun), not a method. + assert kinds == {"class"} + # The most-depended-on class leads. + assert names[0] == "LanguageRegistry" + + +def test_key_concepts_spreads_across_clusters() -> None: + ctx = onboarding.get_spec(SLOT_KEY_CONCEPTS).build_context(_repo_with_layers()) + assert ctx is not None + clusters = {c.cluster for c in ctx.concept_symbols} + # Both layers are represented; one cluster does not own the page. + assert clusters == {"Core", "Storage"} + core_count = sum(1 for c in ctx.concept_symbols if c.cluster == "Core") + assert core_count <= 3 # half-the-page cap on a single cluster + + +def test_key_concepts_grounds_relationships_from_edges() -> None: + """A heritage edge among two chosen concepts is surfaced as a relation.""" + base = _file("m/base.py", [_sym("m/base.py", "BaseProvider", "class", doc="Interface.")]) + impl = _file("m/openai.py", [_sym("m/openai.py", "OpenAIProvider", "class", doc="Concrete.")]) + other = _file("m/client.py", [_sym("m/client.py", "ApiClient", "class", doc="Client.")]) + conf = _file("m/config.py", [_sym("m/config.py", "Settings", "class", doc="Config.")]) + files = [base, impl, other, conf] + edges = [("m/openai.py::OpenAIProvider", "m/base.py::BaseProvider", "extends")] + # Give every class cross-file callers so all are selected. + for i in range(5): + c = f"call/u{i}.py" + files.append(_file(c, [_sym(c, f"u{i}", "function")])) + for tgt in ( + "m/base.py::BaseProvider", + "m/openai.py::OpenAIProvider", + "m/client.py::ApiClient", + "m/config.py::Settings", + ): + edges.append((f"{c}::u{i}", tgt, "calls")) + ctx = onboarding.get_spec(SLOT_KEY_CONCEPTS).build_context( + _signals(files, _graph_builder(files, edges)) + ) + assert ctx is not None + rels = {(r.source, r.kind, r.target) for r in ctx.relations} + assert ("OpenAIProvider", "extends", "BaseProvider") in rels + + +def test_key_concepts_excludes_test_helpers() -> None: + prod = [ + _file("app/service.py", [_sym("app/service.py", "Service", "class")]), + _file("app/model.py", [_sym("app/model.py", "Model", "class")]), + _file("app/repo.py", [_sym("app/repo.py", "Repository", "class")]), + _file("app/view.py", [_sym("app/view.py", "View", "class")]), + ] + test = _file( + "tests/helpers.py", [_sym("tests/helpers.py", "MegaHelper", "class")], is_test=True + ) + files = [*prod, test] + edges = [] + # Give the test helper the MOST cross-file callers - it must still be excluded. + for i in range(12): + c = f"tests/t{i}.py" + files.append(_file(c, [_sym(c, f"t{i}", "function")], is_test=True)) + edges.append((f"{c}::t{i}", "tests/helpers.py::MegaHelper", "calls")) + for i in range(3): + c = f"call/p{i}.py" + files.append(_file(c, [_sym(c, f"p{i}", "function")])) + for tgt in ( + "app/service.py::Service", + "app/model.py::Model", + "app/repo.py::Repository", + "app/view.py::View", + ): + edges.append((f"{c}::p{i}", tgt, "calls")) + ctx = onboarding.get_spec(SLOT_KEY_CONCEPTS).build_context( + _signals(files, _graph_builder(files, edges)) + ) + assert ctx is not None + assert "MegaHelper" not in {c.name for c in ctx.concept_symbols} + + +def test_key_concepts_gate_still_fails_below_minimum() -> None: + files = [_file("a.py", [_sym("a.py", "Only", "class")])] + ctx = onboarding.get_spec(SLOT_KEY_CONCEPTS).build_context( + _signals(files, _graph_builder(files, [])) + ) + assert ctx is None + + +def test_key_concepts_uses_full_graph_when_parsed_files_empty() -> None: + """On an incremental update, ``parsed_files`` is only the changed files + (empty when nothing changed). The builder must still draw the whole + concept set from the graph so the page self-heals on update.""" + import dataclasses + + # Simulate the incremental-update call shape: full graph, no parsed files. + sig = dataclasses.replace(_repo_with_layers(), parsed_files=()) + ctx = onboarding.get_spec(SLOT_KEY_CONCEPTS).build_context(sig) + assert ctx is not None + names = {c.name for c in ctx.concept_symbols} + assert "LanguageRegistry" in names + assert len(ctx.concept_symbols) >= 4 + + +# --------------------------------------------------------------------------- +# Item 1 self-heal: a changed concept set changes the rendered prompt hash +# --------------------------------------------------------------------------- + + +def _render_key_concepts(ctx) -> str: + from pathlib import Path + + import jinja2 + + templates_dir = ( + Path(__file__).resolve().parents[3] / "packages/core/src/repowise/core/generation/templates" + ) + env = jinja2.Environment( + loader=jinja2.FileSystemLoader(str(templates_dir)), + undefined=jinja2.StrictUndefined, + autoescape=False, + ) + return env.get_template("onboarding/key_concepts.j2").render(ctx=ctx, slot=SLOT_KEY_CONCEPTS) + + +def test_changed_concept_set_changes_source_hash() -> None: + a = KeyConceptsContext( + repo_name="r", + concept_symbols=[ + ConceptSymbol(name="Alpha", kind="class", file_path="a.py", cluster="Core"), + ConceptSymbol(name="Beta", kind="class", file_path="b.py", cluster="Core"), + ConceptSymbol(name="Gamma", kind="class", file_path="c.py", cluster="Store"), + ConceptSymbol(name="Delta", kind="class", file_path="d.py", cluster="Store"), + ], + ) + b = KeyConceptsContext( + repo_name="r", + concept_symbols=[ + ConceptSymbol(name="Alpha", kind="class", file_path="a.py", cluster="Core"), + ConceptSymbol( + name="Epsilon", kind="class", file_path="e.py", cluster="Core" + ), # changed + ConceptSymbol(name="Gamma", kind="class", file_path="c.py", cluster="Store"), + ConceptSymbol(name="Delta", kind="class", file_path="d.py", cluster="Store"), + ], + ) + ha = compute_source_hash(_render_key_concepts(a) + ONBOARDING_GENERATION_VERSION) + hb = compute_source_hash(_render_key_concepts(b) + ONBOARDING_GENERATION_VERSION) + assert ha != hb + + +def test_generation_version_folds_into_source_hash() -> None: + prompt = "identical rendered prompt" + h_v2 = compute_source_hash(prompt + "2") + h_v3 = compute_source_hash(prompt + "3") + assert h_v2 != h_v3 + # The shipped version is what pertype folds in; keep it a plain string. + assert isinstance(ONBOARDING_GENERATION_VERSION, str) + + +# --------------------------------------------------------------------------- +# Item 2: grounding post-check +# --------------------------------------------------------------------------- + + +def _ctx_for_grounding() -> KeyConceptsContext: + return KeyConceptsContext( + repo_name="r", + concept_symbols=[ + ConceptSymbol( + name="GraphBuilder", kind="class", file_path="core/graph/builder.py", cluster="Core" + ), + ConceptSymbol( + name="LanguageSpec", + kind="class", + file_path="core/languages/spec.py", + cluster="Core", + ), + ], + ) + + +def test_grounding_passes_known_citations() -> None: + ctx = _ctx_for_grounding() + content = ( + "The `GraphBuilder` in `core/graph/builder.py` produces the graph that " + "`LanguageSpec` (`spec.py`) describes. It supports `full` import mode." + ) + cleaned, ungrounded = check_grounding(content, ctx) + assert ungrounded == [] + assert cleaned == content # nothing stripped + + +def test_grounding_catches_fabricated_path_and_symbol() -> None: + ctx = _ctx_for_grounding() + content = ( + "Ingestion starts in `ingestion/resolvers/dotnet/index.py`, the entry " + "point, wired up by the `SecretOrchestrator` class." + ) + cleaned, ungrounded = check_grounding(content, ctx) + assert "ingestion/resolvers/dotnet/index.py" in ungrounded + assert "SecretOrchestrator" in ungrounded + # Demoted to plain text: the code-span backticks are gone. + assert "`ingestion/resolvers/dotnet/index.py`" not in cleaned + assert "`SecretOrchestrator`" not in cleaned + # Text preserved (sentence not deleted). + assert "dotnet/index.py" in cleaned + assert "SecretOrchestrator" in cleaned + + +def test_grounding_cleans_reused_page_content() -> None: + """The check runs on content, so a reused (cached) page carrying a stale + fabrication is cleaned the same way a fresh one is.""" + ctx = _ctx_for_grounding() + reused = "Cached page still cites the fabricated `PhantomAnalyzer` symbol." + cleaned, ungrounded = check_grounding(reused, ctx) + assert ungrounded == ["PhantomAnalyzer"] + assert "`PhantomAnalyzer`" not in cleaned + + +def test_grounding_leaves_lowercase_words_alone() -> None: + """Plain enum-value words in backticks (`full`, `none`) are not symbols.""" + ctx = _ctx_for_grounding() + content = "Import support is `full`, `partial`, or `none`." + cleaned, ungrounded = check_grounding(content, ctx) + assert ungrounded == [] + assert cleaned == content + + +def test_collect_known_gathers_paths_and_symbols() -> None: + paths, symbols = collect_known(_ctx_for_grounding()) + assert "core/graph/builder.py" in paths + assert "builder.py" in paths # basename included + assert "GraphBuilder" in symbols + assert "LanguageSpec" in symbols