codetrail-cli 1.4.1__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.
Files changed (171) hide show
  1. codetrail/__init__.py +3 -0
  2. codetrail/__main__.py +5 -0
  3. codetrail/agents/__init__.py +4 -0
  4. codetrail/agents/convert.py +92 -0
  5. codetrail/agents/export.py +284 -0
  6. codetrail/agents/instructions.py +42 -0
  7. codetrail/agents/protocol.py +111 -0
  8. codetrail/agents/tools.py +185 -0
  9. codetrail/assistant/__init__.py +161 -0
  10. codetrail/assistant/claude_code.py +330 -0
  11. codetrail/assistant/codex.py +260 -0
  12. codetrail/assistant/environment.py +66 -0
  13. codetrail/assistant/estimate.py +225 -0
  14. codetrail/assistant/fake.py +74 -0
  15. codetrail/assistant/guard.py +80 -0
  16. codetrail/assistant/guard_hook.py +58 -0
  17. codetrail/assistant/local.py +245 -0
  18. codetrail/assistant/local_tools.py +177 -0
  19. codetrail/assistant/prompts.py +404 -0
  20. codetrail/assistant/routing.py +86 -0
  21. codetrail/assistant/runner.py +120 -0
  22. codetrail/assistant/status.py +186 -0
  23. codetrail/assistant/usage.py +93 -0
  24. codetrail/bridge.py +292 -0
  25. codetrail/cli.py +407 -0
  26. codetrail/config.py +485 -0
  27. codetrail/database/__init__.py +60 -0
  28. codetrail/database/migrations/0001_facts.sql +34 -0
  29. codetrail/database/migrations/0002_settings.sql +5 -0
  30. codetrail/database/migrations/0003_learning.sql +34 -0
  31. codetrail/database/migrations/0004_assistant_usage.sql +24 -0
  32. codetrail/database/migrations/0005_page_failures.sql +7 -0
  33. codetrail/database/migrations/0006_metric_values.sql +9 -0
  34. codetrail/errors.py +5 -0
  35. codetrail/extract/__init__.py +249 -0
  36. codetrail/extract/adr.py +98 -0
  37. codetrail/extract/github_actions.py +140 -0
  38. codetrail/extract/openapi.py +101 -0
  39. codetrail/extract/python.py +221 -0
  40. codetrail/extract/swift.py +204 -0
  41. codetrail/extract/terraform.py +166 -0
  42. codetrail/extract/typescript.py +464 -0
  43. codetrail/facts/__init__.py +112 -0
  44. codetrail/facts/store.py +209 -0
  45. codetrail/generate/__init__.py +1 -0
  46. codetrail/generate/failures.py +31 -0
  47. codetrail/generate/outline.py +159 -0
  48. codetrail/generate/revise.py +72 -0
  49. codetrail/generate/run.py +530 -0
  50. codetrail/generate/scope.py +135 -0
  51. codetrail/generate/validate.py +218 -0
  52. codetrail/guide/__init__.py +147 -0
  53. codetrail/learn/__init__.py +143 -0
  54. codetrail/learn/routes.py +139 -0
  55. codetrail/locales/ar/LC_MESSAGES/codetrail.mo +0 -0
  56. codetrail/locales/ar/LC_MESSAGES/codetrail.po +2236 -0
  57. codetrail/locales/codetrail.pot +1942 -0
  58. codetrail/lock.py +114 -0
  59. codetrail/metrics/__init__.py +6 -0
  60. codetrail/metrics/activity.py +96 -0
  61. codetrail/metrics/churn.py +156 -0
  62. codetrail/metrics/commits.py +45 -0
  63. codetrail/metrics/coverage.py +114 -0
  64. codetrail/metrics/decisions.py +77 -0
  65. codetrail/metrics/history.py +47 -0
  66. codetrail/metrics/inventory.py +63 -0
  67. codetrail/metrics/rationale.py +74 -0
  68. codetrail/metrics/releases.py +26 -0
  69. codetrail/metrics/report.py +111 -0
  70. codetrail/metrics/repository.py +128 -0
  71. codetrail/metrics/size.py +95 -0
  72. codetrail/py.typed +0 -0
  73. codetrail/remove.py +50 -0
  74. codetrail/repo/__init__.py +4 -0
  75. codetrail/repo/git.py +56 -0
  76. codetrail/repo/gitleaks.toml +6 -0
  77. codetrail/repo/history.py +262 -0
  78. codetrail/repo/mirror.py +137 -0
  79. codetrail/repo/refresh.py +41 -0
  80. codetrail/repo/rules.py +83 -0
  81. codetrail/repo/secrets.py +145 -0
  82. codetrail/repo/signal.py +65 -0
  83. codetrail/repo/source.py +166 -0
  84. codetrail/search.py +178 -0
  85. codetrail/server.py +45 -0
  86. codetrail/system.py +484 -0
  87. codetrail/update.py +276 -0
  88. codetrail/web/__init__.py +1 -0
  89. codetrail/web/app.py +897 -0
  90. codetrail/web/diagrams.py +414 -0
  91. codetrail/web/documentation.py +146 -0
  92. codetrail/web/i18n.py +44 -0
  93. codetrail/web/navigation.py +127 -0
  94. codetrail/web/render.py +126 -0
  95. codetrail/web/repository_view.py +230 -0
  96. codetrail/web/security.py +171 -0
  97. codetrail/web/static/brand/favicon.svg +1 -0
  98. codetrail/web/static/brand/mark.svg +1 -0
  99. codetrail/web/static/css/base.css +192 -0
  100. codetrail/web/static/css/components.css +2254 -0
  101. codetrail/web/static/css/content.css +310 -0
  102. codetrail/web/static/css/layout.css +410 -0
  103. codetrail/web/static/css/print.css +42 -0
  104. codetrail/web/static/css/tokens.css +228 -0
  105. codetrail/web/static/js/api.js +49 -0
  106. codetrail/web/static/js/ask.js +182 -0
  107. codetrail/web/static/js/d2-worker.js +34 -0
  108. codetrail/web/static/js/d2.js +28 -0
  109. codetrail/web/static/js/diagram.js +52 -0
  110. codetrail/web/static/js/documentation.js +24 -0
  111. codetrail/web/static/js/learning.js +47 -0
  112. codetrail/web/static/js/main.js +38 -0
  113. codetrail/web/static/js/outline.js +44 -0
  114. codetrail/web/static/js/palette.js +211 -0
  115. codetrail/web/static/js/repository.js +528 -0
  116. codetrail/web/static/js/resize.js +126 -0
  117. codetrail/web/static/js/shortcuts.js +87 -0
  118. codetrail/web/static/js/sidebar.js +194 -0
  119. codetrail/web/static/js/theme-init.js +27 -0
  120. codetrail/web/static/js/theme.js +42 -0
  121. codetrail/web/static/js/update.js +107 -0
  122. codetrail/web/static/vendor/d2/LICENSE +375 -0
  123. codetrail/web/static/vendor/d2/LICENSE-elk +263 -0
  124. codetrail/web/static/vendor/d2/LICENSE-wasm_exec +27 -0
  125. codetrail/web/static/vendor/d2/VERSION +12 -0
  126. codetrail/web/static/vendor/d2/d2.wasm +0 -0
  127. codetrail/web/static/vendor/d2/elk.js +105914 -0
  128. codetrail/web/static/vendor/d2/wasm_exec.js +656 -0
  129. codetrail/web/static/vendor/inter/InterVariable-Italic.woff2 +0 -0
  130. codetrail/web/static/vendor/inter/InterVariable.woff2 +0 -0
  131. codetrail/web/static/vendor/inter/LICENSE.txt +92 -0
  132. codetrail/web/static/vendor/inter/VERSION +6 -0
  133. codetrail/web/static/vendor/noto-sans-arabic/NotoSansArabic-wght.woff2 +0 -0
  134. codetrail/web/static/vendor/noto-sans-arabic/OFL.txt +93 -0
  135. codetrail/web/static/vendor/noto-sans-arabic/VERSION +12 -0
  136. codetrail/web/target_view.py +63 -0
  137. codetrail/web/templates/answers.html +25 -0
  138. codetrail/web/templates/area.html +46 -0
  139. codetrail/web/templates/base.html +37 -0
  140. codetrail/web/templates/decisions.html +30 -0
  141. codetrail/web/templates/diagram.html +10 -0
  142. codetrail/web/templates/digests.html +24 -0
  143. codetrail/web/templates/documentation.html +247 -0
  144. codetrail/web/templates/error.html +9 -0
  145. codetrail/web/templates/estimate.html +48 -0
  146. codetrail/web/templates/fact.html +29 -0
  147. codetrail/web/templates/home.html +196 -0
  148. codetrail/web/templates/layout/ask_panel.html +27 -0
  149. codetrail/web/templates/layout/header.html +32 -0
  150. codetrail/web/templates/layout/icons.html +27 -0
  151. codetrail/web/templates/layout/outline.html +4 -0
  152. codetrail/web/templates/layout/palette.html +27 -0
  153. codetrail/web/templates/layout/shortcuts.html +27 -0
  154. codetrail/web/templates/layout/sidebar.html +79 -0
  155. codetrail/web/templates/layout/update_dialog.html +18 -0
  156. codetrail/web/templates/macros.html +13 -0
  157. codetrail/web/templates/page.html +93 -0
  158. codetrail/web/templates/progress.html +46 -0
  159. codetrail/web/templates/repository.html +369 -0
  160. codetrail/web/templates/search.html +31 -0
  161. codetrail/web/templates/source.html +15 -0
  162. codetrail/web/templates/system.html +43 -0
  163. codetrail/web/templates/system_arrows.html +17 -0
  164. codetrail/web/templates/system_legend.html +9 -0
  165. codetrail/web/templates/update_log.html +30 -0
  166. codetrail/web/trend.py +22 -0
  167. codetrail_cli-1.4.1.dist-info/METADATA +372 -0
  168. codetrail_cli-1.4.1.dist-info/RECORD +171 -0
  169. codetrail_cli-1.4.1.dist-info/WHEEL +4 -0
  170. codetrail_cli-1.4.1.dist-info/entry_points.txt +3 -0
  171. codetrail_cli-1.4.1.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,218 @@
