agent-debrief 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.
Files changed (108) hide show
  1. agent_debrief/__init__.py +3 -0
  2. agent_debrief/__main__.py +18 -0
  3. agent_debrief/ai/__init__.py +1 -0
  4. agent_debrief/ai/orchestrator.py +57 -0
  5. agent_debrief/ai/prompts/__init__.py +1 -0
  6. agent_debrief/ai/prompts/workflows.py +66 -0
  7. agent_debrief/ai/providers/__init__.py +1 -0
  8. agent_debrief/ai/providers/anthropic.py +60 -0
  9. agent_debrief/ai/providers/base.py +56 -0
  10. agent_debrief/ai/providers/openai_compat.py +87 -0
  11. agent_debrief/ai/providers/registry.py +32 -0
  12. agent_debrief/ai/structured_outputs/__init__.py +1 -0
  13. agent_debrief/ai/structured_outputs/schemas.py +73 -0
  14. agent_debrief/analysis/__init__.py +1 -0
  15. agent_debrief/analysis/architecture_analyzer.py +105 -0
  16. agent_debrief/analysis/ast_analyzer.py +319 -0
  17. agent_debrief/analysis/diff_analyzer.py +360 -0
  18. agent_debrief/analysis/evidence_engine.py +158 -0
  19. agent_debrief/analysis/git_utils.py +213 -0
  20. agent_debrief/analysis/relationship_analyzer.py +299 -0
  21. agent_debrief/analysis/risk_analyzer.py +142 -0
  22. agent_debrief/analysis/symbol_diff.py +64 -0
  23. agent_debrief/analysis/symbol_indexer.py +141 -0
  24. agent_debrief/analysis/task_analyzer.py +178 -0
  25. agent_debrief/analysis/test_results.py +105 -0
  26. agent_debrief/api/__init__.py +1 -0
  27. agent_debrief/api/app.py +105 -0
  28. agent_debrief/api/routes/__init__.py +1 -0
  29. agent_debrief/api/routes/core.py +88 -0
  30. agent_debrief/api/routes/stream.py +78 -0
  31. agent_debrief/api/routes/tasks.py +132 -0
  32. agent_debrief/api/security.py +108 -0
  33. agent_debrief/cli/__init__.py +1 -0
  34. agent_debrief/cli/app.py +454 -0
  35. agent_debrief/cli/hook.py +50 -0
  36. agent_debrief/communication/__init__.py +1 -0
  37. agent_debrief/communication/chat.py +260 -0
  38. agent_debrief/communication/common.py +76 -0
  39. agent_debrief/communication/concepts_catalog.py +389 -0
  40. agent_debrief/communication/flow.py +277 -0
  41. agent_debrief/communication/retrieval.py +134 -0
  42. agent_debrief/communication/summary.py +198 -0
  43. agent_debrief/communication/tutor.py +110 -0
  44. agent_debrief/communication/voice.py +177 -0
  45. agent_debrief/config/__init__.py +14 -0
  46. agent_debrief/config/secrets.py +66 -0
  47. agent_debrief/config/settings.py +92 -0
  48. agent_debrief/database/__init__.py +3 -0
  49. agent_debrief/database/connection.py +121 -0
  50. agent_debrief/database/migrations/0001_initial.sql +226 -0
  51. agent_debrief/demo.py +300 -0
  52. agent_debrief/events/__init__.py +1 -0
  53. agent_debrief/events/correlator.py +128 -0
  54. agent_debrief/events/deduplicator.py +26 -0
  55. agent_debrief/events/ingestion.py +211 -0
  56. agent_debrief/events/normalizer.py +213 -0
  57. agent_debrief/events/redaction.py +107 -0
  58. agent_debrief/events/schemas.py +44 -0
  59. agent_debrief/integrations/__init__.py +1 -0
  60. agent_debrief/integrations/base.py +41 -0
  61. agent_debrief/integrations/claude/__init__.py +1 -0
  62. agent_debrief/integrations/claude/adapter.py +105 -0
  63. agent_debrief/integrations/claude/hooks.py +186 -0
  64. agent_debrief/integrations/claude/transcript.py +211 -0
  65. agent_debrief/integrations/codex/__init__.py +0 -0
  66. agent_debrief/integrations/codex/adapter.py +34 -0
  67. agent_debrief/integrations/cursor/__init__.py +0 -0
  68. agent_debrief/integrations/cursor/adapter.py +32 -0
  69. agent_debrief/integrations/registry.py +22 -0
  70. agent_debrief/main.py +25 -0
  71. agent_debrief/models/__init__.py +1 -0
  72. agent_debrief/models/api.py +515 -0
  73. agent_debrief/models/common.py +103 -0
  74. agent_debrief/models/understanding.py +99 -0
  75. agent_debrief/paths.py +60 -0
  76. agent_debrief/repositories/__init__.py +37 -0
  77. agent_debrief/repositories/analysis.py +137 -0
  78. agent_debrief/repositories/base.py +17 -0
  79. agent_debrief/repositories/events.py +70 -0
  80. agent_debrief/repositories/explanations.py +117 -0
  81. agent_debrief/repositories/jobs.py +106 -0
  82. agent_debrief/repositories/sessions.py +75 -0
  83. agent_debrief/repositories/tasks.py +121 -0
  84. agent_debrief/repositories/workspaces.py +37 -0
  85. agent_debrief/services/__init__.py +1 -0
  86. agent_debrief/services/agent_service.py +70 -0
  87. agent_debrief/services/analysis_runner.py +62 -0
  88. agent_debrief/services/analysis_service.py +337 -0
  89. agent_debrief/services/errors.py +40 -0
  90. agent_debrief/services/settings_service.py +76 -0
  91. agent_debrief/services/task_service.py +445 -0
  92. agent_debrief/understanding/__init__.py +1 -0
  93. agent_debrief/understanding/context_builder.py +128 -0
  94. agent_debrief/understanding/knowledge_graph.py +75 -0
  95. agent_debrief/understanding/task_understanding.py +91 -0
  96. agent_debrief/util.py +65 -0
  97. agent_debrief/web_dist/assets/FlowTab-BnuhLJ6X.css +1 -0
  98. agent_debrief/web_dist/assets/FlowTab-DxkwVXsJ.js +23 -0
  99. agent_debrief/web_dist/assets/index-97ryKr0Z.css +1 -0
  100. agent_debrief/web_dist/assets/index-DhBchsMz.js +71 -0
  101. agent_debrief/web_dist/index.html +19 -0
  102. agent_debrief/workers/__init__.py +1 -0
  103. agent_debrief/workers/worker.py +72 -0
  104. agent_debrief-0.1.0.dist-info/METADATA +169 -0
  105. agent_debrief-0.1.0.dist-info/RECORD +108 -0
  106. agent_debrief-0.1.0.dist-info/WHEEL +4 -0
  107. agent_debrief-0.1.0.dist-info/entry_points.txt +3 -0
  108. agent_debrief-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,3 @@
