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.
- agent_debrief/__init__.py +3 -0
- agent_debrief/__main__.py +18 -0
- agent_debrief/ai/__init__.py +1 -0
- agent_debrief/ai/orchestrator.py +57 -0
- agent_debrief/ai/prompts/__init__.py +1 -0
- agent_debrief/ai/prompts/workflows.py +66 -0
- agent_debrief/ai/providers/__init__.py +1 -0
- agent_debrief/ai/providers/anthropic.py +60 -0
- agent_debrief/ai/providers/base.py +56 -0
- agent_debrief/ai/providers/openai_compat.py +87 -0
- agent_debrief/ai/providers/registry.py +32 -0
- agent_debrief/ai/structured_outputs/__init__.py +1 -0
- agent_debrief/ai/structured_outputs/schemas.py +73 -0
- agent_debrief/analysis/__init__.py +1 -0
- agent_debrief/analysis/architecture_analyzer.py +105 -0
- agent_debrief/analysis/ast_analyzer.py +319 -0
- agent_debrief/analysis/diff_analyzer.py +360 -0
- agent_debrief/analysis/evidence_engine.py +158 -0
- agent_debrief/analysis/git_utils.py +213 -0
- agent_debrief/analysis/relationship_analyzer.py +299 -0
- agent_debrief/analysis/risk_analyzer.py +142 -0
- agent_debrief/analysis/symbol_diff.py +64 -0
- agent_debrief/analysis/symbol_indexer.py +141 -0
- agent_debrief/analysis/task_analyzer.py +178 -0
- agent_debrief/analysis/test_results.py +105 -0
- agent_debrief/api/__init__.py +1 -0
- agent_debrief/api/app.py +105 -0
- agent_debrief/api/routes/__init__.py +1 -0
- agent_debrief/api/routes/core.py +88 -0
- agent_debrief/api/routes/stream.py +78 -0
- agent_debrief/api/routes/tasks.py +132 -0
- agent_debrief/api/security.py +108 -0
- agent_debrief/cli/__init__.py +1 -0
- agent_debrief/cli/app.py +454 -0
- agent_debrief/cli/hook.py +50 -0
- agent_debrief/communication/__init__.py +1 -0
- agent_debrief/communication/chat.py +260 -0
- agent_debrief/communication/common.py +76 -0
- agent_debrief/communication/concepts_catalog.py +389 -0
- agent_debrief/communication/flow.py +277 -0
- agent_debrief/communication/retrieval.py +134 -0
- agent_debrief/communication/summary.py +198 -0
- agent_debrief/communication/tutor.py +110 -0
- agent_debrief/communication/voice.py +177 -0
- agent_debrief/config/__init__.py +14 -0
- agent_debrief/config/secrets.py +66 -0
- agent_debrief/config/settings.py +92 -0
- agent_debrief/database/__init__.py +3 -0
- agent_debrief/database/connection.py +121 -0
- agent_debrief/database/migrations/0001_initial.sql +226 -0
- agent_debrief/demo.py +300 -0
- agent_debrief/events/__init__.py +1 -0
- agent_debrief/events/correlator.py +128 -0
- agent_debrief/events/deduplicator.py +26 -0
- agent_debrief/events/ingestion.py +211 -0
- agent_debrief/events/normalizer.py +213 -0
- agent_debrief/events/redaction.py +107 -0
- agent_debrief/events/schemas.py +44 -0
- agent_debrief/integrations/__init__.py +1 -0
- agent_debrief/integrations/base.py +41 -0
- agent_debrief/integrations/claude/__init__.py +1 -0
- agent_debrief/integrations/claude/adapter.py +105 -0
- agent_debrief/integrations/claude/hooks.py +186 -0
- agent_debrief/integrations/claude/transcript.py +211 -0
- agent_debrief/integrations/codex/__init__.py +0 -0
- agent_debrief/integrations/codex/adapter.py +34 -0
- agent_debrief/integrations/cursor/__init__.py +0 -0
- agent_debrief/integrations/cursor/adapter.py +32 -0
- agent_debrief/integrations/registry.py +22 -0
- agent_debrief/main.py +25 -0
- agent_debrief/models/__init__.py +1 -0
- agent_debrief/models/api.py +515 -0
- agent_debrief/models/common.py +103 -0
- agent_debrief/models/understanding.py +99 -0
- agent_debrief/paths.py +60 -0
- agent_debrief/repositories/__init__.py +37 -0
- agent_debrief/repositories/analysis.py +137 -0
- agent_debrief/repositories/base.py +17 -0
- agent_debrief/repositories/events.py +70 -0
- agent_debrief/repositories/explanations.py +117 -0
- agent_debrief/repositories/jobs.py +106 -0
- agent_debrief/repositories/sessions.py +75 -0
- agent_debrief/repositories/tasks.py +121 -0
- agent_debrief/repositories/workspaces.py +37 -0
- agent_debrief/services/__init__.py +1 -0
- agent_debrief/services/agent_service.py +70 -0
- agent_debrief/services/analysis_runner.py +62 -0
- agent_debrief/services/analysis_service.py +337 -0
- agent_debrief/services/errors.py +40 -0
- agent_debrief/services/settings_service.py +76 -0
- agent_debrief/services/task_service.py +445 -0
- agent_debrief/understanding/__init__.py +1 -0
- agent_debrief/understanding/context_builder.py +128 -0
- agent_debrief/understanding/knowledge_graph.py +75 -0
- agent_debrief/understanding/task_understanding.py +91 -0
- agent_debrief/util.py +65 -0
- agent_debrief/web_dist/assets/FlowTab-BnuhLJ6X.css +1 -0
- agent_debrief/web_dist/assets/FlowTab-DxkwVXsJ.js +23 -0
- agent_debrief/web_dist/assets/index-97ryKr0Z.css +1 -0
- agent_debrief/web_dist/assets/index-DhBchsMz.js +71 -0
- agent_debrief/web_dist/index.html +19 -0
- agent_debrief/workers/__init__.py +1 -0
- agent_debrief/workers/worker.py +72 -0
- agent_debrief-0.1.0.dist-info/METADATA +169 -0
- agent_debrief-0.1.0.dist-info/RECORD +108 -0
- agent_debrief-0.1.0.dist-info/WHEEL +4 -0
- agent_debrief-0.1.0.dist-info/entry_points.txt +3 -0
- agent_debrief-0.1.0.dist-info/licenses/LICENSE +21 -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)
|