Skip to content

Commit 0e70d13

Browse files
authored
feat(vault): ID scheme v2 + vault ls/show/chain browse commands (#1428)
feat(vault): ID scheme v2 + vault ls/show/chain browse commands
2 parents c04f42d + 7e8444c commit 0e70d13

6 files changed

Lines changed: 569 additions & 11 deletions

File tree

interviews/vault-cli/src/vault_cli/commands/authoring.py

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,66 @@ def _short_hash(title: str) -> str:
3838
return hashlib.sha256(title.encode("utf-8")).hexdigest()[:6]
3939

4040

41+
def _id_hash(title: str, topic: str) -> str:
42+
"""ID scheme v2 hash: first 4 hex chars of sha256(title + "\\n" + topic).
43+
44+
Matches the recipe documented in interviews/vault/docs/ID_SCHEMES.md.
45+
Including the topic defends against two unrelated questions with
46+
identical titles hashing identically.
47+
"""
48+
payload = f"{title}\n{topic}".encode("utf-8")
49+
return hashlib.sha256(payload).hexdigest()[:4]
50+
51+
52+
def _yyyymm() -> str:
53+
"""Current year-month as 6 digits, for the v2 ID scheme."""
54+
return datetime.now(UTC).strftime("%Y%m")
55+
56+
4157
def _slug(s: str) -> str:
4258
keep = "".join(c if c.isalnum() or c == "-" else "-" for c in s.lower())
4359
while "--" in keep:
4460
keep = keep.replace("--", "-")
4561
return keep.strip("-") or "untitled"
4662

4763

64+
def _new_question_id(track: str, title: str, topic: str, existing_ids: set[str]) -> tuple[str, str]:
65+
"""Mint a v2 question ID: <track>-<yyyymm>-<4hex>.
66+
67+
On collision within the same (track, yyyymm) bucket, increment the
68+
4-hex suffix to the next free hex until a free slot is found.
69+
Returns (id, hex_used).
70+
"""
71+
yyyymm = _yyyymm()
72+
base_hex = _id_hash(title, topic)
73+
# Increment hex on collision (65,536 slots per bucket; collisions rare).
74+
n = int(base_hex, 16)
75+
for _ in range(0x10000):
76+
candidate_hex = f"{n:04x}"
77+
qid = f"{track}-{yyyymm}-{candidate_hex}"
78+
if qid not in existing_ids:
79+
return qid, candidate_hex
80+
n = (n + 1) & 0xFFFF
81+
raise RuntimeError(f"ID-space exhausted for bucket {track}-{yyyymm}")
82+
83+
84+
def _new_chain_id(track: str, topic: str, existing_ids: set[str]) -> str:
85+
"""Mint a v2 chain ID: chain-<track>-<topic-slug>-<yyyymm>[-<suffix>].
86+
87+
On collision add a single-letter suffix (a, b, c, …) to disambiguate.
88+
"""
89+
yyyymm = _yyyymm()
90+
slug = _slug(topic)
91+
base = f"chain-{track}-{slug}-{yyyymm}"
92+
if base not in existing_ids:
93+
return base
94+
for letter in "abcdefghijklmnopqrstuvwxyz":
95+
candidate = f"{base}-{letter}"
96+
if candidate not in existing_ids:
97+
return candidate
98+
raise RuntimeError(f"Chain-ID space exhausted for bucket {base}")
99+
100+
48101
def _git_user_email() -> str | None:
49102
"""Resolve the committer identity for auto-populating ``authors:`` (David H4)."""
50103
try:
@@ -136,8 +189,7 @@ def new_cmd(
136189
rate, per §3.3 concurrency contract.
137190
"""
138191
# v1.0: classification lives in YAML, filesystem uses track only.
139-
topic_slug = _slug(topic)
140-
h = _short_hash(title)
192+
# v2 ID scheme: <track>-<yyyymm>-<4hex> (see docs/ID_SCHEMES.md).
141193

142194
# §3.3: git pull --rebase on the registry before allocation.
143195
if not skip_rebase:
@@ -149,18 +201,14 @@ def new_cmd(
149201
check=False, capture_output=True,
150202
)
151203

152-
# Allocate seq by scanning existing files under the track directory.
204+
# Build the set of existing IDs so _new_question_id can avoid collisions.
153205
cell_dir = path_for_question(vault_dir, track.value, "").parent
154206
cell_dir.mkdir(parents=True, exist_ok=True)
155-
seq = 1
156-
while True:
157-
filename = f"{track.value}-{topic_slug}-{h}-{seq:04d}.yaml"
158-
candidate = cell_dir / filename
159-
if not candidate.exists():
160-
break
161-
seq += 1
207+
existing_ids: set[str] = set()
208+
for p in cell_dir.glob("*.yaml"):
209+
existing_ids.add(p.stem)
162210

163-
qid = f"{track.value}-{topic_slug}-{h}-{seq:04d}"
211+
qid, _ = _new_question_id(track.value, title, topic, existing_ids)
164212
now = _now()
165213

166214
author = _git_user_email()
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"""``vault chain`` — browse and inspect question chains.
2+
3+
Subcommands:
4+
vault chain ls [--track --topic] list chains with counts + spans
5+
vault chain show <chain-id> walk a chain end-to-end
6+
7+
A chain links questions on a single topic into a progression, usually
8+
across Bloom's levels. ≈32% of the corpus participates in ≥1 chain;
9+
≈101 questions are in multiple chains.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
from pathlib import Path
15+
16+
import typer
17+
from rich.console import Console
18+
from rich.table import Table
19+
20+
from vault_cli.loader import load_all
21+
22+
23+
chain_app = typer.Typer(help="Browse and inspect question chains.")
24+
25+
26+
def _collect_chains(loaded) -> dict[str, list]:
27+
"""Return {chain_id: [(position, loaded_question), ...]}."""
28+
out: dict[str, list] = {}
29+
for lq in loaded:
30+
for c in (lq.question.chains or []):
31+
out.setdefault(c.id, []).append((c.position, lq))
32+
for cid in out:
33+
out[cid].sort(key=lambda x: x[0])
34+
return out
35+
36+
37+
@chain_app.command("ls")
38+
def chain_ls(
39+
track: str | None = typer.Option(None, "--track", help="Filter by track (cloud/edge/mobile/tinyml/global)."),
40+
topic: str | None = typer.Option(None, "--topic", help="Filter by topic slug."),
41+
vault_dir: Path = typer.Option(Path("interviews/vault"), "--vault-dir"),
42+
) -> None:
43+
"""List chains with member count + level span."""
44+
console = Console()
45+
loaded, _ = load_all(vault_dir)
46+
chains = _collect_chains(loaded)
47+
48+
rows = []
49+
for cid, members in chains.items():
50+
topics = {m[1].question.topic for m in members}
51+
tracks = {m[1].question.track for m in members}
52+
levels = sorted({m[1].question.level for m in members})
53+
first_topic = next(iter(topics))
54+
first_track = next(iter(tracks))
55+
if track and first_track != track:
56+
continue
57+
if topic and topic not in topics:
58+
continue
59+
rows.append((cid, first_track, first_topic, len(members), "/".join(levels)))
60+
61+
rows.sort(key=lambda r: (r[1], r[2], r[0]))
62+
63+
table = Table(show_header=True, header_style="bold")
64+
table.add_column("chain_id", no_wrap=True, style="cyan")
65+
table.add_column("track", no_wrap=True)
66+
table.add_column("topic", no_wrap=True, style="dim")
67+
table.add_column("#", justify="right")
68+
table.add_column("level span")
69+
for cid, tr, tp, n, sp in rows:
70+
table.add_row(cid, tr, tp, str(n), sp)
71+
console.print(table)
72+
console.print(f"[dim]{len(rows)} chains[/dim]")
73+
74+
75+
@chain_app.command("show")
76+
def chain_show(
77+
chain_id: str = typer.Argument(..., help="Chain ID to walk (e.g. cloud-chain-432)."),
78+
vault_dir: Path = typer.Option(Path("interviews/vault"), "--vault-dir"),
79+
) -> None:
80+
"""Walk a chain end-to-end: one row per member, ordered by position."""
81+
console = Console()
82+
loaded, _ = load_all(vault_dir)
83+
chains = _collect_chains(loaded)
84+
85+
members = chains.get(chain_id)
86+
if members is None:
87+
console.print(f"[red]chain not found:[/red] {chain_id!r}")
88+
raise typer.Exit(code=1)
89+
90+
# Detect topic/track drift within the chain.
91+
topics = sorted({m[1].question.topic for m in members})
92+
tracks = sorted({m[1].question.track for m in members})
93+
levels = [m[1].question.level for m in members]
94+
levels_sorted = sorted(levels, key=lambda L: ("L1","L2","L3","L4","L5","L6+").index(L))
95+
monotonic = levels == levels_sorted
96+
97+
console.print(f"[bold cyan]{chain_id}[/bold cyan] "
98+
f"{len(members)} members track(s)={','.join(tracks)} topic(s)={','.join(topics)}")
99+
if len(topics) > 1:
100+
console.print(f" [yellow]warning[/yellow]: chain spans multiple topics — likely mis-linked")
101+
if not monotonic:
102+
console.print(f" [yellow]warning[/yellow]: levels not monotonically non-decreasing across positions")
103+
104+
table = Table(show_header=True, header_style="bold")
105+
table.add_column("#", justify="right", no_wrap=True)
106+
table.add_column("level", no_wrap=True)
107+
table.add_column("zone", no_wrap=True)
108+
table.add_column("id", no_wrap=True, style="cyan")
109+
table.add_column("title")
110+
for pos, lq in members:
111+
q = lq.question
112+
table.add_row(str(pos), q.level, q.zone, q.id, q.title)
113+
console.print(table)
114+
115+
116+
def register(app: typer.Typer) -> None:
117+
app.add_typer(chain_app, name="chain")
118+
119+
120+
__all__ = ["register"]
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""``vault ls`` — browse questions with axis filters.
2+
3+
One-line-per-question output, aligned columns, filterable on every
4+
first-class classification axis. Powers the "I want to see level at a
5+
glance" workflow without opening individual YAMLs.
6+
7+
Usage:
8+
vault ls # every question in the vault
9+
vault ls --track cloud # cloud-only
10+
vault ls --level L6+ # only L6+ questions
11+
vault ls --zone mastery # only mastery-zone
12+
vault ls --topic kv-cache-management
13+
vault ls --status published
14+
vault ls --in-chains # only questions that are in >=1 chain
15+
vault ls --track cloud --level L4 --zone diagnosis # combinable
16+
17+
Output columns: id | track | level | zone | topic | #chains | title
18+
"""
19+
20+
from __future__ import annotations
21+
22+
from pathlib import Path
23+
24+
import typer
25+
from rich.console import Console
26+
from rich.table import Table
27+
28+
from vault_cli.loader import load_all
29+
30+
31+
def register(app: typer.Typer) -> None:
32+
@app.command("ls")
33+
def ls_cmd(
34+
track: str | None = typer.Option(None, "--track", help="Filter by track."),
35+
level: str | None = typer.Option(None, "--level", help="Filter by level (L1 … L6+)."),
36+
zone: str | None = typer.Option(None, "--zone", help="Filter by zone (one of the 11 ikigai zones)."),
37+
topic: str | None = typer.Option(None, "--topic", help="Filter by topic slug."),
38+
status: str | None = typer.Option(None, "--status", help="Filter by status (published, draft, flagged, archived, deleted)."),
39+
in_chains: bool = typer.Option(False, "--in-chains", help="Only questions that belong to ≥1 chain."),
40+
vault_dir: Path = typer.Option(
41+
Path("interviews/vault"), "--vault-dir",
42+
help="Vault root.",
43+
),
44+
limit: int = typer.Option(0, "--limit", "-n", help="Truncate output after N rows (0 = all)."),
45+
plain: bool = typer.Option(False, "--plain", help="Plain tab-separated output (for piping to awk/grep)."),
46+
) -> None:
47+
"""List questions with axis filters. Aligned output or TSV."""
48+
console = Console()
49+
loaded, errors = load_all(vault_dir)
50+
if errors:
51+
console.print(f"[yellow]warning[/yellow]: {len(errors)} load errors (skipped)")
52+
53+
rows = []
54+
for lq in loaded:
55+
q = lq.question
56+
if track and q.track != track: continue
57+
if level and q.level != level: continue
58+
if zone and q.zone != zone: continue
59+
if topic and q.topic != topic: continue
60+
if status and q.status != status: continue
61+
n_chains = len(q.chains or [])
62+
if in_chains and n_chains == 0: continue
63+
rows.append((q.id, q.track, q.level, q.zone, q.topic, n_chains, q.title))
64+
65+
rows.sort(key=lambda r: (r[1], r[2], r[0])) # track, level, id
66+
if limit:
67+
rows = rows[:limit]
68+
69+
if plain:
70+
for r in rows:
71+
print("\t".join(str(x) for x in r))
72+
console.print(f"[dim]({len(rows)} rows)[/dim]", style="dim")
73+
return
74+
75+
table = Table(show_header=True, header_style="bold")
76+
table.add_column("id", no_wrap=True, style="cyan")
77+
table.add_column("track", no_wrap=True)
78+
table.add_column("level", no_wrap=True)
79+
table.add_column("zone", no_wrap=True)
80+
table.add_column("topic", no_wrap=True, style="dim")
81+
table.add_column("ch", justify="right", style="dim")
82+
table.add_column("title", no_wrap=False)
83+
for qid, tr, lv, zn, tp, nc, ti in rows:
84+
table.add_row(qid, tr, lv, zn, tp, str(nc), ti)
85+
console.print(table)
86+
console.print(f"[dim]{len(rows)} rows[/dim]")
87+
88+
89+
__all__ = ["register"]

0 commit comments

Comments
 (0)