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,186 @@
1
+ """Whether each provider is installed and signed in, and how, before any paid work (design section 15.2).
2
+
3
+ Claude Code is asked with `claude auth status --json` and Codex with `codex login status`, in the same allowlisted
4
+ environment the calls use; only whether the program is signed in, and how, is kept, never an account's details. With
5
+ `auth = "subscription"`, a sign-in that bills an API account instead is refused. The local provider's endpoint is asked
6
+ for its models, which are shown so a stranger answering on the port is noticed.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import tempfile
14
+ from collections.abc import Mapping, Sequence
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ import httpx
19
+
20
+ from codetrail.assistant import AssistantError, runner
21
+ from codetrail.assistant.environment import child_environment, resolve_program
22
+ from codetrail.config import GlobalConfig, TargetConfig, model_choice
23
+
24
+ CHECK_TIMEOUT_SECONDS = 30
25
+ INSTALL = {
26
+ "claude_code": "Install Claude Code (https://code.claude.com/docs/en/setup), run `claude` once and sign in with "
27
+ "/login using your Claude subscription.",
28
+ "codex": "Install Codex (`npm install -g @openai/codex`), then run `codex login` and choose Sign in with ChatGPT.",
29
+ }
30
+ CODEX_WARNING = (
31
+ "Codex reads outside the repository's allowed files (design section 15.5). Its isolation from your Codex "
32
+ "settings is tested against a stand-in, and checked live only where Codex is installed (`just test-live`)."
33
+ )
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class ProviderStatus:
38
+ provider: str
39
+ ready: bool
40
+ installed: bool = False
41
+ signed_in: bool = False
42
+ program: str = ""
43
+ method: str = ""
44
+ fix: str = ""
45
+ warning: str = ""
46
+ models: tuple[str, ...] = ()
47
+
48
+
49
+ def provider_status(
50
+ provider: str, settings: GlobalConfig, environ: Mapping[str, str] = os.environ, client: httpx.Client | None = None
51
+ ) -> ProviderStatus:
52
+ if provider == "claude_code":
53
+ return _claude_code(settings, environ)
54
+ if provider == "codex":
55
+ return _codex(settings, environ)
56
+ return _local(settings, client)
57
+
58
+
59
+ def require_ready(
60
+ target: TargetConfig,
61
+ settings: GlobalConfig,
62
+ environ: Mapping[str, str] = os.environ,
63
+ kinds: Sequence[str] = ("plan", "write", "digest", "answer", "grade"),
64
+ ) -> list[ProviderStatus]:
65
+ """The status of each provider the given kinds of call use; raises with the fix when one isn't ready."""
66
+ providers = list(dict.fromkeys(model_choice(getattr(target.models, kind))[0] for kind in kinds))
67
+ statuses = [provider_status(provider, settings, environ) for provider in providers]
68
+ for status in statuses:
69
+ if not status.ready:
70
+ raise AssistantError(f"{status.provider} isn't ready. {status.fix}")
71
+ return statuses
72
+
73
+
74
+ def _claude_code(settings: GlobalConfig, environ: Mapping[str, str]) -> ProviderStatus:
75
+ config = settings.providers.claude_code
76
+ environment = child_environment("claude_code", config.auth, environ)
77
+ try:
78
+ program = resolve_program(config.command, environment.get("PATH", ""))
79
+ except AssistantError:
80
+ return ProviderStatus("claude_code", False, fix=INSTALL["claude_code"])
81
+ with tempfile.TemporaryDirectory() as folder:
82
+ try:
83
+ _code, output = runner.run_command(
84
+ [program, "auth", "status", "--json"], Path(folder), environment, CHECK_TIMEOUT_SECONDS
85
+ )
86
+ found = json.loads(output)
87
+ except AssistantError, ValueError:
88
+ return ProviderStatus(
89
+ "claude_code",
90
+ False,
91
+ True,
92
+ program=program,
93
+ fix="`claude auth status` didn't answer; run `claude` once to finish its setup.",
94
+ )
95
+ found = found if isinstance(found, dict) else {}
96
+ signed_in = found.get("loggedIn") is True
97
+ subscription = found.get("authMethod") == "claude.ai"
98
+ plan = str(found.get("subscriptionType") or "")
99
+ method = (
100
+ f"Claude subscription ({plan})"
101
+ if subscription and plan
102
+ else ("Claude subscription" if subscription else f"{found.get('authMethod', 'unknown')} sign-in")
103
+ )
104
+ if not signed_in:
105
+ return ProviderStatus(
106
+ "claude_code",
107
+ False,
108
+ True,
109
+ False,
110
+ program,
111
+ fix="Run `claude` and sign in with /login using your Claude subscription.",
112
+ )
113
+ if config.auth == "subscription" and not subscription:
114
+ return ProviderStatus(
115
+ "claude_code",
116
+ False,
117
+ True,
118
+ True,
119
+ program,
120
+ method,
121
+ fix="Claude Code isn't signed in with a Claude subscription: run `claude` and /login "
122
+ 'with your Claude account, or set [providers.claude_code] auth = "api_key".',
123
+ )
124
+ return ProviderStatus("claude_code", True, True, True, program, method)
125
+
126
+
127
+ def _codex(settings: GlobalConfig, environ: Mapping[str, str]) -> ProviderStatus:
128
+ config = settings.providers.codex
129
+ with tempfile.TemporaryDirectory() as folder:
130
+ home = Path(folder) / "home"
131
+ home.mkdir()
132
+ environment = child_environment("codex", config.auth, environ, empty_home=home)
133
+ try:
134
+ program = resolve_program(config.command, environment.get("PATH", ""))
135
+ except AssistantError:
136
+ return ProviderStatus("codex", False, fix=INSTALL["codex"], warning=CODEX_WARNING)
137
+ try:
138
+ code, output = runner.run_command(
139
+ [program, "login", "status"], Path(folder), environment, CHECK_TIMEOUT_SECONDS
140
+ )
141
+ except AssistantError:
142
+ code, output = 1, ""
143
+ signed_in = code == 0 and "logged in" in output.lower() and "not logged in" not in output.lower()
144
+ subscription = "chatgpt" in output.lower()
145
+ method = "ChatGPT subscription" if subscription else "API key"
146
+ if not signed_in:
147
+ return ProviderStatus(
148
+ "codex",
149
+ False,
150
+ True,
151
+ False,
152
+ program,
153
+ fix="Run `codex login` and choose Sign in with ChatGPT.",
154
+ warning=CODEX_WARNING,
155
+ )
156
+ if config.auth == "subscription" and not subscription:
157
+ return ProviderStatus(
158
+ "codex",
159
+ False,
160
+ True,
161
+ True,
162
+ program,
163
+ method,
164
+ fix="Codex is signed in with an API key: run `codex login` and choose Sign in with "
165
+ 'ChatGPT, or set [providers.codex] auth = "api_key".',
166
+ warning=CODEX_WARNING,
167
+ )
168
+ return ProviderStatus("codex", True, True, True, program, method, warning=CODEX_WARNING)
169
+
170
+
171
+ def _local(settings: GlobalConfig, client: httpx.Client | None) -> ProviderStatus:
172
+ config = settings.providers.local
173
+ fix = f"Start Ollama (`ollama serve`) or LM Studio's server, or set [providers.local] base_url ({config.base_url})."
174
+ http = client or httpx.Client(trust_env=False, follow_redirects=False, timeout=10)
175
+ try:
176
+ response = http.get(config.base_url.rstrip("/") + "/models")
177
+ listed = response.json().get("data", []) if response.status_code == 200 else None
178
+ except httpx.HTTPError, ValueError, AttributeError:
179
+ listed = None
180
+ finally:
181
+ if client is None:
182
+ http.close()
183
+ if not isinstance(listed, list):
184
+ return ProviderStatus("local", False, fix=fix, program=config.base_url)
185
+ models = tuple(str(item.get("id")) for item in listed if isinstance(item, dict) and item.get("id"))
186
+ return ProviderStatus("local", True, True, True, config.base_url, "no sign-in needed", models=models)
@@ -0,0 +1,93 @@
1
+ """Records what each assistant call used, and keeps the latest plan usage readings (design section 15.3).
2
+
3
+ A call's cost is the provider's own figure when it gives one, otherwise its tokens at the configured prices; a local
4
+ model costs nothing; a model with no configured price has tokens only. These records are the history the estimates
5
+ are made from (section 15.4).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sqlite3
11
+ from collections.abc import Mapping
12
+ from dataclasses import dataclass
13
+ from datetime import UTC, datetime
14
+
15
+ from codetrail.assistant import Usage
16
+ from codetrail.config import Price
17
+
18
+ PER_MILLION = 1_000_000
19
+
20
+
21
+ def priced(usage: Usage, prices: Mapping[str, Price]) -> float | None:
22
+ """The usage at the configured prices; zero for local models; None for a model without a price."""
23
+ if usage.provider == "local":
24
+ return 0.0
25
+ price = prices.get(usage.model)
26
+ if price is None:
27
+ return None
28
+ cached = price.cached_input if price.cached_input is not None else price.input
29
+ return (
30
+ usage.input_tokens * price.input + usage.cached_input_tokens * cached + usage.output_tokens * price.output
31
+ ) / PER_MILLION
32
+
33
+
34
+ class UsageLog:
35
+ def __init__(self, connection: sqlite3.Connection, prices: Mapping[str, Price]) -> None:
36
+ self.connection = connection
37
+ self.prices = prices
38
+
39
+ def record(self, kind: str, usage: Usage) -> float:
40
+ """Records the call and returns its cost in dollars (zero when unknown) for the update's budget."""
41
+ if not usage.provider:
42
+ return 0.0
43
+ cost = usage.cost_usd if usage.cost_usd is not None else priced(usage, self.prices)
44
+ now = datetime.now(UTC).isoformat(timespec="seconds")
45
+ self.connection.execute("BEGIN") # the call and its plan readings are saved together or not at all
46
+ try:
47
+ self.connection.execute(
48
+ "INSERT INTO assistant_calls (called_at, kind, provider, model, input_tokens, cached_input_tokens,"
49
+ " output_tokens, cost_usd) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
50
+ (now, kind, usage.provider, usage.model, usage.input_tokens, usage.cached_input_tokens,
51
+ usage.output_tokens, cost),
52
+ ) # fmt: skip
53
+ for window in usage.plan_windows:
54
+ self.connection.execute(
55
+ "INSERT INTO plan_usage (provider, window, utilization, resets_at, observed_at)"
56
+ " VALUES (?, ?, ?, ?, ?) ON CONFLICT (provider, window) DO UPDATE SET"
57
+ " utilization = excluded.utilization, resets_at = excluded.resets_at,"
58
+ " observed_at = excluded.observed_at",
59
+ (usage.provider, window.window, window.utilization, window.resets_at, now),
60
+ )
61
+ self.connection.execute("COMMIT")
62
+ except BaseException:
63
+ if self.connection.in_transaction: # SQLite ends it itself after some errors
64
+ self.connection.execute("ROLLBACK")
65
+ raise
66
+ return cost or 0.0
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class UpdateUsage:
71
+ tokens: int
72
+ cost_usd: float # what was reported or priced; calls with no price count as zero
73
+ calls: int
74
+ finished_at: str
75
+
76
+
77
+ def last_update_usage(connection: sqlite3.Connection) -> UpdateUsage | None:
78
+ """What the latest update that wrote the guide used: its plan, write and digest calls (design section 16.1).
79
+
80
+ Those calls follow the update's own snapshot, so they run from the last snapshot taken before the latest of them.
81
+ """
82
+ finished = connection.execute(
83
+ "SELECT MAX(called_at) FROM assistant_calls WHERE kind IN ('plan', 'write', 'digest')"
84
+ ).fetchone()[0]
85
+ if finished is None:
86
+ return None
87
+ start = connection.execute("SELECT MAX(taken_at) FROM snapshots WHERE taken_at <= ?", (finished,)).fetchone()[0]
88
+ row = connection.execute(
89
+ "SELECT COALESCE(SUM(input_tokens + cached_input_tokens + output_tokens), 0), COALESCE(SUM(cost_usd), 0),"
90
+ " COUNT(*) FROM assistant_calls WHERE kind IN ('plan', 'write', 'digest') AND called_at BETWEEN ? AND ?",
91
+ (start or "", finished),
92
+ ).fetchone()
93
+ return UpdateUsage(int(row[0]), round(float(row[1]), 4), int(row[2]), str(finished))
codetrail/bridge.py ADDED
@@ -0,0 +1,292 @@
1
+ """The bridge: questions from the page, answered read-only by the assistant, saved to the guide on request (7.3).
2
+
3
+ `POST /bridge/questions` streams newline-delimited JSON: `text` events while the assistant writes, then `done` with
4
+ the answer's id and its rendered HTML, or `error`. The whole answer is scanned for secrets before `done`; one that
5
+ holds something gitleaks flags ends with `error` and isn't kept (design section 15.5). Its usage is recorded.
6
+ Answers stay in memory for the session until saved, at most `max_session_answers` of them, and `GET /bridge/answers`
7
+ lists them for the page's Ask panel. One question runs at a time per session; closing the page
8
+ cancels it. Every route sits behind the security middleware.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import re
15
+ import time
16
+ import uuid
17
+ from collections.abc import AsyncIterator, Callable, Mapping
18
+ from dataclasses import dataclass, field
19
+ from datetime import UTC, datetime
20
+ from typing import Any
21
+
22
+ import anyio
23
+ from fastapi import APIRouter
24
+ from fastapi.responses import JSONResponse, Response, StreamingResponse
25
+ from markupsafe import escape
26
+ from pydantic import BaseModel, ConfigDict, Field
27
+
28
+ from codetrail.assistant import AnswerChunk, Assistant, AssistantError, QuestionRequest, Usage
29
+ from codetrail.assistant.usage import UsageLog
30
+ from codetrail.config import Paths, Price, ToolsSettings
31
+ from codetrail.database import connect
32
+ from codetrail.errors import CodetrailError
33
+ from codetrail.facts.store import FactStore
34
+ from codetrail.generate.validate import MARKER, ValidationContext, parse_rationale, validate_page
35
+ from codetrail.guide import PAGE_ID, GuideRepository, Page
36
+ from codetrail.lock import TargetBusy, target_lock
37
+ from codetrail.repo.secrets import SecretScanner
38
+ from codetrail.repo.source import SourceManifest
39
+ from codetrail.web.diagrams import available_diagrams
40
+ from codetrail.web.i18n import Language
41
+ from codetrail.web.render import render_body
42
+
43
+ PAGE_CONTEXT_CHARACTERS = 12_000
44
+
45
+
46
+ class Question(BaseModel):
47
+ model_config = ConfigDict(extra="forbid")
48
+ question: str = Field(min_length=1, max_length=20_000)
49
+ page_id: str | None = Field(default=None, max_length=200)
50
+
51
+
52
+ @dataclass
53
+ class Answer:
54
+ question: str
55
+ language: str
56
+ body: str
57
+ files_read: list[str]
58
+ commit: str
59
+ asked_at: str
60
+ cost_usd: float = 0.0
61
+
62
+
63
+ @dataclass
64
+ class BridgeState:
65
+ answers: dict[str, Answer] = field(default_factory=dict)
66
+ answering_since: float | None = None # claimed in the request itself, so simultaneous questions are refused
67
+ holder: str | None = None
68
+ abandon_after_seconds: float = 3600.0 # a slot held this long is treated as abandoned
69
+
70
+ def claim(self) -> str | None:
71
+ """Claims the one question slot and returns its token; a slot held well past a call's limit is abandoned."""
72
+ now = time.monotonic()
73
+ if self.answering_since is not None and now - self.answering_since < self.abandon_after_seconds:
74
+ return None
75
+ self.answering_since, self.holder = now, uuid.uuid4().hex
76
+ return self.holder
77
+
78
+ def release(self, token: str) -> None:
79
+ """Releases the slot only for the claim that still holds it."""
80
+ if self.holder == token:
81
+ self.answering_since, self.holder = None, None
82
+
83
+
84
+ def bridge_router(
85
+ paths: Paths,
86
+ name: str,
87
+ assistant_for: Callable[[], Assistant],
88
+ language_of: Callable[[], Language],
89
+ max_question_chars: int,
90
+ max_nodes: int,
91
+ tools: ToolsSettings,
92
+ prices: Mapping[str, Price],
93
+ call_timeout_seconds: float,
94
+ max_session_answers: int = 20,
95
+ ) -> APIRouter:
96
+ router = APIRouter()
97
+ state = BridgeState(abandon_after_seconds=call_timeout_seconds + 60)
98
+ scanner = SecretScanner(tools)
99
+ data = paths.target_data(name)
100
+ guide = GuideRepository(data / "guide")
101
+
102
+ @router.post("/bridge/questions")
103
+ async def ask(question: Question) -> Response:
104
+ language = language_of()
105
+ _ = language.translations.gettext
106
+ if len(question.question) > max_question_chars:
107
+ limit = _("Questions are limited to %(count)s characters.") % {"count": max_question_chars}
108
+ return JSONResponse({"error": limit}, 413)
109
+ manifest = SourceManifest.load(data / "source.json")
110
+ if manifest is None:
111
+ return JSONResponse({"error": _("Run codetrail update %(target)s first.") % {"target": name}}, 409)
112
+ if question.page_id and not PAGE_ID.fullmatch(question.page_id):
113
+ return JSONResponse({"error": _("That page doesn't exist.")}, 404)
114
+ page = guide.read_page(question.page_id) if question.page_id else None
115
+ if question.page_id and page is None:
116
+ return JSONResponse({"error": _("That page doesn't exist.")}, 404)
117
+ diagrams = _available_diagrams() # before the claim: nothing between claim and stream may fail
118
+ token = state.claim() # no await before this point, so two requests can't both pass
119
+ if token is None:
120
+ return JSONResponse({"error": _("Another question is still being answered.")}, 429)
121
+ request = QuestionRequest(
122
+ target=name,
123
+ question=question.question,
124
+ language=language.code,
125
+ page_title=page.title if page else "",
126
+ page_body=page.body[:PAGE_CONTEXT_CHARACTERS] if page else "",
127
+ page_facts=[str(fact.get("id")) for fact in (page.meta.get("facts") or [])] if page else [],
128
+ diagrams=diagrams,
129
+ )
130
+ return StreamingResponse(_stream(request, manifest.commit, token), media_type="application/x-ndjson")
131
+
132
+ async def _stream(request: QuestionRequest, commit: str, token: str) -> AsyncIterator[bytes]:
133
+ try:
134
+ _ = language_of().translations.gettext # inside the try: the claim is released whatever fails
135
+ parts: list[str] = []
136
+ try:
137
+ async for chunk in assistant_for().answer(request):
138
+ if chunk.text:
139
+ parts.append(chunk.text)
140
+ if not chunk.done: # streamed text; a buffered answer arrives with done, after the scan
141
+ yield _event({"type": "text", "text": chunk.text})
142
+ if chunk.done:
143
+ findings = await anyio.to_thread.run_sync(scanner.scan_text, "".join(parts))
144
+ cost = _record(chunk)
145
+ if findings:
146
+ yield _event(
147
+ {
148
+ "type": "error",
149
+ "message": _(
150
+ "The answer was withheld: it contains something that looks like a secret "
151
+ "(%(rule)s)."
152
+ )
153
+ % {"rule": findings[0].rule},
154
+ }
155
+ )
156
+ return
157
+ answer = Answer(request.question, request.language, "".join(parts), chunk.files_read, commit,
158
+ datetime.now(UTC).isoformat(timespec="seconds"), chunk.cost_usd) # fmt: skip
159
+ answer_id = uuid.uuid4().hex
160
+ state.answers[answer_id] = answer
161
+ while len(state.answers) > max_session_answers: # the oldest unsaved answer goes first
162
+ del state.answers[next(iter(state.answers))]
163
+ used = {"tokens": _tokens(chunk.usage), "cost_usd": round(cost, 4)}
164
+ yield _event(
165
+ {"type": "done", "answer_id": answer_id, "html": _html(answer.body), "usage": used}
166
+ )
167
+ except AssistantError as error:
168
+ yield _event({"type": "error", "message": str(error)})
169
+ except Exception as error: # details stay out of the page
170
+ failed = _("The answer failed (%(error)s).") % {"error": type(error).__name__}
171
+ yield _event({"type": "error", "message": failed})
172
+ finally:
173
+ state.release(token)
174
+
175
+ def _record(chunk: AnswerChunk) -> float:
176
+ connection = connect(data / "codetrail.db")
177
+ try:
178
+ return UsageLog(connection, prices).record("answer", chunk.usage) or chunk.cost_usd
179
+ finally:
180
+ connection.close()
181
+
182
+ def _available_diagrams() -> list[str]:
183
+ """The diagrams an answer may place; none if they can't be read, since the answer works without them."""
184
+ try:
185
+ connection = connect(data / "codetrail.db")
186
+ try:
187
+ return available_diagrams(FactStore(connection))
188
+ finally:
189
+ connection.close()
190
+ except Exception: # an optional extra: a locked or newer database mustn't stop the question
191
+ return []
192
+
193
+ def _html(body: str) -> str:
194
+ connection = connect(data / "codetrail.db")
195
+ try:
196
+ segments = render_body(body, FactStore(connection), max_nodes)
197
+ finally:
198
+ connection.close()
199
+ parts = []
200
+ for segment in segments:
201
+ if segment.kind == "diagram" and segment.diagram is not None: # drawn by the page's script, from facts
202
+ diagram = segment.diagram
203
+ alt = escape(", ".join(node.label for node in diagram.nodes))
204
+ parts.append(f'<figure class="diagram-block" lang="en" dir="ltr" data-alt="{alt}">'
205
+ f'<pre class="diagram">{escape(diagram.d2)}</pre></figure>') # fmt: skip
206
+ else:
207
+ parts.append(str(segment.html))
208
+ return "".join(parts)
209
+
210
+ @router.get("/bridge/answers")
211
+ def session_answers() -> Response:
212
+ """This session's unsaved answers, oldest first, rendered and sanitized, for the Ask panel (design 16.4)."""
213
+ listed = [
214
+ {"id": answer_id, "question": answer.question, "html": _html(answer.body), "asked_at": answer.asked_at}
215
+ for answer_id, answer in list(state.answers.items())
216
+ ]
217
+ return JSONResponse({"answers": listed}, headers={"Cache-Control": "no-store"})
218
+
219
+ @router.post("/bridge/answers/{answer_id}/save")
220
+ def save(answer_id: str) -> Response:
221
+ _ = language_of().translations.gettext
222
+ answer = state.answers.get(answer_id)
223
+ if answer is None:
224
+ return JSONResponse({"error": _("That answer is gone; ask again.")}, 404)
225
+ try:
226
+ with target_lock(paths, name):
227
+ page_id = _save(answer, data, guide, tools)
228
+ except TargetBusy:
229
+ return JSONResponse({"error": _("An update is running; save again when it finishes.")}, 409)
230
+ except CodetrailError as error:
231
+ return JSONResponse({"error": str(error)}, 409)
232
+ del state.answers[answer_id]
233
+ return JSONResponse({"page_id": page_id})
234
+
235
+ return router
236
+
237
+
238
+ def _tokens(usage: Usage) -> int:
239
+ return usage.input_tokens + usage.cached_input_tokens + usage.output_tokens
240
+
241
+
242
+ def _save(answer: Answer, data: Any, guide: GuideRepository, tools: ToolsSettings) -> str:
243
+ guide.ensure()
244
+ manifest = SourceManifest.load(data / "source.json")
245
+ if manifest is None:
246
+ raise AssistantError("The sources are missing; run an update.")
247
+ connection = connect(data / "codetrail.db")
248
+ try:
249
+ store = FactStore(connection)
250
+ context = ValidationContext(data / "source", manifest, store, data / "mirror.git", SecretScanner(tools))
251
+ body = demote_unverified(answer.body, context)
252
+ finally:
253
+ connection.close()
254
+ slug = re.sub(r"[^a-z0-9]+", "-", answer.question.lower()).strip("-")[:50].strip("-") or "answer"
255
+ page_id = f"answers/{datetime.now(UTC).strftime('%Y-%m-%d')}-{slug}-{uuid.uuid4().hex[:6]}"
256
+ meta = {
257
+ "id": page_id, "kind": "answer", "title": answer.question[:120], "question": answer.question,
258
+ "language": answer.language, "asked_at": answer.asked_at, "built_at": answer.commit,
259
+ "files": [{"path": path, "blob": manifest.files[path]} for path in answer.files_read if path in manifest.files],
260
+ } # fmt: skip
261
+ if guide.has_uncommitted_changes():
262
+ raise AssistantError("The guide has uncommitted edits; commit or discard them first.")
263
+ try:
264
+ guide.write_page(Page(page_id, meta, body))
265
+ guide.commit(f"Save the answer to: {answer.question[:60]}")
266
+ except BaseException:
267
+ guide.discard() # never leave the guide dirty, or every update would refuse to start
268
+ raise
269
+ return page_id
270
+
271
+
272
+ def demote_unverified(body: str, context: ValidationContext) -> str:
273
+ """Turns every documented block whose quote can't be verified into an inferred one (design section 7.3)."""
274
+ lines = body.splitlines()
275
+ for block in parse_rationale(body):
276
+ if block.kind != "documented":
277
+ continue
278
+ snippet = f"> [!documented] {block.citation or ''}\n" + "\n".join(
279
+ f"> {line}" for line in block.text.splitlines()
280
+ )
281
+ if validate_page(snippet, None, context):
282
+ index = block.line - 1
283
+ if MARKER.match(lines[index]):
284
+ lines[index] = "> [!inferred]"
285
+ return "\n".join(lines)
286
+
287
+
288
+ def _event(payload: dict[str, Any]) -> bytes:
289
+ return (json.dumps(payload, ensure_ascii=False) + "\n").encode()
290
+
291
+
292
+ __all__ = ["AnswerChunk", "bridge_router", "demote_unverified"]