1
+ """Agent Debrief — understand what your AI coding agent actually built."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,18 @@
1
+ """``python -m agent_debrief`` — also the entry point Claude Code hooks use."""
2
+
3
+ import sys
4
+
5
+
6
+ def _entry() -> None:
7
+ # Fast path: hooks run on every tool call, so avoid importing the CLI/web stack for them.
8
+ if len(sys.argv) >= 3 and sys.argv[1] == "hook":
9
+ from agent_debrief.cli.hook import run_hook
10
+
11
+ sys.exit(run_hook(sys.argv[2]))
12
+ from agent_debrief.cli.app import main
13
+
14
+ main()
15
+
16
+
17
+ if __name__ == "__main__":
18
+ _entry()
@@ -0,0 +1 @@
1
+ """LLM orchestration: providers, prompts and validated structured outputs."""
@@ -0,0 +1,57 @@
1
+ """Runs one LLM workflow: build the call, validate the structured result, retry with feedback."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any, TypeVar
7
+
8
+ from pydantic import BaseModel, ValidationError
9
+
10
+ from ..config.settings import AppConfig
11
+ from .providers.base import LLMError, LLMProvider, run_sync
12
+ from .providers.registry import build_provider
13
+
14
+ log = logging.getLogger("agent_debrief.ai")
15
+ M = TypeVar("M", bound=BaseModel)
16
+ MAX_ATTEMPTS = 3
17
+
18
+
19
+ class Orchestrator:
20
+ def __init__(self, config: AppConfig, provider: LLMProvider | None = None) -> None:
21
+ self.config = config
22
+ self.provider = provider if provider is not None else build_provider(config)
23
+
24
+ @property
25
+ def available(self) -> bool:
26
+ return self.provider is not None
27
+
28
+ @property
29
+ def provider_name(self) -> str | None:
30
+ return self.provider.name if self.provider else None
31
+
32
+ @property
33
+ def model_name(self) -> str | None:
34
+ return self.provider.model if self.provider else None
35
+
36
+ def run(self, workflow: str, system_prompt: str, context: dict[str, Any], model_cls: type[M]) -> M:
37
+ """Return a validated ``model_cls``. Raises :class:`LLMError` if the model cannot comply."""
38
+ if self.provider is None:
39
+ raise LLMError("No LLM provider is configured")
40
+ schema = model_cls.model_json_schema()
41
+ payload = dict(context)
42
+ last_problem = ""
43
+ for attempt in range(1, MAX_ATTEMPTS + 1):
44
+ if last_problem:
45
+ payload["previous_attempt_error"] = (
46
+ "Your previous answer was rejected: " + last_problem
47
+ + " Return a corrected answer that follows the schema."
48
+ )
49
+ try:
50
+ raw = run_sync(self.provider.generate_structured(system_prompt, payload, schema))
51
+ return model_cls.model_validate(raw)
52
+ except ValidationError as exc:
53
+ last_problem = "; ".join(f"{'.'.join(map(str, e['loc']))}: {e['msg']}" for e in exc.errors()[:5])
54
+ log.warning("%s attempt %d returned invalid structure: %s", workflow, attempt, last_problem)
55
+ except LLMError:
56
+ raise
57
+ raise LLMError(f"The model did not produce a valid {workflow} answer after {MAX_ATTEMPTS} attempts ({last_problem})")
@@ -0,0 +1 @@
1
+ """Prompt templates (one workflow = one focused prompt)."""
@@ -0,0 +1,66 @@
1
+ """One focused system prompt per workflow (never one giant prompt)."""
2
+
3
+ RULES = """You are part of Agent Debrief, a tool that helps a developer understand what an AI coding \
4
+ agent implemented.
5
+ Ground rules — they matter more than style:
6
+ 1. Use ONLY the evidence in the provided JSON (`evidence`, `facts`, `changes`, `symbols`, `flow`...). \
7
+ Never invent files, functions, endpoints, behaviour or test results.
8
+ 2. Every claim must list the `evidence_ids` that support it, copied exactly from the input. If you cannot \
9
+ support a statement with evidence, leave `evidence_ids` empty rather than guessing.
10
+ 3. Put file names, function names and code identifiers in `backticks`, and only mention ones that appear \
11
+ in the input.
12
+ 4. Keep the three kinds of statement distinct: what the code verifiably does, what the agent said it did, \
13
+ and your own interpretation. Interpretation must be phrased as such ("this suggests…").
14
+ 5. If the evidence is incomplete, say so plainly in the limitations/notes field.
15
+ 6. Be concise. The reader is a developer who did not write this code."""
16
+
17
+ LEVEL_GUIDE = {
18
+ "beginner": "Reader level: BEGINNER. Avoid jargon; define any technical term in a few words; use an everyday analogy if it helps; short sentences.",
19
+ "developer": "Reader level: DEVELOPER. Assume general programming knowledge but not familiarity with this codebase; be precise and brief.",
20
+ "advanced": "Reader level: ADVANCED. Skip basics; focus on design trade-offs, edge cases, failure modes and security/performance implications.",
21
+ }
22
+
23
+ TASK_SUMMARIZER = RULES + """
24
+
25
+ Workflow: TASK SUMMARIZER. Produce at most FIVE bullets (<= 25 words each) that let the reader grasp the \
26
+ completed task in 30 seconds: what was asked, what was actually built, how it works at a high level, how \
27
+ it was tested, and what to watch out for. Put deeper technical detail in each bullet's `detail`. List \
28
+ genuinely unfinished work in `remaining_work` only if the evidence (agent message, failing tests, missing \
29
+ tests) shows it. Never claim the task is complete or correct — only report what the evidence shows."""
30
+
31
+ CHANGE_EXPLAINER = RULES + """
32
+
33
+ Workflow: CHANGE EXPLAINER. Explain HOW the implemented change works as 3-6 short claims, in execution \
34
+ order (request in → processing → data access → response). Each claim must cite the code-range or diff \
35
+ evidence it is based on. Mention error handling that exists, and note when none was found."""
36
+
37
+ FLOW_GENERATOR = RULES + """
38
+
39
+ Workflow: FLOW ANNOTATOR. The application flow graph is already built from static analysis; you may NOT \
40
+ add, remove or reconnect nodes or edges. For the nodes provided, write: a one-sentence `description`, \
41
+ `why_it_exists` (its role in the flow, based on who calls it and what it does), `inputs`, `outputs` and \
42
+ `error_scenarios` (only those visible in the code: raised exceptions, status codes, guarded branches). \
43
+ Use node ids exactly as given. Skip a node if you have nothing evidence-based to add."""
44
+
45
+ CONCEPT_TUTOR = RULES + """
46
+
47
+ Workflow: TUTOR. Teach each provided concept using THIS implementation. For each concept: \
48
+ `simple_explanation` (the idea, at the reader level), `in_this_implementation` (point at the exact \
49
+ functions/lines from the evidence and say what role the concept plays there), `why_needed`, \
50
+ `failure_cases` (what goes wrong if it is missing or misused here), and a short `knowledge_check` \
51
+ question with its answer. Do not add concepts that were not provided."""
52
+
53
+ VOICE_ASSISTANT = RULES + """
54
+
55
+ Workflow: VOICE EXPLAINER. Write what a friendly colleague would SAY out loud: short, natural spoken \
56
+ sentences (max ~18 words each), no markdown, no bullet symbols, no code blocks, spell out symbols \
57
+ ("slash login" rather than "/login" only if it reads better). 5-10 sentences. Do not read file paths \
58
+ character by character — name the function or file once and move on."""
59
+
60
+ QA_ASSISTANT = RULES + """
61
+
62
+ Workflow: QUESTION ANSWERING. Answer the developer's question about this specific task.
63
+ Structure: (1) `direct_answer` first — the answer in 1-4 sentences; (2) `supporting_evidence_ids` — the \
64
+ evidence that backs it; (3) `flow_explanation` — how the relevant code path works, if useful; (4) \
65
+ `limitations` — what the evidence does not tell you (e.g. the agent's reasoning is only known if it said \
66
+ so). If the evidence does not contain the answer, say so in `direct_answer` instead of guessing."""
@@ -0,0 +1 @@
1
+ """LLM provider implementations."""
@@ -0,0 +1,60 @@
1
+ """Anthropic Messages API (structured output via a forced tool call)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from .base import LLMError, LLMProvider, scrub
11
+
12
+ DEFAULT_BASE_URL = "https://api.anthropic.com"
13
+ API_VERSION = "2023-06-01"
14
+
15
+
16
+ class AnthropicProvider(LLMProvider):
17
+ name = "anthropic"
18
+
19
+ def __init__(self, api_key: str, model: str, base_url: str | None = None,
20
+ max_tokens: int = 4096, timeout: float = 120) -> None:
21
+ self.api_key = api_key
22
+ self.model = model
23
+ self.base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
24
+ self.max_tokens = max_tokens
25
+ self.timeout = timeout
26
+
27
+ async def generate_structured(
28
+ self, system_prompt: str, context: dict[str, Any], response_schema: dict[str, Any]
29
+ ) -> dict[str, Any]:
30
+ body = {
31
+ "model": self.model,
32
+ "max_tokens": self.max_tokens,
33
+ "system": system_prompt,
34
+ "messages": [{"role": "user", "content": json.dumps(context, ensure_ascii=False)}],
35
+ "tools": [{
36
+ "name": "respond",
37
+ "description": "Return your answer. Fill every field using only the supplied evidence.",
38
+ "input_schema": response_schema,
39
+ }],
40
+ "tool_choice": {"type": "tool", "name": "respond"},
41
+ }
42
+ headers = {"x-api-key": self.api_key, "anthropic-version": API_VERSION, "content-type": "application/json"}
43
+ try:
44
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
45
+ response = await client.post(f"{self.base_url}/v1/messages", headers=headers, json=body)
46
+ except httpx.HTTPError as exc:
47
+ raise LLMError(scrub(f"Could not reach Anthropic: {exc}", self.api_key)) from exc
48
+ if response.status_code >= 400:
49
+ raise LLMError(scrub(f"Anthropic returned {response.status_code}: {_error_message(response)}", self.api_key))
50
+ for block in response.json().get("content", []):
51
+ if block.get("type") == "tool_use" and isinstance(block.get("input"), dict):
52
+ return block["input"]
53
+ raise LLMError("Anthropic returned no structured answer")
54
+
55
+
56
+ def _error_message(response: httpx.Response) -> str:
57
+ try:
58
+ return str(response.json().get("error", {}).get("message") or response.text[:200])
59
+ except ValueError:
60
+ return response.text[:200]
@@ -0,0 +1,56 @@
1
+ """LLM provider abstraction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import threading
7
+ from collections.abc import Coroutine
8
+ from typing import Any, TypeVar
9
+
10
+ T = TypeVar("T")
11
+
12
+
13
+ class LLMError(RuntimeError):
14
+ """A provider call failed or returned something unusable. Messages never contain API keys."""
15
+
16
+
17
+ class LLMProvider:
18
+ """Generate a JSON object that conforms to ``response_schema``."""
19
+
20
+ name: str = "base"
21
+ model: str = ""
22
+
23
+ async def generate_structured(
24
+ self, system_prompt: str, context: dict[str, Any], response_schema: dict[str, Any]
25
+ ) -> dict[str, Any]:
26
+ raise NotImplementedError
27
+
28
+
29
+ def run_sync(coro: Coroutine[Any, Any, T]) -> T:
30
+ """Run a coroutine from synchronous code, even when called from inside a running event loop."""
31
+ try:
32
+ asyncio.get_running_loop()
33
+ except RuntimeError:
34
+ return asyncio.run(coro)
35
+ box: dict[str, Any] = {}
36
+
37
+ def runner() -> None:
38
+ try:
39
+ box["value"] = asyncio.run(coro)
40
+ except BaseException as exc: # noqa: BLE001
41
+ box["error"] = exc
42
+
43
+ thread = threading.Thread(target=runner, daemon=True)
44
+ thread.start()
45
+ thread.join()
46
+ if "error" in box:
47
+ raise box["error"]
48
+ return box["value"]
49
+
50
+
51
+ def scrub(message: str, *secrets: str | None) -> str:
52
+ """Remove credentials from an error message before it can be shown or logged."""
53
+ for secret in secrets:
54
+ if secret and len(secret) > 6:
55
+ message = message.replace(secret, "[REDACTED]")
56
+ return message[:500]
@@ -0,0 +1,87 @@
1
+ """OpenAI Chat Completions — also any OpenAI-compatible server (Ollama, vLLM, OpenRouter, LM Studio…)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ from .base import LLMError, LLMProvider, scrub
12
+
13
+ DEFAULT_BASE_URL = "https://api.openai.com/v1"
14
+
15
+
16
+ class OpenAICompatibleProvider(LLMProvider):
17
+ name = "openai"
18
+
19
+ def __init__(self, api_key: str | None, model: str, base_url: str | None = None,
20
+ max_tokens: int = 4096, timeout: float = 120, name: str = "openai") -> None:
21
+ self.api_key = api_key
22
+ self.model = model
23
+ self.base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
24
+ self.max_tokens = max_tokens
25
+ self.timeout = timeout
26
+ self.name = name
27
+
28
+ async def generate_structured(
29
+ self, system_prompt: str, context: dict[str, Any], response_schema: dict[str, Any]
30
+ ) -> dict[str, Any]:
31
+ system = (
32
+ system_prompt
33
+ + "\n\nReply with ONE JSON object that matches this JSON Schema exactly, and nothing else:\n"
34
+ + json.dumps(response_schema)
35
+ )
36
+ messages = [{"role": "system", "content": system},
37
+ {"role": "user", "content": json.dumps(context, ensure_ascii=False)}]
38
+ headers = {"content-type": "application/json"}
39
+ if self.api_key:
40
+ headers["authorization"] = f"Bearer {self.api_key}"
41
+
42
+ formats: list[dict[str, Any] | None] = [
43
+ {"type": "json_schema", "json_schema": {"name": "response", "schema": response_schema, "strict": False}},
44
+ {"type": "json_object"},
45
+ None,
46
+ ]
47
+ last_error = "no response"
48
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
49
+ for fmt in formats:
50
+ body: dict[str, Any] = {"model": self.model, "messages": messages, "max_tokens": self.max_tokens}
51
+ if fmt:
52
+ body["response_format"] = fmt
53
+ try:
54
+ response = await client.post(f"{self.base_url}/chat/completions", headers=headers, json=body)
55
+ except httpx.HTTPError as exc:
56
+ raise LLMError(scrub(f"Could not reach the LLM endpoint: {exc}", self.api_key)) from exc
57
+ if response.status_code == 400 and fmt is not None:
58
+ last_error = response.text[:200]
59
+ continue # this server does not support that response_format — try a simpler one
60
+ if response.status_code >= 400:
61
+ raise LLMError(scrub(f"LLM endpoint returned {response.status_code}: {response.text[:200]}", self.api_key))
62
+ try:
63
+ text = response.json()["choices"][0]["message"]["content"]
64
+ except (KeyError, IndexError, ValueError, TypeError) as exc:
65
+ raise LLMError("The LLM endpoint returned an unexpected response shape") from exc
66
+ return _parse_json(text)
67
+ raise LLMError(scrub(f"The LLM endpoint rejected every response format: {last_error}", self.api_key))
68
+
69
+
70
+ def _parse_json(text: str) -> dict[str, Any]:
71
+ text = (text or "").strip()
72
+ fenced = re.match(r"^```(?:json)?\s*(.*?)\s*```$", text, re.S)
73
+ if fenced:
74
+ text = fenced.group(1)
75
+ try:
76
+ data = json.loads(text)
77
+ except ValueError:
78
+ match = re.search(r"\{.*\}", text, re.S)
79
+ if not match:
80
+ raise LLMError("The model did not return JSON") from None
81
+ try:
82
+ data = json.loads(match.group(0))
83
+ except ValueError as exc:
84
+ raise LLMError("The model returned malformed JSON") from exc
85
+ if not isinstance(data, dict):
86
+ raise LLMError("The model returned JSON that is not an object")
87
+ return data
@@ -0,0 +1,32 @@
1
+ """Build the configured provider (or ``None`` when running offline)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ...config import resolve_api_key
6
+ from ...config.settings import DEFAULT_MODELS, AppConfig
7
+ from .anthropic import AnthropicProvider
8
+ from .base import LLMProvider
9
+ from .openai_compat import OpenAICompatibleProvider
10
+
11
+
12
+ def build_provider(config: AppConfig) -> LLMProvider | None:
13
+ llm = config.llm
14
+ if llm.provider == "none":
15
+ return None
16
+ key, _ = resolve_api_key(llm)
17
+ model = llm.model or DEFAULT_MODELS.get(llm.provider) or ""
18
+ if llm.provider == "anthropic":
19
+ if not key or not model:
20
+ return None
21
+ return AnthropicProvider(key, model, llm.base_url, llm.max_output_tokens, llm.timeout_seconds)
22
+ if llm.provider in {"openai", "openai_compatible"}:
23
+ # Local OpenAI-compatible servers often need no key, but hosted OpenAI always does.
24
+ if (llm.provider == "openai" and not key) or not model:
25
+ return None
26
+ return OpenAICompatibleProvider(key, model, llm.base_url, llm.max_output_tokens, llm.timeout_seconds,
27
+ name=llm.provider)
28
+ return None
29
+
30
+
31
+ def llm_configured(config: AppConfig) -> bool:
32
+ return build_provider(config) is not None
@@ -0,0 +1 @@
1
+ """Pydantic schemas the LLM must answer with."""
@@ -0,0 +1,73 @@
1
+ """What each LLM workflow must return. Validated with Pydantic; invalid output triggers a retry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class ClaimLLM(BaseModel):
9
+ text: str = Field(min_length=3, max_length=600)
10
+ evidence_ids: list[str] = Field(default_factory=list)
11
+
12
+
13
+ class BulletLLM(BaseModel):
14
+ text: str = Field(min_length=3, max_length=300)
15
+ detail: str | None = Field(default=None, max_length=1200)
16
+ evidence_ids: list[str] = Field(default_factory=list)
17
+
18
+
19
+ class SummaryLLM(BaseModel):
20
+ bullets: list[BulletLLM] = Field(min_length=1, max_length=5)
21
+ remaining_work: list[ClaimLLM] = Field(default_factory=list, max_length=6)
22
+
23
+
24
+ class ChangeExplainLLM(BaseModel):
25
+ how_it_works: list[ClaimLLM] = Field(min_length=1, max_length=8)
26
+
27
+
28
+ class NodeAnnotation(BaseModel):
29
+ node_id: str
30
+ description: str | None = Field(default=None, max_length=500)
31
+ why_it_exists: str | None = Field(default=None, max_length=400)
32
+ inputs: list[str] = Field(default_factory=list, max_length=8)
33
+ outputs: list[str] = Field(default_factory=list, max_length=8)
34
+ error_scenarios: list[str] = Field(default_factory=list, max_length=8)
35
+ evidence_ids: list[str] = Field(default_factory=list)
36
+
37
+
38
+ class FlowAnnotationsLLM(BaseModel):
39
+ nodes: list[NodeAnnotation] = Field(default_factory=list, max_length=60)
40
+
41
+
42
+ class KnowledgeCheckLLM(BaseModel):
43
+ question: str
44
+ options: list[str] = Field(default_factory=list, max_length=5)
45
+ answer: str
46
+ explanation: str
47
+
48
+
49
+ class LessonLLM(BaseModel):
50
+ concept_id: str
51
+ simple_explanation: str = Field(max_length=900)
52
+ in_this_implementation: str = Field(max_length=900)
53
+ why_needed: str = Field(max_length=500)
54
+ failure_cases: list[str] = Field(default_factory=list, max_length=5)
55
+ knowledge_check: KnowledgeCheckLLM | None = None
56
+ evidence_ids: list[str] = Field(default_factory=list)
57
+
58
+
59
+ class TutorLLM(BaseModel):
60
+ lessons: list[LessonLLM] = Field(default_factory=list, max_length=10)
61
+
62
+
63
+ class VoiceLLM(BaseModel):
64
+ sentences: list[str] = Field(min_length=1, max_length=14)
65
+ evidence_ids: list[str] = Field(default_factory=list)
66
+
67
+
68
+ class ChatLLM(BaseModel):
69
+ direct_answer: str = Field(min_length=1, max_length=1500)
70
+ supporting_evidence_ids: list[str] = Field(default_factory=list)
71
+ flow_explanation: str | None = Field(default=None, max_length=1500)
72
+ limitations: str | None = Field(default=None, max_length=600)
73
+ suggested_questions: list[str] = Field(default_factory=list, max_length=4)
@@ -0,0 +1 @@
1
+ """Code and session analysis. The understanding engine's deterministic core."""
@@ -0,0 +1,105 @@
1
+ """Evidence-based architecture layering.
2
+
3
+ Each symbol is placed in a layer with an explicit *basis* and confidence. Only decorators (a route,
4
+ a background task) are strong enough to be called ``verified``; naming/path/import conventions are
5
+ ``inferred`` and say so. When nothing fits, the layer is ``other`` — uncertainty is allowed.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from pathlib import PurePosixPath
12
+
13
+ from ..models.understanding import ArchitectureLayer
14
+ from .ast_analyzer import SymbolInfo
15
+ from .relationship_analyzer import Relationship, classify_external
16
+ from .symbol_indexer import SymbolIndex
17
+
18
+ PATH_TOKENS: dict[str, set[str]] = {
19
+ "api": {"routes", "route", "router", "routers", "api", "endpoints", "views", "controllers", "controller", "handlers", "resources"},
20
+ "service": {"service", "services", "usecases", "use_cases", "domain", "logic", "managers", "workflows"},
21
+ "database": {"repository", "repositories", "repo", "repos", "db", "database", "dao", "models", "orm", "crud", "queries", "storage", "store", "migrations"},
22
+ "external": {"clients", "client", "integrations", "adapters", "gateways", "providers", "connectors"},
23
+ "background": {"tasks", "jobs", "workers", "celery", "cron", "scheduler"},
24
+ }
25
+ CLASS_SUFFIX = {"Service": "service", "Repository": "database", "Repo": "database", "Controller": "api",
26
+ "Handler": "api", "Client": "external", "Gateway": "external", "Worker": "background"}
27
+ BACKGROUND_DECORATORS = ("shared_task", ".task", "celery", "repeat_every", "scheduled", "cron", "periodic_task")
28
+ FRONTEND_EXT = {".tsx", ".jsx", ".vue", ".svelte", ".html", ".css", ".scss", ".js", ".ts"}
29
+
30
+
31
+ @dataclass
32
+ class Placement:
33
+ layer: str
34
+ confidence: str # high | medium | low
35
+ verification: str # verified | inferred
36
+ basis: list[str] = field(default_factory=list)
37
+
38
+
39
+ def classify_symbols(index: SymbolIndex, rels: list[Relationship]) -> dict[str, Placement]:
40
+ """qualified_name -> placement for every indexed function/method/class in the changed files."""
41
+ db_users: set[str] = set()
42
+ external_users: set[str] = set()
43
+ for rel in rels:
44
+ if rel.kind == "uses" and rel.target.startswith("external:"):
45
+ kind = classify_external(rel.target)
46
+ (db_users if kind == "database" else external_users if kind == "external" else set()).add(rel.source)
47
+
48
+ placements: dict[str, Placement] = {}
49
+ for rel_file, module in index.modules.items(): # changed files *and* the context they import
50
+ for sym in module.symbols:
51
+ placements[sym.qualified_name] = _place(sym, rel_file, db_users, external_users)
52
+ return placements
53
+
54
+
55
+ def _place(sym: SymbolInfo, rel_file: str, db_users: set[str], external_users: set[str]) -> Placement:
56
+ if sym.routes:
57
+ route = sym.routes[0]
58
+ return Placement("api", "high", "verified", [f"route decorator for {route['method']} {route['path']}"])
59
+ if any(any(tok in d for tok in BACKGROUND_DECORATORS) for d in sym.decorators):
60
+ return Placement("background", "high", "verified", [f"decorator {sym.decorators[0]}"])
61
+ if sym.qualified_name in db_users:
62
+ return Placement("database", "medium", "inferred", ["calls into a database library / looks like a DB call"])
63
+ if sym.qualified_name in external_users:
64
+ return Placement("external", "medium", "inferred", ["calls into an external service client library"])
65
+ tokens = {t.lower() for t in PurePosixPath(rel_file).with_suffix("").parts}
66
+ for layer, words in PATH_TOKENS.items():
67
+ hit = tokens & words
68
+ if hit:
69
+ return Placement(layer, "medium", "inferred", [f"file path contains '{sorted(hit)[0]}'"])
70
+ for suffix, layer in CLASS_SUFFIX.items():
71
+ if sym.name.endswith(suffix) or (sym.parent or "").endswith(suffix):
72
+ return Placement(layer, "low", "inferred", [f"name ends with '{suffix}'"])
73
+ return Placement("other", "low", "inferred", ["no layer convention matched"])
74
+
75
+
76
+ def build_architecture(
77
+ placements: dict[str, Placement], non_python_files: list[str], only: set[str] | None = None
78
+ ) -> list[ArchitectureLayer]:
79
+ """Summarise layers for the symbols in ``only`` (the changed ones); context symbols are skipped."""
80
+ grouped: dict[str, ArchitectureLayer] = {}
81
+ for qname, place in placements.items():
82
+ if place.layer == "other" or (only is not None and qname not in only):
83
+ continue
84
+ layer = grouped.setdefault(
85
+ place.layer,
86
+ ArchitectureLayer(layer=place.layer, confidence=place.confidence, # type: ignore[arg-type]
87
+ verification_status=place.verification), # type: ignore[arg-type]
88
+ )
89
+ layer.symbols.append(qname)
90
+ for b in place.basis:
91
+ if b not in layer.basis and len(layer.basis) < 4:
92
+ layer.basis.append(b)
93
+ order = {"low": 0, "medium": 1, "high": 2}
94
+ if order[place.confidence] < order[layer.confidence]:
95
+ layer.confidence = place.confidence # type: ignore[assignment]
96
+ if place.verification == "inferred":
97
+ layer.verification_status = "inferred"
98
+ frontend = [f for f in non_python_files if PurePosixPath(f).suffix.lower() in FRONTEND_EXT]
99
+ if frontend:
100
+ grouped["frontend"] = ArchitectureLayer(
101
+ layer="frontend", files=sorted(frontend), confidence="medium", verification_status="inferred",
102
+ basis=["changed files have front-end extensions (" + ", ".join(sorted({PurePosixPath(f).suffix for f in frontend})) + ")"],
103
+ )
104
+ order_names = ["frontend", "api", "service", "database", "external", "background"]
105
+ return sorted(grouped.values(), key=lambda a: order_names.index(a.layer) if a.layer in order_names else 99)