ctx-lessons 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ctx/__init__.py +0 -0
- ctx/cli/__init__.py +0 -0
- ctx/cli/app.py +66 -0
- ctx/cli/config.py +226 -0
- ctx/cli/init.py +123 -0
- ctx/db/__init__.py +38 -0
- ctx/db/migrations.py +27 -0
- ctx/db/queries.py +219 -0
- ctx/db/schema.py +59 -0
- ctx/ingest/__init__.py +0 -0
- ctx/ingest/commands.py +295 -0
- ctx/ingest/cursor.py +38 -0
- ctx/ingest/docs.py +109 -0
- ctx/ingest/extract.py +210 -0
- ctx/ingest/filters.py +45 -0
- ctx/ingest/gh.py +116 -0
- ctx/lesson/__init__.py +0 -0
- ctx/lesson/commands.py +167 -0
- ctx/lesson/frontmatter.py +46 -0
- ctx/lesson/models.py +68 -0
- ctx/lesson/transitions.py +28 -0
- ctx/paths.py +25 -0
- ctx/promote/__init__.py +0 -0
- ctx/promote/commands.py +94 -0
- ctx/promote/git_ops.py +95 -0
- ctx/promote/pr_body.py +45 -0
- ctx/promote/validate.py +59 -0
- ctx/py.typed +0 -0
- ctx/query/__init__.py +0 -0
- ctx/query/commands.py +215 -0
- ctx/query/formatting.py +168 -0
- ctx/query/ranking.py +40 -0
- ctx/query/search.py +66 -0
- ctx/session.py +23 -0
- ctx/sync/__init__.py +0 -0
- ctx/sync/commands.py +50 -0
- ctx/sync/sync.py +154 -0
- ctx/sync/team_knowledge.py +70 -0
- ctx_lessons-0.1.0.dist-info/METADATA +252 -0
- ctx_lessons-0.1.0.dist-info/RECORD +43 -0
- ctx_lessons-0.1.0.dist-info/WHEEL +4 -0
- ctx_lessons-0.1.0.dist-info/entry_points.txt +2 -0
- ctx_lessons-0.1.0.dist-info/licenses/LICENSE +21 -0
ctx/__init__.py
ADDED
|
File without changes
|
ctx/cli/__init__.py
ADDED
|
File without changes
|
ctx/cli/app.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Typer chassis. Feature units register their subcommands here."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.metadata
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(
|
|
10
|
+
name="ctx",
|
|
11
|
+
help="Curated knowledge CLI for AI coding agents.",
|
|
12
|
+
no_args_is_help=True,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _version_callback(value: bool) -> None:
|
|
17
|
+
if value:
|
|
18
|
+
# Distribution name is `ctx-lessons`; the command is `ctx`.
|
|
19
|
+
try:
|
|
20
|
+
version = importlib.metadata.version("ctx-lessons")
|
|
21
|
+
except importlib.metadata.PackageNotFoundError:
|
|
22
|
+
version = "unknown (not installed)"
|
|
23
|
+
typer.echo(version)
|
|
24
|
+
raise typer.Exit()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.callback()
|
|
28
|
+
def _root(
|
|
29
|
+
version: bool = typer.Option(
|
|
30
|
+
False,
|
|
31
|
+
"--version",
|
|
32
|
+
callback=_version_callback,
|
|
33
|
+
is_eager=True,
|
|
34
|
+
help="Show version and exit.",
|
|
35
|
+
),
|
|
36
|
+
verbose: bool = typer.Option(
|
|
37
|
+
False,
|
|
38
|
+
"--verbose",
|
|
39
|
+
help="Verbose progress output (stderr).",
|
|
40
|
+
),
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Curated knowledge CLI for AI coding agents."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def main() -> None:
|
|
46
|
+
"""Entry point for `ctx` script."""
|
|
47
|
+
app()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _register_subcommands() -> None:
|
|
51
|
+
from ctx.cli.init import register as register_init
|
|
52
|
+
from ctx.ingest.commands import register as register_ingest
|
|
53
|
+
from ctx.lesson.commands import register as register_lesson
|
|
54
|
+
from ctx.promote.commands import register as register_promote
|
|
55
|
+
from ctx.query.commands import register as register_query
|
|
56
|
+
from ctx.sync.commands import register as register_sync
|
|
57
|
+
|
|
58
|
+
register_lesson(app)
|
|
59
|
+
register_init(app)
|
|
60
|
+
register_query(app)
|
|
61
|
+
register_ingest(app)
|
|
62
|
+
register_sync(app)
|
|
63
|
+
register_promote(app)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
_register_subcommands()
|
ctx/cli/config.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Config discovery + loading per ADR-0005.
|
|
2
|
+
|
|
3
|
+
Two separate config surfaces:
|
|
4
|
+
- **Repo** (`<repo>/.ctx.toml` or ``[ctx]`` table in pyproject.toml /
|
|
5
|
+
package.json): team-shared, defines scope identity for the repo.
|
|
6
|
+
- **Home** (``~/.ctx/config.toml``): per-developer, holds extractor
|
|
7
|
+
provider, model, and bot-author allowlist. Never overridden by repo.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
import tomllib
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from ctx.lesson.models import SCOPE_RE
|
|
21
|
+
|
|
22
|
+
_DEFAULT_BOT_AUTHORS = [
|
|
23
|
+
"dependabot[bot]",
|
|
24
|
+
"renovate[bot]",
|
|
25
|
+
"codecov[bot]",
|
|
26
|
+
"github-actions[bot]",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
_MAX_CONFIG_FILE_BYTES = 1 * 1024 * 1024 # 1 MiB cap; defends against memory DoS
|
|
30
|
+
_MAX_WALK_DEPTH = 32
|
|
31
|
+
|
|
32
|
+
_CTX_HEADING_RE = re.compile(r"^## ctx\s*$", re.MULTILINE)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class RepoConfig:
|
|
37
|
+
scope: str
|
|
38
|
+
team: str | None = None
|
|
39
|
+
product: str | None = None
|
|
40
|
+
include_products: list[str] = field(default_factory=list)
|
|
41
|
+
applies_to_default: str | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class HomeConfig:
|
|
46
|
+
extractor_provider: str = "anthropic"
|
|
47
|
+
extractor_model: str | None = None
|
|
48
|
+
extractor_base_url: str | None = None
|
|
49
|
+
excluded_authors: list[str] = field(default_factory=lambda: list(_DEFAULT_BOT_AUTHORS))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class ActiveScope:
|
|
54
|
+
"""The scope set used as the default for queries.
|
|
55
|
+
|
|
56
|
+
Per ADR-0005 + ADR-0003: when no explicit ``--scope`` is given,
|
|
57
|
+
queries run against this set; ranking favours the most specific match.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
scope_set: set[str]
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def from_repo_config(cls, cfg: RepoConfig | None) -> ActiveScope:
|
|
64
|
+
if cfg is None:
|
|
65
|
+
return cls(scope_set={"org"})
|
|
66
|
+
scopes = {cfg.scope, "org"}
|
|
67
|
+
if cfg.team:
|
|
68
|
+
scopes.add(f"team:{cfg.team}")
|
|
69
|
+
if cfg.product:
|
|
70
|
+
scopes.add(f"product:{cfg.product}")
|
|
71
|
+
# C5: .ctx.toml `include_products` opts in to additional product scopes
|
|
72
|
+
# (e.g. predecessor product for legacy lesson access).
|
|
73
|
+
for product in cfg.include_products:
|
|
74
|
+
scopes.add(f"product:{product}")
|
|
75
|
+
return cls(scope_set=scopes)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def discover_repo_config(start: Path) -> RepoConfig | None:
|
|
79
|
+
"""Walk from ``start`` upward looking for a ctx config.
|
|
80
|
+
|
|
81
|
+
Stops at the first match, the directory containing ``.git``, the user's
|
|
82
|
+
home directory, the filesystem root, or after ``_MAX_WALK_DEPTH`` steps —
|
|
83
|
+
whichever comes first.
|
|
84
|
+
|
|
85
|
+
To prevent symlink-based bypass of the home-boundary check, the walk
|
|
86
|
+
operates on logical (non-resolved) paths.
|
|
87
|
+
"""
|
|
88
|
+
home = Path(os.environ.get("HOME", str(Path.home())))
|
|
89
|
+
start_path = Path(start)
|
|
90
|
+
|
|
91
|
+
# If start is not under home, only check start itself — do not walk into
|
|
92
|
+
# ancestor directories that may be attacker-plantable.
|
|
93
|
+
if not _is_under(start_path, home):
|
|
94
|
+
return _try_loaders_in(start_path)
|
|
95
|
+
|
|
96
|
+
cur = start_path
|
|
97
|
+
for _ in range(_MAX_WALK_DEPTH):
|
|
98
|
+
cfg = _try_loaders_in(cur)
|
|
99
|
+
if cfg is not None:
|
|
100
|
+
return cfg
|
|
101
|
+
if (cur / ".git").exists():
|
|
102
|
+
return None
|
|
103
|
+
if cur == home or cur.parent == cur:
|
|
104
|
+
return None
|
|
105
|
+
cur = cur.parent
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _is_under(path: Path, ancestor: Path) -> bool:
|
|
110
|
+
"""True if `path` is `ancestor` or below, comparing logical components."""
|
|
111
|
+
try:
|
|
112
|
+
path.relative_to(ancestor)
|
|
113
|
+
return True
|
|
114
|
+
except ValueError:
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _try_loaders_in(directory: Path) -> RepoConfig | None:
|
|
119
|
+
if cfg := _load_ctx_toml(directory / ".ctx.toml"):
|
|
120
|
+
return cfg
|
|
121
|
+
if cfg := _load_table_from_toml(directory / "pyproject.toml"):
|
|
122
|
+
return cfg
|
|
123
|
+
if cfg := _load_table_from_package_json(directory / "package.json"):
|
|
124
|
+
return cfg
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _safe_read_text(path: Path) -> str | None:
|
|
129
|
+
"""Read a config file, refusing oversized files. Returns None if absent."""
|
|
130
|
+
try:
|
|
131
|
+
size = path.stat().st_size
|
|
132
|
+
except FileNotFoundError:
|
|
133
|
+
return None
|
|
134
|
+
if size > _MAX_CONFIG_FILE_BYTES:
|
|
135
|
+
return None
|
|
136
|
+
return path.read_text(encoding="utf-8")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _load_ctx_toml(path: Path) -> RepoConfig | None:
|
|
140
|
+
text = _safe_read_text(path)
|
|
141
|
+
if text is None:
|
|
142
|
+
return None
|
|
143
|
+
data = tomllib.loads(text)
|
|
144
|
+
return _repo_config_from_dict(data, source=str(path))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _load_table_from_toml(path: Path) -> RepoConfig | None:
|
|
148
|
+
text = _safe_read_text(path)
|
|
149
|
+
if text is None:
|
|
150
|
+
return None
|
|
151
|
+
data = tomllib.loads(text)
|
|
152
|
+
ctx_table = data.get("ctx")
|
|
153
|
+
if not isinstance(ctx_table, dict):
|
|
154
|
+
return None
|
|
155
|
+
return _repo_config_from_dict(ctx_table, source=str(path))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _load_table_from_package_json(path: Path) -> RepoConfig | None:
|
|
159
|
+
text = _safe_read_text(path)
|
|
160
|
+
if text is None:
|
|
161
|
+
return None
|
|
162
|
+
try:
|
|
163
|
+
data = json.loads(text)
|
|
164
|
+
except json.JSONDecodeError:
|
|
165
|
+
return None
|
|
166
|
+
ctx_table = data.get("ctx")
|
|
167
|
+
if not isinstance(ctx_table, dict):
|
|
168
|
+
return None
|
|
169
|
+
return _repo_config_from_dict(ctx_table, source=str(path))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _repo_config_from_dict(data: dict, *, source: str) -> RepoConfig:
|
|
173
|
+
if "scope" not in data:
|
|
174
|
+
raise ValueError(f"{source}: missing required field 'scope'")
|
|
175
|
+
scope = data["scope"]
|
|
176
|
+
if not isinstance(scope, str) or not SCOPE_RE.match(scope):
|
|
177
|
+
raise ValueError(f"{source}: invalid scope: {scope!r}")
|
|
178
|
+
return RepoConfig(
|
|
179
|
+
scope=scope,
|
|
180
|
+
team=data.get("team"),
|
|
181
|
+
product=data.get("product"),
|
|
182
|
+
include_products=list(data.get("include_products", [])),
|
|
183
|
+
applies_to_default=data.get("applies_to_default"),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def load_home_config() -> HomeConfig:
|
|
188
|
+
home = Path(os.environ.get("HOME", str(Path.home())))
|
|
189
|
+
path = home / ".ctx" / "config.toml"
|
|
190
|
+
text = _safe_read_text(path)
|
|
191
|
+
if text is None:
|
|
192
|
+
return HomeConfig()
|
|
193
|
+
data = tomllib.loads(text)
|
|
194
|
+
extractor = data.get("extractor", {})
|
|
195
|
+
return HomeConfig(
|
|
196
|
+
extractor_provider=extractor.get("provider", "anthropic"),
|
|
197
|
+
extractor_model=extractor.get("model"),
|
|
198
|
+
extractor_base_url=extractor.get("base_url"),
|
|
199
|
+
excluded_authors=list(data.get("excluded_authors", _DEFAULT_BOT_AUTHORS)),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# --- warn-once-per-session ----------------------------------------------------
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def warn_if_no_repo_config(cfg: RepoConfig | None) -> None:
|
|
207
|
+
"""Emit a one-shot stderr warning when falling back to org-only.
|
|
208
|
+
|
|
209
|
+
Per ADR-0005: at most one warning per shell session, gated by a touch
|
|
210
|
+
file under ``~/.ctx/`` keyed on the session id (see ``ctx.session``).
|
|
211
|
+
"""
|
|
212
|
+
from ctx.session import session_hash
|
|
213
|
+
|
|
214
|
+
if cfg is not None:
|
|
215
|
+
return
|
|
216
|
+
home = Path(os.environ.get("HOME", str(Path.home())))
|
|
217
|
+
marker_dir = home / ".ctx"
|
|
218
|
+
marker_dir.mkdir(parents=True, exist_ok=True)
|
|
219
|
+
marker = marker_dir / f".warned-{session_hash()}"
|
|
220
|
+
if marker.exists():
|
|
221
|
+
return
|
|
222
|
+
marker.touch()
|
|
223
|
+
print(
|
|
224
|
+
"warning: no .ctx.toml found walking up to .git; falling back to org-only retrieval.",
|
|
225
|
+
file=sys.stderr,
|
|
226
|
+
)
|
ctx/cli/init.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""`ctx init` subcommand: scaffold .ctx.toml + AGENTS.md (+ optional GHA workflow)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from ctx.lesson.models import SCOPE_RE
|
|
12
|
+
|
|
13
|
+
_AGENTS_SECTION_TEMPLATE = """## ctx
|
|
14
|
+
|
|
15
|
+
This repo's curated knowledge corpus is queryable via the `ctx` CLI.
|
|
16
|
+
Default scope: `{scope}`.
|
|
17
|
+
|
|
18
|
+
- `ctx search "<query>"` — search the corpus
|
|
19
|
+
- `ctx show <lesson-id>` — full lesson by id
|
|
20
|
+
- `ctx draft new` — capture a personal-layer lesson
|
|
21
|
+
- `ctx deprecate <id> [--superseded-by <id>]` — mark a lesson outdated
|
|
22
|
+
- `ctx validate <path>` — frontmatter + dead-glob CI gate
|
|
23
|
+
- `ctx sync` — pull team-knowledge updates and reindex
|
|
24
|
+
|
|
25
|
+
`ctx --help` lists every subcommand. Snippet output is capped at ~30
|
|
26
|
+
lines per result; pass `--json` for parseable agent consumption.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
_CTX_TOML_TEMPLATE = """scope = "{scope}"
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
_GHA_WORKFLOW = """name: ctx validate
|
|
33
|
+
|
|
34
|
+
on:
|
|
35
|
+
pull_request:
|
|
36
|
+
paths:
|
|
37
|
+
- "**/*.md"
|
|
38
|
+
|
|
39
|
+
jobs:
|
|
40
|
+
validate:
|
|
41
|
+
runs-on: ubuntu-latest
|
|
42
|
+
steps:
|
|
43
|
+
- uses: actions/checkout@v4
|
|
44
|
+
- uses: astral-sh/setup-uv@v6
|
|
45
|
+
- run: uv tool install ctx
|
|
46
|
+
- run: ctx validate "**/*.md"
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
_CTX_HEADING_RE = re.compile(r"^## ctx\s*$", re.MULTILINE)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def register(app: typer.Typer) -> None:
|
|
53
|
+
@app.command("init")
|
|
54
|
+
def init(
|
|
55
|
+
scope: str = typer.Option(..., "--scope"),
|
|
56
|
+
team_knowledge: bool = typer.Option(
|
|
57
|
+
False,
|
|
58
|
+
"--team-knowledge",
|
|
59
|
+
help="Also write a starter GitHub Actions workflow that runs ctx validate.",
|
|
60
|
+
),
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Scaffold .ctx.toml and an AGENTS.md ctx section in the current directory."""
|
|
63
|
+
if not SCOPE_RE.match(scope):
|
|
64
|
+
typer.echo(f"invalid scope: {scope!r}", err=True)
|
|
65
|
+
raise typer.Exit(code=2)
|
|
66
|
+
|
|
67
|
+
cwd = Path.cwd()
|
|
68
|
+
|
|
69
|
+
ctx_toml = cwd / ".ctx.toml"
|
|
70
|
+
if ctx_toml.is_symlink():
|
|
71
|
+
typer.echo(f"refusing to write through symlink: {ctx_toml}", err=True)
|
|
72
|
+
raise typer.Exit(code=2)
|
|
73
|
+
if not ctx_toml.exists():
|
|
74
|
+
_write_no_follow(ctx_toml, _CTX_TOML_TEMPLATE.format(scope=scope))
|
|
75
|
+
typer.echo(f"wrote {ctx_toml}")
|
|
76
|
+
else:
|
|
77
|
+
typer.echo(f"{ctx_toml} already exists (skipped)")
|
|
78
|
+
|
|
79
|
+
_write_or_append_agents_md(cwd / "AGENTS.md", scope=scope)
|
|
80
|
+
|
|
81
|
+
if team_knowledge:
|
|
82
|
+
wf = cwd / ".github" / "workflows" / "ctx-validate.yml"
|
|
83
|
+
if wf.is_symlink():
|
|
84
|
+
typer.echo(f"refusing to write through symlink: {wf}", err=True)
|
|
85
|
+
raise typer.Exit(code=2)
|
|
86
|
+
wf.parent.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
_write_no_follow(wf, _GHA_WORKFLOW)
|
|
88
|
+
typer.echo(f"wrote {wf}")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _write_no_follow(path: Path, content: str) -> None:
|
|
92
|
+
"""Create-or-truncate `path` without following symlinks.
|
|
93
|
+
|
|
94
|
+
Refuses if `path` exists as a symlink. Atomic-enough for v1: an attacker
|
|
95
|
+
racing to plant a symlink between this and the open() call would still
|
|
96
|
+
be blocked by O_NOFOLLOW.
|
|
97
|
+
"""
|
|
98
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW
|
|
99
|
+
fd = os.open(path, flags, 0o644)
|
|
100
|
+
try:
|
|
101
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
102
|
+
f.write(content)
|
|
103
|
+
except BaseException:
|
|
104
|
+
os.close(fd)
|
|
105
|
+
raise
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _write_or_append_agents_md(path: Path, *, scope: str) -> None:
|
|
109
|
+
section = _AGENTS_SECTION_TEMPLATE.format(scope=scope)
|
|
110
|
+
if path.is_symlink():
|
|
111
|
+
typer.echo(f"refusing to write through symlink: {path}", err=True)
|
|
112
|
+
raise typer.Exit(code=2)
|
|
113
|
+
if not path.exists():
|
|
114
|
+
_write_no_follow(path, section)
|
|
115
|
+
typer.echo(f"wrote {path}")
|
|
116
|
+
return
|
|
117
|
+
existing = path.read_text(encoding="utf-8")
|
|
118
|
+
if _CTX_HEADING_RE.search(existing):
|
|
119
|
+
typer.echo(f"{path} already has a ## ctx section (skipped)")
|
|
120
|
+
return
|
|
121
|
+
sep = "" if existing.endswith("\n\n") else ("\n" if existing.endswith("\n") else "\n\n")
|
|
122
|
+
_write_no_follow(path, existing + sep + section)
|
|
123
|
+
typer.echo(f"updated {path}")
|
ctx/db/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""SQLite + FTS5 storage for the lesson corpus.
|
|
2
|
+
|
|
3
|
+
Public surface per ADR-0001. SQL strings never cross this package boundary.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from ctx.db.migrations import CURRENT_VERSION, apply_v2_reserved, migrate
|
|
7
|
+
from ctx.db.queries import (
|
|
8
|
+
LessonRecord,
|
|
9
|
+
SearchResult,
|
|
10
|
+
add_lesson,
|
|
11
|
+
connect,
|
|
12
|
+
count_lessons,
|
|
13
|
+
deprecated_lessons,
|
|
14
|
+
get,
|
|
15
|
+
list_by_scope,
|
|
16
|
+
mark_deprecated,
|
|
17
|
+
search,
|
|
18
|
+
set_superseded_by,
|
|
19
|
+
stale_lessons,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"CURRENT_VERSION",
|
|
24
|
+
"LessonRecord",
|
|
25
|
+
"SearchResult",
|
|
26
|
+
"add_lesson",
|
|
27
|
+
"apply_v2_reserved",
|
|
28
|
+
"connect",
|
|
29
|
+
"count_lessons",
|
|
30
|
+
"deprecated_lessons",
|
|
31
|
+
"get",
|
|
32
|
+
"list_by_scope",
|
|
33
|
+
"mark_deprecated",
|
|
34
|
+
"migrate",
|
|
35
|
+
"search",
|
|
36
|
+
"set_superseded_by",
|
|
37
|
+
"stale_lessons",
|
|
38
|
+
]
|
ctx/db/migrations.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
|
|
5
|
+
from ctx.db.schema import V1_SCHEMA, V2_RESERVED
|
|
6
|
+
|
|
7
|
+
CURRENT_VERSION = 1
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def migrate(conn: sqlite3.Connection) -> None:
|
|
11
|
+
"""Apply forward migrations from the stored version to CURRENT_VERSION."""
|
|
12
|
+
current = conn.execute("PRAGMA user_version").fetchone()[0]
|
|
13
|
+
if current < 1:
|
|
14
|
+
conn.executescript(V1_SCHEMA)
|
|
15
|
+
conn.execute(f"PRAGMA user_version = {1}")
|
|
16
|
+
# Future: if current < 2: apply v2 here. v2 is NOT auto-applied in v1.
|
|
17
|
+
conn.commit()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def apply_v2_reserved(conn: sqlite3.Connection) -> None:
|
|
21
|
+
"""Apply the reserved v2 embeddings-column migration.
|
|
22
|
+
|
|
23
|
+
Used by ADR-0002 forward-compat tests. NOT called by ``migrate()`` in v1.
|
|
24
|
+
"""
|
|
25
|
+
conn.executescript(V2_RESERVED)
|
|
26
|
+
conn.execute("PRAGMA user_version = 2")
|
|
27
|
+
conn.commit()
|
ctx/db/queries.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import date, timedelta
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ctx.lesson.frontmatter import dump_yaml, parse_yaml
|
|
9
|
+
from ctx.lesson.models import Lesson
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class LessonRecord:
|
|
14
|
+
lesson: Lesson
|
|
15
|
+
body: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class SearchResult:
|
|
20
|
+
lesson: Lesson
|
|
21
|
+
score: float
|
|
22
|
+
snippet: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def connect(path: Path) -> sqlite3.Connection:
|
|
26
|
+
conn = sqlite3.connect(str(path))
|
|
27
|
+
conn.execute("PRAGMA foreign_keys = ON")
|
|
28
|
+
return conn
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def add_lesson(conn: sqlite3.Connection, lesson: Lesson, *, body: str) -> None:
|
|
32
|
+
"""Upsert a lesson and rebuild its side-table rows. Atomic."""
|
|
33
|
+
fm = dump_yaml(lesson)
|
|
34
|
+
with conn:
|
|
35
|
+
conn.execute(
|
|
36
|
+
"""
|
|
37
|
+
INSERT INTO lessons (id, scope, category, status, created, last_reviewed,
|
|
38
|
+
superseded_by, body, frontmatter)
|
|
39
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
40
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
41
|
+
scope=excluded.scope,
|
|
42
|
+
category=excluded.category,
|
|
43
|
+
status=excluded.status,
|
|
44
|
+
created=excluded.created,
|
|
45
|
+
last_reviewed=excluded.last_reviewed,
|
|
46
|
+
superseded_by=excluded.superseded_by,
|
|
47
|
+
body=excluded.body,
|
|
48
|
+
frontmatter=excluded.frontmatter
|
|
49
|
+
""",
|
|
50
|
+
(
|
|
51
|
+
lesson.id,
|
|
52
|
+
lesson.scope,
|
|
53
|
+
lesson.category,
|
|
54
|
+
lesson.status,
|
|
55
|
+
lesson.created.isoformat(),
|
|
56
|
+
lesson.last_reviewed.isoformat(),
|
|
57
|
+
lesson.superseded_by,
|
|
58
|
+
body,
|
|
59
|
+
fm,
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
conn.execute("DELETE FROM lesson_authors WHERE lesson_id = ?", (lesson.id,))
|
|
63
|
+
conn.execute("DELETE FROM lesson_tags WHERE lesson_id = ?", (lesson.id,))
|
|
64
|
+
conn.execute("DELETE FROM lesson_applies_to_files WHERE lesson_id = ?", (lesson.id,))
|
|
65
|
+
conn.executemany(
|
|
66
|
+
"INSERT INTO lesson_authors (lesson_id, author) VALUES (?, ?)",
|
|
67
|
+
[(lesson.id, a) for a in lesson.authors],
|
|
68
|
+
)
|
|
69
|
+
conn.executemany(
|
|
70
|
+
"INSERT INTO lesson_tags (lesson_id, tag) VALUES (?, ?)",
|
|
71
|
+
[(lesson.id, t) for t in lesson.tags],
|
|
72
|
+
)
|
|
73
|
+
conn.executemany(
|
|
74
|
+
"INSERT INTO lesson_applies_to_files (lesson_id, glob) VALUES (?, ?)",
|
|
75
|
+
[(lesson.id, g) for g in lesson.applies_to_files],
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get(conn: sqlite3.Connection, lesson_id: str) -> LessonRecord | None:
|
|
80
|
+
row = conn.execute(
|
|
81
|
+
"SELECT frontmatter, body FROM lessons WHERE id = ?", (lesson_id,)
|
|
82
|
+
).fetchone()
|
|
83
|
+
if row is None:
|
|
84
|
+
return None
|
|
85
|
+
return LessonRecord(lesson=parse_yaml(row[0]), body=row[1])
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def list_by_scope(conn: sqlite3.Connection, scope: str) -> list[Lesson]:
|
|
89
|
+
rows = conn.execute(
|
|
90
|
+
"SELECT frontmatter FROM lessons WHERE scope = ? ORDER BY id", (scope,)
|
|
91
|
+
).fetchall()
|
|
92
|
+
return [parse_yaml(r[0]) for r in rows]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _build_fts_query(query: str) -> str | None:
|
|
96
|
+
"""Build a safe FTS5 MATCH expression from raw CLI input.
|
|
97
|
+
|
|
98
|
+
Each whitespace-separated term is wrapped as its own quoted phrase and the
|
|
99
|
+
terms are OR-ed together. Quoting every term defuses the FTS5 operator
|
|
100
|
+
grammar (NEAR, AND, column:term, parens, ``c++``, unbalanced quotes) — the
|
|
101
|
+
same injection-safety the old single-phrase wrapper gave us — while OR over
|
|
102
|
+
individual terms restores recall: a multi-word query matches lessons that
|
|
103
|
+
contain *any* of the terms, and bm25 ranks lessons matching more of them
|
|
104
|
+
higher. We deliberately do not treat the whole input as one contiguous
|
|
105
|
+
phrase, which required every word to appear adjacently and in order.
|
|
106
|
+
|
|
107
|
+
Returns ``None`` when the query has no usable terms (caller short-circuits
|
|
108
|
+
to an empty result set rather than issuing an empty MATCH, which errors).
|
|
109
|
+
"""
|
|
110
|
+
terms = [t for t in query.split() if t]
|
|
111
|
+
if not terms:
|
|
112
|
+
return None
|
|
113
|
+
return " OR ".join(f'"{t.replace(chr(34), chr(34) * 2)}"' for t in terms)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def search(
|
|
117
|
+
conn: sqlite3.Connection,
|
|
118
|
+
query: str,
|
|
119
|
+
*,
|
|
120
|
+
scope_set: set[str] | None = None,
|
|
121
|
+
include_deprecated: bool = False,
|
|
122
|
+
limit: int = 5,
|
|
123
|
+
) -> list[SearchResult]:
|
|
124
|
+
match_expr = _build_fts_query(query)
|
|
125
|
+
if match_expr is None:
|
|
126
|
+
return []
|
|
127
|
+
sql = """
|
|
128
|
+
SELECT l.frontmatter,
|
|
129
|
+
bm25(lessons_fts) AS rank,
|
|
130
|
+
snippet(lessons_fts, 0, '[', ']', '…', 16) AS snip
|
|
131
|
+
FROM lessons_fts
|
|
132
|
+
JOIN lessons l ON l.rowid = lessons_fts.rowid
|
|
133
|
+
WHERE lessons_fts MATCH ?
|
|
134
|
+
"""
|
|
135
|
+
params: list[object] = [match_expr]
|
|
136
|
+
if not include_deprecated:
|
|
137
|
+
sql += " AND l.status != 'deprecated'"
|
|
138
|
+
if scope_set is not None:
|
|
139
|
+
placeholders = ",".join("?" for _ in scope_set)
|
|
140
|
+
sql += f" AND l.scope IN ({placeholders})"
|
|
141
|
+
params.extend(scope_set)
|
|
142
|
+
sql += " ORDER BY rank LIMIT ?"
|
|
143
|
+
params.append(limit)
|
|
144
|
+
|
|
145
|
+
rows = conn.execute(sql, params).fetchall()
|
|
146
|
+
return [
|
|
147
|
+
SearchResult(lesson=parse_yaml(fm), score=-rank, snippet=snip or "")
|
|
148
|
+
for fm, rank, snip in rows
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def count_lessons(
|
|
153
|
+
conn: sqlite3.Connection,
|
|
154
|
+
*,
|
|
155
|
+
scope_set: set[str] | None = None,
|
|
156
|
+
include_deprecated: bool = False,
|
|
157
|
+
) -> int:
|
|
158
|
+
"""Count lessons, optionally restricted to ``scope_set``.
|
|
159
|
+
|
|
160
|
+
Used by the search command to detect the silent empty-scope trap: an active
|
|
161
|
+
scope (e.g. the org-only fallback) that contains zero lessons while the
|
|
162
|
+
corpus as a whole is non-empty.
|
|
163
|
+
"""
|
|
164
|
+
sql = "SELECT count(*) FROM lessons WHERE 1=1"
|
|
165
|
+
params: list[object] = []
|
|
166
|
+
if not include_deprecated:
|
|
167
|
+
sql += " AND status != 'deprecated'"
|
|
168
|
+
if scope_set is not None:
|
|
169
|
+
if not scope_set:
|
|
170
|
+
return 0
|
|
171
|
+
placeholders = ",".join("?" for _ in scope_set)
|
|
172
|
+
sql += f" AND scope IN ({placeholders})"
|
|
173
|
+
params.extend(scope_set)
|
|
174
|
+
return int(conn.execute(sql, params).fetchone()[0])
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def mark_deprecated(
|
|
178
|
+
conn: sqlite3.Connection, lesson_id: str, superseded_by: str | None = None
|
|
179
|
+
) -> None:
|
|
180
|
+
row = conn.execute("SELECT frontmatter FROM lessons WHERE id = ?", (lesson_id,)).fetchone()
|
|
181
|
+
if row is None:
|
|
182
|
+
raise KeyError(lesson_id)
|
|
183
|
+
updated = parse_yaml(row[0]).model_copy(
|
|
184
|
+
update={"status": "deprecated", "superseded_by": superseded_by}
|
|
185
|
+
)
|
|
186
|
+
with conn:
|
|
187
|
+
conn.execute(
|
|
188
|
+
"UPDATE lessons SET status=?, superseded_by=?, frontmatter=? WHERE id=?",
|
|
189
|
+
("deprecated", superseded_by, dump_yaml(updated), lesson_id),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def set_superseded_by(conn: sqlite3.Connection, lesson_id: str, replacement_id: str) -> None:
|
|
194
|
+
row = conn.execute("SELECT frontmatter FROM lessons WHERE id = ?", (lesson_id,)).fetchone()
|
|
195
|
+
if row is None:
|
|
196
|
+
raise KeyError(lesson_id)
|
|
197
|
+
updated = parse_yaml(row[0]).model_copy(update={"superseded_by": replacement_id})
|
|
198
|
+
with conn:
|
|
199
|
+
conn.execute(
|
|
200
|
+
"UPDATE lessons SET superseded_by=?, frontmatter=? WHERE id=?",
|
|
201
|
+
(replacement_id, dump_yaml(updated), lesson_id),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def deprecated_lessons(conn: sqlite3.Connection) -> list[Lesson]:
|
|
206
|
+
rows = conn.execute(
|
|
207
|
+
"SELECT frontmatter FROM lessons WHERE status = 'deprecated' ORDER BY id"
|
|
208
|
+
).fetchall()
|
|
209
|
+
return [parse_yaml(r[0]) for r in rows]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def stale_lessons(conn: sqlite3.Connection, months: int) -> list[Lesson]:
|
|
213
|
+
# 30-day months — good enough for "stale" semantics.
|
|
214
|
+
threshold = (date.today() - timedelta(days=months * 30)).isoformat()
|
|
215
|
+
rows = conn.execute(
|
|
216
|
+
"SELECT frontmatter FROM lessons WHERE last_reviewed <= ? ORDER BY last_reviewed",
|
|
217
|
+
(threshold,),
|
|
218
|
+
).fetchall()
|
|
219
|
+
return [parse_yaml(r[0]) for r in rows]
|