1
+ """Validation before a page is saved (design section 6.5).
2
+
3
+ - A documented block cites a visible file with a line range (at most 40 lines) or a commit, and quotes it; the
4
+ quote must appear, whitespace-normalized and contiguous, in those lines or in the commit's message. Commit messages
5
+ are scanned with gitleaks first; a flagged one can't be quoted.
6
+ - Fact links must name existing facts; diagram placeholders must name a known diagram over an existing scope.
7
+ - Checks must have a question and rubric points grounded in existing facts or visible paths.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ from collections.abc import Mapping, Sequence
15
+ from dataclasses import dataclass
16
+ from pathlib import Path, PurePosixPath
17
+ from typing import Any
18
+
19
+ from codetrail.errors import CodetrailError
20
+ from codetrail.facts import EntityKind, RelationKind
21
+ from codetrail.facts.store import FactStore
22
+ from codetrail.generate.outline import in_scope
23
+ from codetrail.repo.git import run_git
24
+ from codetrail.repo.secrets import SecretScanner
25
+ from codetrail.repo.source import SourceManifest
26
+
27
+ MARKER = re.compile(r"^>\s*\[!(documented|inferred)\]\s*(\S*)\s*$")
28
+ FACT_LINK = re.compile(r"\[\[([a-z_]+:[^\]\s]+)\]\]")
29
+ DIAGRAM = re.compile(r"^\{\{\s*diagram\b(.*?)\}\}\s*$")
30
+ DIAGRAM_KINDS = {"imports": "scope", "dependencies": "project", "resources": "scope", "system": "focus"}
31
+ FILE_CITATION = re.compile(r"^(?P<path>[^#\s]+)#L(?P<start>\d+)(?:-L(?P<end>\d+))?$")
32
+ COMMIT_CITATION = re.compile(r"^commit:(?P<sha>[0-9a-f]{7,40})$")
33
+ MAX_CITED_LINES = 40
34
+ QUOTE_MARKS = "\"'\u201c\u201d\u2018\u2019\u00ab\u00bb" # straight and typographic quotation marks
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class RationaleBlock:
39
+ kind: str
40
+ citation: str | None
41
+ text: str
42
+ line: int
43
+
44
+
45
+ @dataclass
46
+ class ValidationContext:
47
+ source_root: Path
48
+ manifest: SourceManifest
49
+ store: FactStore
50
+ mirror: Path
51
+ scanner: SecretScanner
52
+
53
+
54
+ def parse_rationale(body: str) -> list[RationaleBlock]:
55
+ blocks = []
56
+ lines = body.splitlines()
57
+ index = 0
58
+ while index < len(lines):
59
+ marker = MARKER.match(lines[index])
60
+ if not marker:
61
+ index += 1
62
+ continue
63
+ start = index
64
+ index += 1
65
+ content = []
66
+ while index < len(lines) and lines[index].startswith(">") and not MARKER.match(lines[index]):
67
+ content.append(lines[index][1:].removeprefix(" "))
68
+ index += 1
69
+ blocks.append(RationaleBlock(marker.group(1), marker.group(2) or None, "\n".join(content).strip(), start + 1))
70
+ return blocks
71
+
72
+
73
+ def normalize(text: str) -> str:
74
+ return " ".join(text.split())
75
+
76
+
77
+ FLAGGED_PROBLEM = "The page contains something that looks like a secret"
78
+
79
+
80
+ def validate_page(body: str, checks: Sequence[Mapping[str, Any]] | None, context: ValidationContext) -> list[str]:
81
+ problems: list[str] = []
82
+ if not body.strip():
83
+ return ["The page is empty."]
84
+ # The last check before anything is saved: a page or its checks must hold nothing that looks like a secret.
85
+ for finding in context.scanner.scan_text(body + "\n" + json.dumps(list(checks or []), ensure_ascii=False)):
86
+ problems.append(f"{FLAGGED_PROBLEM} ({finding.rule}); leave it out.")
87
+ if problems: # the other problems quote the draft, and they reach the retry prompt, the CLI and the page
88
+ return problems
89
+ for block in parse_rationale(body):
90
+ if block.kind == "documented":
91
+ problems += _check_documented(block, context)
92
+ for fact in FACT_LINK.findall(body):
93
+ if context.store.entity(fact) is None:
94
+ problems.append(f"The fact link [[{fact}]] names no existing fact.")
95
+ for line in body.splitlines():
96
+ diagram = DIAGRAM.match(line.strip())
97
+ if diagram:
98
+ problems += _check_diagram(diagram.group(1), context)
99
+ if checks is not None:
100
+ problems += _check_checks(checks, context)
101
+ return problems
102
+
103
+
104
+ def validate_brief(brief: str, context: ValidationContext, max_words: int) -> list[str]:
105
+ """A page's brief for coding agents (design 23.3): present, within its words, and valid as a page is. A secret
106
+ finding keeps its own wording, so a flagged revision is still retried from the page as it stood."""
107
+ if not brief.strip():
108
+ return ["The brief is missing."]
109
+ words = len(brief.split())
110
+ if words > max_words:
111
+ return [f"The brief has {words} words; keep it to at most {max_words}."]
112
+ return [
113
+ problem if problem.startswith(FLAGGED_PROBLEM) else f"In the brief: {problem}"
114
+ for problem in validate_page(brief, None, context)
115
+ ]
116
+
117
+
118
+ def _check_documented(block: RationaleBlock, context: ValidationContext) -> list[str]:
119
+ quote = normalize(block.text).strip(QUOTE_MARKS + " ")
120
+ where = f"The documented block at line {block.line}"
121
+ if not quote:
122
+ return [f"{where} has no quote."]
123
+ citation = block.citation or ""
124
+ file = FILE_CITATION.match(citation)
125
+ commit = COMMIT_CITATION.match(citation)
126
+ if file:
127
+ path, start = file.group("path"), int(file.group("start"))
128
+ end = int(file.group("end") or start)
129
+ if path not in context.manifest.files:
130
+ return [f"{where} cites {path}, which isn't a visible file."]
131
+ if end < start or end - start + 1 > MAX_CITED_LINES:
132
+ return [f"{where} cites lines {start}-{end}; cite at most {MAX_CITED_LINES} lines, in order."]
133
+ lines = (context.source_root / PurePosixPath(path)).read_text(encoding="utf-8", errors="replace").splitlines()
134
+ if start < 1 or end > len(lines):
135
+ return [f"{where} cites lines {start}-{end}, but {path} has {len(lines)} lines."]
136
+ if quote not in normalize("\n".join(lines[start - 1 : end])):
137
+ return [f"{where}: the quote isn't in {path} lines {start}-{end}. Copy the words exactly from those lines."]
138
+ return []
139
+ if commit:
140
+ message = _commit_message(commit.group("sha"), context)
141
+ if message is None:
142
+ return [f"{where} cites a commit that doesn't exist in the repository."]
143
+ if context.scanner.scan_text(message):
144
+ return [f"{where} cites a commit whose message is withheld (gitleaks flagged it)."]
145
+ if quote not in normalize(message):
146
+ return [f"{where}: the quote isn't in the message of commit {commit.group('sha')}."]
147
+ return []
148
+ return [f"{where} has no valid citation (use path#L<start>-L<end> or commit:<sha>)."]
149
+
150
+
151
+ def _commit_message(sha: str, context: ValidationContext) -> str | None:
152
+ try:
153
+ return run_git(["show", "-s", "--format=%B", f"{sha}^{{commit}}"], git_dir=context.mirror).decode(
154
+ "utf-8", "replace"
155
+ )
156
+ except CodetrailError:
157
+ return None
158
+
159
+
160
+ def _check_diagram(arguments: str, context: ValidationContext) -> list[str]:
161
+ parts = arguments.split()
162
+ if parts == ["system"]:
163
+ has_parts = bool(context.store.entities(EntityKind.PART))
164
+ return [] if has_parts else ["The system diagram would draw nothing: no parts were found."]
165
+ if len(parts) != 2 or parts[0] not in DIAGRAM_KINDS or "=" not in parts[1]:
166
+ return [f"The diagram placeholder '{{{{diagram{arguments}}}}}' isn't a known diagram."]
167
+ kind, (key, value) = parts[0], parts[1].split("=", 1)
168
+ if key != DIAGRAM_KINDS[kind]:
169
+ return [f"The {kind} diagram takes {DIAGRAM_KINDS[kind]}=, not {key}=."]
170
+ if kind in ("imports", "resources") and not any(in_scope(path, [value]) for path in context.manifest.files):
171
+ return [f"The diagram scope {value} holds no visible file."]
172
+ if kind == "dependencies":
173
+ project = context.store.entity(value)
174
+ if project is None or project.kind is not EntityKind.PROJECT:
175
+ return [f"The diagram project {value} isn't a project fact."]
176
+ if not _draws_something(kind, value, context.store):
177
+ return [f"The {kind} diagram for {value} would draw nothing: no facts of that kind are there."]
178
+ return []
179
+
180
+
181
+ def _draws_something(kind: str, value: str, store: FactStore) -> bool:
182
+ """Whether the diagram has at least one fact to draw, so no page shows an empty box."""
183
+ prefix = value.rstrip("/") + "/"
184
+ if kind == "imports":
185
+ kinds = (EntityKind.MODULE, EntityKind.SWIFT_TARGET)
186
+ return any(entity.id.split(":", 1)[1].startswith(prefix) for each in kinds for entity in store.entities(each))
187
+ if kind == "resources":
188
+ folders = (entity.id.split(":", 1)[1] + "/" for entity in store.entities(EntityKind.TERRAFORM_MODULE))
189
+ return any(folder.startswith(prefix) or prefix.startswith(folder) for folder in folders)
190
+ if kind == "system":
191
+ focus = value.strip("/")
192
+ folders = (str(part.attributes.get("folder") or "") for part in store.entities(EntityKind.PART)
193
+ if part.attributes.get("kind") != "platform") # fmt: skip
194
+ return any(folder == focus or folder.startswith(focus + "/") for folder in folders)
195
+ return any(relation.source_id == value for relation in store.relations(RelationKind.DEPENDS_ON))
196
+
197
+
198
+ def _check_checks(checks: Sequence[Mapping[str, Any]], context: ValidationContext) -> list[str]:
199
+ if not 1 <= len(checks) <= 6:
200
+ return ["Write two to four checks."]
201
+ problems = []
202
+ for check in checks:
203
+ name = str(check.get("id", "?"))
204
+ if not str(check.get("question", "")).strip():
205
+ problems.append(f"Check {name} has no question.")
206
+ rubric = check.get("rubric") or []
207
+ if not rubric:
208
+ problems.append(f"Check {name} has no rubric.")
209
+ for point in rubric:
210
+ for ground in point.get("grounds") or []:
211
+ ground = str(ground)
212
+ known_fact = context.store.entity(ground) is not None
213
+ known_path = any(in_scope(path, [ground]) for path in context.manifest.files)
214
+ if not (known_fact or known_path):
215
+ problems.append(
216
+ f"Check {name} is grounded in {ground}, which is neither a fact nor a visible path."
217
+ )
218
+ return problems
@@ -0,0 +1,147 @@
1
+ """The guide: the knowledge base Claude writes, as Markdown with YAML front matter in its own git repository.
2
+
3
+ It lives in Codetrail's data folder, never in a target. An update writes pages into the working tree and commits once;
4
+ if anything fails, the uncommitted changes are discarded (design section 6.8). Front matter is read with
5
+ `yaml.safe_load`, so no tag in it can build an object.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import yaml
16
+
17
+ from codetrail.errors import CodetrailError
18
+ from codetrail.repo.git import run_git
19
+
20
+ PAGE_ID = re.compile(r"(areas|concepts|paths|digests|answers)/[a-z0-9][a-z0-9-]{0,80}")
21
+ OUTLINE_FILE = "outline.yaml"
22
+ COMMIT_ID = re.compile(r"[0-9a-f]{7,40}")
23
+ # The guide's own commits run no hooks and need no signing key, whatever the user's global git settings say.
24
+ COMMIT_SETTINGS = ["-c", "core.hooksPath=/dev/null", "-c", "commit.gpgsign=false",
25
+ "-c", "user.name=Codetrail", "-c", "user.email=codetrail@localhost"] # fmt: skip
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Page:
30
+ id: str
31
+ meta: dict[str, Any] = field(default_factory=dict)
32
+ body: str = ""
33
+
34
+ @property
35
+ def kind(self) -> str:
36
+ return str(self.meta.get("kind", ""))
37
+
38
+ @property
39
+ def title(self) -> str:
40
+ return str(self.meta.get("title", self.id))
41
+
42
+
43
+ def validate_page_id(page_id: str) -> str:
44
+ if not PAGE_ID.fullmatch(page_id):
45
+ raise CodetrailError(f"{page_id!r} isn't a valid page id.")
46
+ return page_id
47
+
48
+
49
+ class GuideRepository:
50
+ def __init__(self, root: Path) -> None:
51
+ self.root = root
52
+
53
+ @property
54
+ def git_dir(self) -> Path:
55
+ return self.root / ".git"
56
+
57
+ def ensure(self) -> None:
58
+ if (self.git_dir / "HEAD").exists():
59
+ return
60
+ self.root.mkdir(parents=True, exist_ok=True)
61
+ run_git(["init", "-q", "-b", "main", str(self.root)])
62
+
63
+ def _git(self, *arguments: str) -> bytes:
64
+ return run_git([*COMMIT_SETTINGS, *arguments], git_dir=self.git_dir, work_tree=self.root)
65
+
66
+ def path_of(self, page_id: str) -> Path:
67
+ return self.root / f"{validate_page_id(page_id)}.md"
68
+
69
+ def read_page(self, page_id: str) -> Page | None:
70
+ file = self.path_of(page_id)
71
+ if not file.exists():
72
+ return None
73
+ return parse_page(page_id, file.read_text(encoding="utf-8"))
74
+
75
+ def write_page(self, page: Page) -> None:
76
+ file = self.path_of(page.id)
77
+ file.parent.mkdir(parents=True, exist_ok=True)
78
+ file.write_text(render_page(page), encoding="utf-8")
79
+
80
+ def pages(self, kind: str | None = None) -> list[Page]:
81
+ found = []
82
+ for file in sorted(self.root.glob("*/*.md")):
83
+ page_id = f"{file.parent.name}/{file.stem}"
84
+ if PAGE_ID.fullmatch(page_id):
85
+ page = parse_page(page_id, file.read_text(encoding="utf-8"))
86
+ if kind is None or page.kind == kind:
87
+ found.append(page)
88
+ return found
89
+
90
+ def read_outline(self) -> dict[str, Any] | None:
91
+ file = self.root / OUTLINE_FILE
92
+ if not file.exists():
93
+ return None
94
+ data = yaml.safe_load(file.read_text(encoding="utf-8"))
95
+ return data if isinstance(data, dict) else None
96
+
97
+ def write_outline(self, outline: dict[str, Any]) -> None:
98
+ self.root.mkdir(parents=True, exist_ok=True)
99
+ text = yaml.safe_dump(outline, sort_keys=False, allow_unicode=True)
100
+ (self.root / OUTLINE_FILE).write_text(text, encoding="utf-8")
101
+
102
+ def has_uncommitted_changes(self) -> bool:
103
+ # No optional locks: the page checks this while an update may be committing (design 16.3).
104
+ return bool(self._git("--no-optional-locks", "status", "--porcelain", "--untracked-files=all").strip())
105
+
106
+ def head(self) -> str | None:
107
+ try:
108
+ return self._git("rev-parse", "--verify", "-q", "HEAD").decode().strip() or None
109
+ except CodetrailError:
110
+ return None
111
+
112
+ def commit(self, message: str) -> str | None:
113
+ """Commits every change and returns the commit, or None when nothing changed."""
114
+ self._git("add", "-A")
115
+ if not self._git("status", "--porcelain").strip():
116
+ return None
117
+ self._git("commit", "-q", "-m", message)
118
+ return self.head()
119
+
120
+ def discard(self) -> None:
121
+ """Throws away every uncommitted change, back to the last commit."""
122
+ if self.head() is not None:
123
+ self._git("reset", "-q", "--hard", "HEAD")
124
+ self._git("clean", "-q", "-f", "-d")
125
+
126
+ def file_at(self, commit: str, page_id: str) -> str | None:
127
+ if not COMMIT_ID.fullmatch(commit): # only a commit id reaches git, never an option
128
+ return None
129
+ try:
130
+ return self._git("show", f"{commit}:{validate_page_id(page_id)}.md").decode("utf-8", "replace")
131
+ except CodetrailError:
132
+ return None
133
+
134
+
135
+ def render_page(page: Page) -> str:
136
+ front = yaml.safe_dump(page.meta, sort_keys=False, allow_unicode=True)
137
+ return f"---\n{front}---\n\n{page.body.rstrip()}\n"
138
+
139
+
140
+ def parse_page(page_id: str, text: str) -> Page:
141
+ if text.startswith("---\n"):
142
+ end = text.find("\n---\n", 4)
143
+ if end != -1:
144
+ meta = yaml.safe_load(text[4 : end + 1]) or {}
145
+ body = text[end + 5 :].lstrip("\n").rstrip()
146
+ return Page(page_id, meta if isinstance(meta, dict) else {}, body)
147
+ return Page(page_id, {}, text.rstrip())
@@ -0,0 +1,143 @@
1
+ """Learning state: what the reader has read and learned, and what went stale (design section 8.3).
2
+
3
+ A page's version is a hash of its body and checks. Passing a check records its hash, so when an update rewrites a
4
+ page, checks that didn't change stay passed and only new or changed ones need answering again. A page is learned when
5
+ every current check has a pass; a page learned before whose version changed is stale until it is learned again.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import sqlite3
13
+ from collections.abc import Iterable, Mapping, Sequence
14
+ from dataclasses import dataclass, field
15
+ from datetime import UTC, datetime
16
+ from typing import Any
17
+
18
+ from codetrail.guide import Page
19
+
20
+ PASS = "pass" # noqa: S105 - a verdict, not a password
21
+ VERDICTS = ("pass", "partial", "fail")
22
+
23
+
24
+ def _hash(value: Any) -> str:
25
+ return hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
26
+
27
+
28
+ def check_hash(check: Mapping[str, Any]) -> str:
29
+ return _hash({"question": check.get("question"), "rubric": check.get("rubric")})
30
+
31
+
32
+ def page_version(page: Page) -> str:
33
+ return _hash({"body": page.body, "checks": page.meta.get("checks") or []})
34
+
35
+
36
+ def page_checks(page: Page) -> list[dict[str, Any]]:
37
+ return [check for check in page.meta.get("checks") or [] if isinstance(check, dict) and check.get("id")]
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class PageStatus:
42
+ state: str # "unread", "read", "learned" or "stale"
43
+ passed: set[str] = field(default_factory=set)
44
+ learned_commit: str | None = None
45
+
46
+
47
+ def _now() -> str:
48
+ return datetime.now(UTC).isoformat(timespec="seconds")
49
+
50
+
51
+ class LearningState:
52
+ def __init__(self, connection: sqlite3.Connection) -> None:
53
+ self.connection = connection
54
+
55
+ def mark_read(self, page: Page) -> None:
56
+ self.connection.execute(
57
+ "INSERT INTO page_marks (page_id, read_version, read_at) VALUES (?, ?, ?)"
58
+ " ON CONFLICT (page_id) DO UPDATE SET read_version = excluded.read_version, read_at = excluded.read_at",
59
+ (page.id, page_version(page), _now()),
60
+ )
61
+
62
+ def mark_unread(self, page: Page) -> None:
63
+ """Takes back a read mark; a learned page stays learned, since that came from its checks."""
64
+ self.connection.execute(
65
+ "UPDATE page_marks SET read_version = NULL, read_at = NULL WHERE page_id = ?", (page.id,)
66
+ )
67
+
68
+ def record_attempt(
69
+ self,
70
+ page: Page,
71
+ check: Mapping[str, Any],
72
+ answer: str,
73
+ verdict: str,
74
+ feedback: str,
75
+ language: str,
76
+ guide_commit: str | None,
77
+ ) -> PageStatus:
78
+ """Stores an attempt; a pass is recorded for the check's hash, and a page whose checks all pass is learned."""
79
+ digest = check_hash(check)
80
+ self.connection.execute(
81
+ "INSERT INTO check_attempts (page_id, check_id, check_hash, answer, verdict, feedback, language,"
82
+ " attempted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
83
+ (page.id, str(check["id"]), digest, answer, verdict, feedback, language, _now()),
84
+ )
85
+ if verdict == PASS:
86
+ self.connection.execute(
87
+ "INSERT OR IGNORE INTO check_passes (page_id, check_id, check_hash, passed_at) VALUES (?, ?, ?, ?)",
88
+ (page.id, str(check["id"]), digest, _now()),
89
+ )
90
+ if self._all_passed(page):
91
+ self.connection.execute(
92
+ "INSERT INTO page_marks (page_id, learned_version, learned_commit, learned_at) VALUES (?, ?, ?, ?)"
93
+ " ON CONFLICT (page_id) DO UPDATE SET learned_version = excluded.learned_version,"
94
+ " learned_commit = excluded.learned_commit, learned_at = excluded.learned_at",
95
+ (page.id, page_version(page), guide_commit, _now()),
96
+ )
97
+ return self.status(page)
98
+
99
+ def _passed(self, page: Page) -> set[str]:
100
+ passes = {
101
+ (row["check_id"], row["check_hash"])
102
+ for row in self.connection.execute(
103
+ "SELECT check_id, check_hash FROM check_passes WHERE page_id = ?", (page.id,)
104
+ )
105
+ }
106
+ return {str(check["id"]) for check in page_checks(page) if (str(check["id"]), check_hash(check)) in passes}
107
+
108
+ def _all_passed(self, page: Page) -> bool:
109
+ checks = page_checks(page)
110
+ return bool(checks) and self._passed(page) == {str(check["id"]) for check in checks}
111
+
112
+ def status(self, page: Page) -> PageStatus:
113
+ row = self.connection.execute("SELECT * FROM page_marks WHERE page_id = ?", (page.id,)).fetchone()
114
+ passed = self._passed(page)
115
+ learned_commit = row["learned_commit"] if row else None
116
+ if row and row["learned_version"]:
117
+ if row["learned_version"] == page_version(page) or self._all_passed(page):
118
+ return PageStatus("learned", passed, learned_commit)
119
+ return PageStatus("stale", passed, learned_commit)
120
+ if row and row["read_version"]:
121
+ return PageStatus("read", passed)
122
+ return PageStatus("unread", passed)
123
+
124
+ def learned_page_ids(self) -> set[str]:
125
+ return {
126
+ row["page_id"]
127
+ for row in self.connection.execute("SELECT page_id FROM page_marks WHERE learned_version IS NOT NULL")
128
+ }
129
+
130
+ def mark_digest_read(self, digest_id: str) -> None:
131
+ self.connection.execute(
132
+ "INSERT OR IGNORE INTO digest_reads (digest_id, read_at) VALUES (?, ?)", (digest_id, _now())
133
+ )
134
+
135
+ def unread_digests(self, digest_ids: Iterable[str]) -> list[str]:
136
+ read = {row["digest_id"] for row in self.connection.execute("SELECT digest_id FROM digest_reads")}
137
+ return [digest for digest in digest_ids if digest not in read]
138
+
139
+ def attempts(self, page_id: str) -> Sequence[sqlite3.Row]:
140
+ return self.connection.execute(
141
+ "SELECT check_id, verdict, feedback, attempted_at FROM check_attempts WHERE page_id = ? ORDER BY id",
142
+ (page_id,),
143
+ ).fetchall()
@@ -0,0 +1,139 @@
1
+ """The page's learning routes: marks and checks (design section 8).
2
+
3
+ `POST /learn/read` marks a page or a digest read, and `POST /learn/unread` takes a page's read mark back.
4
+ `POST /learn/checks` sends the reader's answer to the assistant's grading (no tools) and records the attempt; the
5
+ response carries the verdict and the feedback, never the rubric. Feedback that holds something gitleaks flags is
6
+ replaced before it is stored or shown (design section 15.5), and the call's usage is recorded. One grading runs at a
7
+ time. Every route sits behind the security middleware.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import time
13
+ from collections.abc import Callable, Mapping
14
+
15
+ import anyio
16
+ from fastapi import APIRouter
17
+ from fastapi.responses import JSONResponse, Response
18
+ from pydantic import BaseModel, ConfigDict, Field
19
+
20
+ from codetrail.assistant import Assistant, AssistantError, GradeRequest
21
+ from codetrail.assistant.usage import UsageLog
22
+ from codetrail.config import Paths, Price, ToolsSettings
23
+ from codetrail.database import connect
24
+ from codetrail.guide import PAGE_ID, GuideRepository, Page
25
+ from codetrail.learn import VERDICTS, LearningState, page_checks
26
+ from codetrail.repo.secrets import SecretScanner
27
+ from codetrail.web.i18n import Language
28
+
29
+
30
+ class ReadMark(BaseModel):
31
+ model_config = ConfigDict(extra="forbid")
32
+ page_id: str = Field(max_length=200)
33
+
34
+
35
+ class CheckAnswer(BaseModel):
36
+ model_config = ConfigDict(extra="forbid")
37
+ page_id: str = Field(max_length=200)
38
+ check_id: str = Field(max_length=100)
39
+ answer: str = Field(min_length=1, max_length=20_000)
40
+
41
+
42
+ def learning_router(
43
+ paths: Paths,
44
+ name: str,
45
+ assistant_for: Callable[[], Assistant],
46
+ language_of: Callable[[], Language],
47
+ max_answer_chars: int,
48
+ cooldown_seconds: int,
49
+ tools: ToolsSettings,
50
+ prices: Mapping[str, Price] | None = None,
51
+ ) -> APIRouter:
52
+ router = APIRouter()
53
+ data = paths.target_data(name)
54
+ guide = GuideRepository(data / "guide")
55
+ grading: dict[str, float | bool] = {"busy": False, "last": -1e18}
56
+
57
+ def find_page(page_id: str) -> Page | None:
58
+ return guide.read_page(page_id) if PAGE_ID.fullmatch(page_id) else None
59
+
60
+ @router.post("/learn/read")
61
+ def mark_read(mark: ReadMark) -> Response:
62
+ _ = language_of().translations.gettext
63
+ page = find_page(mark.page_id)
64
+ if page is None:
65
+ return JSONResponse({"error": _("That page doesn't exist.")}, 404)
66
+ connection = connect(data / "codetrail.db")
67
+ try:
68
+ learning = LearningState(connection)
69
+ if page.kind == "digest":
70
+ learning.mark_digest_read(page.id)
71
+ else:
72
+ learning.mark_read(page)
73
+ finally:
74
+ connection.close()
75
+ return Response(status_code=204)
76
+
77
+ @router.post("/learn/unread")
78
+ def mark_unread(mark: ReadMark) -> Response:
79
+ _ = language_of().translations.gettext
80
+ page = find_page(mark.page_id)
81
+ if page is None or page.kind == "digest":
82
+ return JSONResponse({"error": _("That page doesn't exist.")}, 404)
83
+ connection = connect(data / "codetrail.db")
84
+ try:
85
+ LearningState(connection).mark_unread(page)
86
+ finally:
87
+ connection.close()
88
+ return Response(status_code=204)
89
+
90
+ @router.post("/learn/checks")
91
+ async def answer_check(submitted: CheckAnswer) -> Response:
92
+ language = language_of()
93
+ _ = language.translations.gettext
94
+ page = find_page(submitted.page_id)
95
+ check = next((item for item in page_checks(page) if item["id"] == submitted.check_id), None) if page else None
96
+ if page is None or check is None:
97
+ return JSONResponse({"error": _("That check doesn't exist.")}, 404)
98
+ if len(submitted.answer) > max_answer_chars:
99
+ limit = _("Answers are limited to %(count)s characters.") % {"count": max_answer_chars}
100
+ return JSONResponse({"error": limit}, 413)
101
+ if grading["busy"]: # claimed with no await before it, so two gradings can't both start
102
+ return JSONResponse({"error": _("Another answer is being graded.")}, 429)
103
+ if time.monotonic() - float(grading["last"]) < cooldown_seconds:
104
+ return JSONResponse({"error": _("Wait a few seconds before the next answer.")}, 429)
105
+ grading["busy"], grading["last"] = True, time.monotonic()
106
+ try:
107
+ request = GradeRequest(
108
+ str(check.get("question", "")), list(check.get("rubric") or []), page.title, page.body,
109
+ submitted.answer, language.code,
110
+ ) # fmt: skip
111
+ verdict = await assistant_for().grade(request)
112
+ except AssistantError as error:
113
+ return JSONResponse({"error": str(error)}, 502)
114
+ finally:
115
+ grading["busy"] = False
116
+ feedback = verdict.feedback
117
+ findings = await anyio.to_thread.run_sync(SecretScanner(tools).scan_text, feedback)
118
+ if findings:
119
+ withheld = _("The feedback was withheld: it contains something that looks like a secret (%(rule)s).")
120
+ feedback = withheld % {"rule": findings[0].rule}
121
+ if verdict.verdict not in VERDICTS:
122
+ unread = _("The grade couldn't be read, so nothing was recorded; try again.")
123
+ return JSONResponse({"error": unread}, 502)
124
+ connection = connect(data / "codetrail.db")
125
+ try:
126
+ cost = UsageLog(connection, prices or {}).record("grade", verdict.usage) or verdict.cost_usd
127
+ status = LearningState(connection).record_attempt(
128
+ page, check, submitted.answer, verdict.verdict, feedback, language.code, guide.head()
129
+ )
130
+ finally:
131
+ connection.close()
132
+ usage = verdict.usage
133
+ used = {
134
+ "tokens": usage.input_tokens + usage.cached_input_tokens + usage.output_tokens,
135
+ "cost_usd": round(cost, 4),
136
+ }
137
+ return JSONResponse({"verdict": verdict.verdict, "feedback": feedback, "state": status.state, "usage": used})
138
+
139
+ return router