devmemory-cli 0.1.0.dev0__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.
- devmemory/__about__.py +3 -0
- devmemory/__init__.py +14 -0
- devmemory/__main__.py +6 -0
- devmemory/adapters/__init__.py +6 -0
- devmemory/adapters/databricks.py +346 -0
- devmemory/adapters/entire.py +444 -0
- devmemory/adapters/git.py +408 -0
- devmemory/adapters/graph.py +251 -0
- devmemory/adapters/metrics.py +150 -0
- devmemory/adapters/tests.py +227 -0
- devmemory/analysis/__init__.py +19 -0
- devmemory/analysis/base.py +128 -0
- devmemory/analysis/chain.py +53 -0
- devmemory/analysis/llm.py +236 -0
- devmemory/analysis/rules.py +110 -0
- devmemory/api/__init__.py +10 -0
- devmemory/api/app.py +390 -0
- devmemory/api/mappers.py +187 -0
- devmemory/api/schemas.py +201 -0
- devmemory/cli/__init__.py +1 -0
- devmemory/cli/_errors.py +36 -0
- devmemory/cli/_render.py +79 -0
- devmemory/cli/analytics.py +136 -0
- devmemory/cli/analyze.py +58 -0
- devmemory/cli/app.py +163 -0
- devmemory/cli/checkpoint.py +199 -0
- devmemory/cli/compare.py +104 -0
- devmemory/cli/doctor.py +151 -0
- devmemory/cli/history.py +56 -0
- devmemory/cli/impact.py +95 -0
- devmemory/cli/init.py +91 -0
- devmemory/cli/mcp.py +66 -0
- devmemory/cli/memory.py +70 -0
- devmemory/cli/restore.py +91 -0
- devmemory/cli/search.py +48 -0
- devmemory/cli/serve.py +64 -0
- devmemory/cli/show.py +139 -0
- devmemory/cli/status.py +72 -0
- devmemory/cli/task.py +333 -0
- devmemory/config.py +302 -0
- devmemory/domain/__init__.py +5 -0
- devmemory/domain/enums.py +151 -0
- devmemory/domain/errors.py +188 -0
- devmemory/domain/models.py +452 -0
- devmemory/domain/taskloop.py +212 -0
- devmemory/environment.py +67 -0
- devmemory/logging.py +148 -0
- devmemory/mcp/__init__.py +12 -0
- devmemory/mcp/server.py +225 -0
- devmemory/paths.py +112 -0
- devmemory/pipeline/__init__.py +7 -0
- devmemory/pipeline/checkpoint.py +443 -0
- devmemory/pipeline/feature_detect.py +53 -0
- devmemory/pipeline/regression.py +141 -0
- devmemory/pipeline/runlog.py +73 -0
- devmemory/pipeline/status_rules.py +44 -0
- devmemory/py.typed +0 -0
- devmemory/services/__init__.py +9 -0
- devmemory/services/agent_context.py +287 -0
- devmemory/services/analysis.py +116 -0
- devmemory/services/analytics.py +328 -0
- devmemory/services/brief.py +53 -0
- devmemory/services/context.py +88 -0
- devmemory/services/databricks_sync.py +121 -0
- devmemory/services/features.py +85 -0
- devmemory/services/impact.py +47 -0
- devmemory/services/memory.py +212 -0
- devmemory/services/projects.py +226 -0
- devmemory/services/restore.py +194 -0
- devmemory/services/taskloop/__init__.py +39 -0
- devmemory/services/taskloop/collectors.py +263 -0
- devmemory/services/taskloop/engine.py +426 -0
- devmemory/services/taskloop/requirements.py +358 -0
- devmemory/services/trace.py +152 -0
- devmemory/services/versions.py +287 -0
- devmemory/storage/__init__.py +9 -0
- devmemory/storage/artifacts.py +113 -0
- devmemory/storage/db.py +205 -0
- devmemory/storage/graph_impacts.py +63 -0
- devmemory/storage/migrations/0001_init.sql +15 -0
- devmemory/storage/migrations/0002_versions.sql +210 -0
- devmemory/storage/migrations/0003_graph.sql +14 -0
- devmemory/storage/migrations/0004_taskloop.sql +82 -0
- devmemory/storage/migrations/0005_project_brief.sql +12 -0
- devmemory/storage/repositories.py +286 -0
- devmemory/storage/tasks.py +342 -0
- devmemory/storage/versions.py +604 -0
- devmemory/web/static/assets/index-CbV5njRH.js +78 -0
- devmemory/web/static/assets/index-DD-7ceZx.css +1 -0
- devmemory/web/static/index.html +18 -0
- devmemory_cli-0.1.0.dev0.dist-info/METADATA +174 -0
- devmemory_cli-0.1.0.dev0.dist-info/RECORD +95 -0
- devmemory_cli-0.1.0.dev0.dist-info/WHEEL +4 -0
- devmemory_cli-0.1.0.dev0.dist-info/entry_points.txt +3 -0
- devmemory_cli-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Per-run structured log for ``devmemory checkpoint``.
|
|
2
|
+
|
|
3
|
+
Answers, after the fact: what ran, what each stage did, what failed, which
|
|
4
|
+
integration caused it, and whether project state changed. Written to
|
|
5
|
+
``.devmemory/runs/<run_id>.json``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
from collections.abc import Iterator
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from datetime import UTC, datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel, Field
|
|
19
|
+
|
|
20
|
+
from devmemory.logging import get_logger
|
|
21
|
+
|
|
22
|
+
_log = get_logger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class StageRecord(BaseModel):
|
|
26
|
+
name: str
|
|
27
|
+
status: str = "ok" # ok | skipped | degraded | failed
|
|
28
|
+
started_at: str
|
|
29
|
+
duration_ms: float = 0.0
|
|
30
|
+
detail: str | None = None
|
|
31
|
+
data: dict[str, object] = Field(default_factory=dict)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RunLog(BaseModel):
|
|
35
|
+
run_id: str = Field(default_factory=lambda: uuid.uuid4().hex)
|
|
36
|
+
command: str = "checkpoint"
|
|
37
|
+
started_at: str = Field(default_factory=lambda: datetime.now(UTC).isoformat())
|
|
38
|
+
finished_at: str | None = None
|
|
39
|
+
outcome: str = "running" # running | success | error
|
|
40
|
+
project_state_changed: bool = False
|
|
41
|
+
version_id: str | None = None
|
|
42
|
+
stages: list[StageRecord] = Field(default_factory=list)
|
|
43
|
+
error: str | None = None
|
|
44
|
+
|
|
45
|
+
@contextmanager
|
|
46
|
+
def stage(self, name: str) -> Iterator[StageRecord]:
|
|
47
|
+
record = StageRecord(name=name, started_at=datetime.now(UTC).isoformat())
|
|
48
|
+
start = time.perf_counter()
|
|
49
|
+
self.stages.append(record)
|
|
50
|
+
try:
|
|
51
|
+
yield record
|
|
52
|
+
except Exception as exc:
|
|
53
|
+
record.status = "failed"
|
|
54
|
+
record.detail = f"{type(exc).__name__}: {exc}"
|
|
55
|
+
record.duration_ms = round((time.perf_counter() - start) * 1000, 1)
|
|
56
|
+
raise
|
|
57
|
+
else:
|
|
58
|
+
record.duration_ms = round((time.perf_counter() - start) * 1000, 1)
|
|
59
|
+
_log.debug("pipeline.stage", stage=name, status=record.status, ms=record.duration_ms)
|
|
60
|
+
|
|
61
|
+
def finish(self, *, outcome: str, error: str | None = None) -> None:
|
|
62
|
+
self.outcome = outcome
|
|
63
|
+
self.error = error
|
|
64
|
+
self.finished_at = datetime.now(UTC).isoformat()
|
|
65
|
+
|
|
66
|
+
def write(self, runs_dir: Path) -> Path:
|
|
67
|
+
runs_dir.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
path = runs_dir / f"{self.run_id}.json"
|
|
69
|
+
path.write_text(json.dumps(self.model_dump(), indent=2), encoding="utf-8")
|
|
70
|
+
return path
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
__all__ = ["RunLog", "StageRecord"]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Derive a version status from the available evidence.
|
|
2
|
+
|
|
3
|
+
Phase 4 covers the direct signals (explicit override, errors, test outcome).
|
|
4
|
+
Metric-regression-aware status is layered on in Phase 6.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from devmemory.domain.enums import VersionStatus
|
|
10
|
+
from devmemory.domain.models import Regression, TestOutcome
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def derive_status(
|
|
14
|
+
*,
|
|
15
|
+
explicit: VersionStatus | None,
|
|
16
|
+
tests: TestOutcome | None,
|
|
17
|
+
errors: list[str],
|
|
18
|
+
regressions: list[Regression] | None = None,
|
|
19
|
+
has_changes: bool = True,
|
|
20
|
+
) -> VersionStatus:
|
|
21
|
+
if explicit is not None:
|
|
22
|
+
return explicit
|
|
23
|
+
|
|
24
|
+
if errors:
|
|
25
|
+
return VersionStatus.ERROR
|
|
26
|
+
|
|
27
|
+
if regressions:
|
|
28
|
+
return VersionStatus.REGRESSION
|
|
29
|
+
|
|
30
|
+
if tests is not None and tests.ran:
|
|
31
|
+
if tests.exit_code not in (0, None) and tests.total == 0:
|
|
32
|
+
return VersionStatus.ERROR
|
|
33
|
+
if tests.failed > 0 or tests.errors > 0:
|
|
34
|
+
return VersionStatus.PARTIAL_SUCCESS if tests.passed > 0 else VersionStatus.ERROR
|
|
35
|
+
if tests.passed > 0 or tests.total == 0:
|
|
36
|
+
return VersionStatus.SUCCESS
|
|
37
|
+
|
|
38
|
+
if not has_changes:
|
|
39
|
+
return VersionStatus.NEEDS_REVIEW
|
|
40
|
+
|
|
41
|
+
return VersionStatus.NEEDS_REVIEW
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
__all__ = ["derive_status"]
|
devmemory/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Service layer - the single waist between adapters/storage and the CLI/API/MCP.
|
|
2
|
+
|
|
3
|
+
Nothing above this layer imports ``sqlite3`` or an adapter directly; nothing in
|
|
4
|
+
this layer imports FastAPI or Typer.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from devmemory.services.context import ProjectContext
|
|
8
|
+
|
|
9
|
+
__all__ = ["ProjectContext"]
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Agent-facing context.
|
|
2
|
+
|
|
3
|
+
The MCP server and the REST ``/api/agent/*`` endpoints both read from here. The
|
|
4
|
+
shapes are small, flat, and JSON-first - built for another AI agent to consume
|
|
5
|
+
before it edits code, not for a human dashboard. Facts only: every field is
|
|
6
|
+
collected data (Git, Entire, tests, metrics), never an LLM interpretation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from devmemory.domain.enums import VersionStatus
|
|
14
|
+
from devmemory.domain.models import DevelopmentVersion
|
|
15
|
+
from devmemory.services.context import ProjectContext
|
|
16
|
+
from devmemory.services.memory import MemoryQuery, PreviousAttempt, previous_attempts
|
|
17
|
+
from devmemory.services.projects import project_status
|
|
18
|
+
from devmemory.services.trace import DevelopmentTrace, build_trace
|
|
19
|
+
from devmemory.storage.versions import VersionRepository
|
|
20
|
+
|
|
21
|
+
# verdicts for check_before_change
|
|
22
|
+
PROCEED = "proceed"
|
|
23
|
+
CAUTION = "caution"
|
|
24
|
+
HIGH_RISK = "high-risk"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class VersionBrief(BaseModel):
|
|
28
|
+
"""A development version, flattened to what an agent needs to reason about it."""
|
|
29
|
+
|
|
30
|
+
version_id: str
|
|
31
|
+
version_number: int
|
|
32
|
+
status: str
|
|
33
|
+
is_adverse: bool
|
|
34
|
+
intent: str | None
|
|
35
|
+
feature: str | None
|
|
36
|
+
agent: str | None
|
|
37
|
+
model: str | None
|
|
38
|
+
git_commit: str
|
|
39
|
+
files_changed: int
|
|
40
|
+
changed_paths: list[str]
|
|
41
|
+
lines_added: int
|
|
42
|
+
lines_removed: int
|
|
43
|
+
tests: str | None
|
|
44
|
+
metrics: list[str]
|
|
45
|
+
regressions: list[str]
|
|
46
|
+
committed_at: str | None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ProjectBrief(BaseModel):
|
|
50
|
+
project: str
|
|
51
|
+
repo_path: str
|
|
52
|
+
branch: str | None
|
|
53
|
+
head_commit: str | None
|
|
54
|
+
head_subject: str | None
|
|
55
|
+
working_tree_clean: bool
|
|
56
|
+
head_has_version: bool
|
|
57
|
+
entire_installed: bool
|
|
58
|
+
entire_enabled: bool
|
|
59
|
+
version_count: int
|
|
60
|
+
success_rate: float
|
|
61
|
+
open_features: list[str]
|
|
62
|
+
last_regression_id: str | None
|
|
63
|
+
latest: VersionBrief | None
|
|
64
|
+
recent_adverse: list[VersionBrief]
|
|
65
|
+
notes: list[str]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class VersionReport(BaseModel):
|
|
69
|
+
brief: VersionBrief
|
|
70
|
+
trace: DevelopmentTrace
|
|
71
|
+
parent_commit: str | None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ChangeGuidance(BaseModel):
|
|
75
|
+
"""The answer to "should I make this change, and what should I know first?"."""
|
|
76
|
+
|
|
77
|
+
verdict: str # proceed | caution | high-risk
|
|
78
|
+
headline: str
|
|
79
|
+
files: list[str]
|
|
80
|
+
intent: str | None
|
|
81
|
+
feature: str | None
|
|
82
|
+
related_attempts: list[PreviousAttempt]
|
|
83
|
+
warnings: list[str]
|
|
84
|
+
recommendations: list[str]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# --- builders ------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def version_brief(v: DevelopmentVersion) -> VersionBrief:
|
|
91
|
+
tests = None
|
|
92
|
+
if v.tests and v.tests.ran:
|
|
93
|
+
tests = f"{v.tests.passed} passed / {v.tests.failed} failed"
|
|
94
|
+
if v.tests.skipped:
|
|
95
|
+
tests += f" / {v.tests.skipped} skipped"
|
|
96
|
+
metrics = [
|
|
97
|
+
(
|
|
98
|
+
f"{m.name}: {m.before:g} -> {m.after:g}"
|
|
99
|
+
if m.before is not None
|
|
100
|
+
else f"{m.name}: {m.after}"
|
|
101
|
+
)
|
|
102
|
+
for m in v.metrics
|
|
103
|
+
if m.after is not None
|
|
104
|
+
]
|
|
105
|
+
regressions = [r.detail or f"{r.metric or r.kind} regressed" for r in v.regressions]
|
|
106
|
+
return VersionBrief(
|
|
107
|
+
version_id=v.version_id,
|
|
108
|
+
version_number=v.version_number,
|
|
109
|
+
status=v.status.value,
|
|
110
|
+
is_adverse=v.status.is_adverse or bool(v.regressions),
|
|
111
|
+
intent=v.intent,
|
|
112
|
+
feature=v.feature_id.split(":", 1)[-1] if v.feature_id else None,
|
|
113
|
+
agent=v.agent,
|
|
114
|
+
model=v.model,
|
|
115
|
+
git_commit=v.git_commit,
|
|
116
|
+
files_changed=v.files_changed,
|
|
117
|
+
changed_paths=[f.path for f in v.changed_files],
|
|
118
|
+
lines_added=v.lines_added,
|
|
119
|
+
lines_removed=v.lines_removed,
|
|
120
|
+
tests=tests,
|
|
121
|
+
metrics=metrics,
|
|
122
|
+
regressions=regressions,
|
|
123
|
+
committed_at=v.committed_at.isoformat() if v.committed_at else None,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def recent_history(
|
|
128
|
+
ctx: ProjectContext, *, limit: int = 20, feature: str | None = None
|
|
129
|
+
) -> list[VersionBrief]:
|
|
130
|
+
versions = VersionRepository(ctx.db).page(
|
|
131
|
+
ctx.config.project_id, limit=max(limit, 1), ascending=False
|
|
132
|
+
)
|
|
133
|
+
if feature:
|
|
134
|
+
want = feature.lower()
|
|
135
|
+
versions = [
|
|
136
|
+
v for v in versions if v.feature_id and v.feature_id.split(":", 1)[-1].lower() == want
|
|
137
|
+
]
|
|
138
|
+
return [version_brief(v) for v in versions[:limit]]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def project_brief(ctx: ProjectContext) -> ProjectBrief:
|
|
142
|
+
status = project_status(ctx, entire_probe=ctx.entire.probe())
|
|
143
|
+
repo = VersionRepository(ctx.db)
|
|
144
|
+
everything = repo.page(ctx.config.project_id, limit=5000, ascending=True)
|
|
145
|
+
n = len(everything)
|
|
146
|
+
successes = sum(1 for v in everything if v.status is VersionStatus.SUCCESS)
|
|
147
|
+
|
|
148
|
+
latest = repo.latest(ctx.config.project_id)
|
|
149
|
+
recent_adverse = [
|
|
150
|
+
version_brief(v) for v in reversed(everything) if v.status.is_adverse or v.regressions
|
|
151
|
+
][:5]
|
|
152
|
+
|
|
153
|
+
notes: list[str] = []
|
|
154
|
+
if not status.head_has_version:
|
|
155
|
+
notes.append(
|
|
156
|
+
"HEAD has no development version yet - run `devmemory checkpoint` after the next change."
|
|
157
|
+
)
|
|
158
|
+
if not status.working_tree_clean:
|
|
159
|
+
notes.append("The working tree has uncommitted changes.")
|
|
160
|
+
if status.last_regression_id:
|
|
161
|
+
notes.append(
|
|
162
|
+
f"The most recent regression is {status.last_regression_id.upper()}; "
|
|
163
|
+
"check it before touching the same area."
|
|
164
|
+
)
|
|
165
|
+
if not status.entire.installed:
|
|
166
|
+
notes.append("Entire is not installed here, so AI-session context may be missing.")
|
|
167
|
+
|
|
168
|
+
return ProjectBrief(
|
|
169
|
+
project=status.project.name,
|
|
170
|
+
repo_path=str(ctx.paths.repo_root),
|
|
171
|
+
branch=status.branch,
|
|
172
|
+
head_commit=status.head_sha,
|
|
173
|
+
head_subject=status.head_subject,
|
|
174
|
+
working_tree_clean=status.working_tree_clean,
|
|
175
|
+
head_has_version=status.head_has_version,
|
|
176
|
+
entire_installed=status.entire.installed,
|
|
177
|
+
entire_enabled=status.entire.enabled,
|
|
178
|
+
version_count=n,
|
|
179
|
+
success_rate=round(successes / n * 100, 1) if n else 0.0,
|
|
180
|
+
open_features=status.open_features,
|
|
181
|
+
last_regression_id=status.last_regression_id,
|
|
182
|
+
latest=version_brief(latest) if latest else None,
|
|
183
|
+
recent_adverse=recent_adverse,
|
|
184
|
+
notes=notes,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def version_report(ctx: ProjectContext, ref: str) -> VersionReport:
|
|
189
|
+
from devmemory.services.versions import get_version
|
|
190
|
+
|
|
191
|
+
v = get_version(ctx, ref)
|
|
192
|
+
return VersionReport(
|
|
193
|
+
brief=version_brief(v),
|
|
194
|
+
trace=build_trace(v),
|
|
195
|
+
parent_commit=v.parent_commit,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def change_guidance(
|
|
200
|
+
ctx: ProjectContext,
|
|
201
|
+
*,
|
|
202
|
+
files: list[str] | None = None,
|
|
203
|
+
intent: str | None = None,
|
|
204
|
+
feature: str | None = None,
|
|
205
|
+
) -> ChangeGuidance:
|
|
206
|
+
files = [f for f in (files or []) if f.strip()]
|
|
207
|
+
attempts = previous_attempts(
|
|
208
|
+
ctx,
|
|
209
|
+
MemoryQuery(
|
|
210
|
+
files=files,
|
|
211
|
+
intent=intent,
|
|
212
|
+
feature=feature,
|
|
213
|
+
include_successes=True,
|
|
214
|
+
limit=8,
|
|
215
|
+
),
|
|
216
|
+
)
|
|
217
|
+
adverse = [a for a in attempts if a.is_adverse]
|
|
218
|
+
|
|
219
|
+
# a strongly-matching past failure, or the same failure more than once
|
|
220
|
+
strong = [a for a in adverse if a.score >= 4.0]
|
|
221
|
+
repeated = len(adverse) >= 2
|
|
222
|
+
|
|
223
|
+
if strong or repeated:
|
|
224
|
+
verdict = HIGH_RISK
|
|
225
|
+
elif adverse:
|
|
226
|
+
verdict = CAUTION
|
|
227
|
+
else:
|
|
228
|
+
verdict = PROCEED
|
|
229
|
+
|
|
230
|
+
warnings: list[str] = []
|
|
231
|
+
recommendations: list[str] = []
|
|
232
|
+
for a in adverse:
|
|
233
|
+
label = f"{a.version_id.upper()} [{a.status}]"
|
|
234
|
+
warnings.append(f"{label}: {a.result} (matched on {', '.join(a.matched_on)})")
|
|
235
|
+
if a.recommendation:
|
|
236
|
+
recommendations.append(f"{a.version_id.upper()}: {a.recommendation}")
|
|
237
|
+
|
|
238
|
+
if verdict == PROCEED:
|
|
239
|
+
succeeded = [a for a in attempts if not a.is_adverse]
|
|
240
|
+
if succeeded:
|
|
241
|
+
headline = (
|
|
242
|
+
f"No prior failures in this area. {succeeded[0].version_id.upper()} "
|
|
243
|
+
"changed similar files successfully - use it as a reference."
|
|
244
|
+
)
|
|
245
|
+
else:
|
|
246
|
+
headline = "No related history - this looks like new ground."
|
|
247
|
+
elif verdict == CAUTION:
|
|
248
|
+
headline = (
|
|
249
|
+
f"{len(adverse)} earlier attempt(s) touching this area went wrong. "
|
|
250
|
+
"Review them before proceeding."
|
|
251
|
+
)
|
|
252
|
+
else:
|
|
253
|
+
headline = (
|
|
254
|
+
f"High risk: {len(adverse)} earlier attempt(s) in this exact area failed"
|
|
255
|
+
f"{' - and more than once' if repeated else ''}. "
|
|
256
|
+
"Read the linked versions and address the root cause first."
|
|
257
|
+
)
|
|
258
|
+
recommendations.append(
|
|
259
|
+
"Surface these prior failures to the developer and confirm the approach before editing."
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
return ChangeGuidance(
|
|
263
|
+
verdict=verdict,
|
|
264
|
+
headline=headline,
|
|
265
|
+
files=files,
|
|
266
|
+
intent=intent,
|
|
267
|
+
feature=feature,
|
|
268
|
+
related_attempts=attempts,
|
|
269
|
+
warnings=warnings,
|
|
270
|
+
recommendations=recommendations,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
__all__ = [
|
|
275
|
+
"CAUTION",
|
|
276
|
+
"HIGH_RISK",
|
|
277
|
+
"PROCEED",
|
|
278
|
+
"ChangeGuidance",
|
|
279
|
+
"ProjectBrief",
|
|
280
|
+
"VersionBrief",
|
|
281
|
+
"VersionReport",
|
|
282
|
+
"change_guidance",
|
|
283
|
+
"project_brief",
|
|
284
|
+
"recent_history",
|
|
285
|
+
"version_brief",
|
|
286
|
+
"version_report",
|
|
287
|
+
]
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Generate and store the AI analysis for a version.
|
|
2
|
+
|
|
3
|
+
Assembles a normalized :class:`AnalysisInput` from facts already on the version
|
|
4
|
+
(plus previous attempts and any stored graph impact), runs the provider chain,
|
|
5
|
+
and writes the result to the separate ``analysis`` table via
|
|
6
|
+
``VersionRepository.replace_analysis`` - which only ever touches interpretive
|
|
7
|
+
fields.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from devmemory.analysis import AnalysisInput, build_providers, run_analysis
|
|
13
|
+
from devmemory.analysis.base import AttemptRef, ImpactRef, MetricDelta
|
|
14
|
+
from devmemory.domain.models import Analysis, DevelopmentVersion
|
|
15
|
+
from devmemory.services.context import ProjectContext
|
|
16
|
+
from devmemory.services.memory import MemoryQuery, previous_attempts
|
|
17
|
+
from devmemory.services.versions import get_version
|
|
18
|
+
from devmemory.storage.graph_impacts import GraphImpactRepository
|
|
19
|
+
from devmemory.storage.versions import VersionRepository
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_analysis_input(ctx: ProjectContext, version: DevelopmentVersion) -> AnalysisInput:
|
|
23
|
+
feature = version.feature_id.split(":", 1)[-1] if version.feature_id else None
|
|
24
|
+
|
|
25
|
+
attempts = previous_attempts(
|
|
26
|
+
ctx,
|
|
27
|
+
MemoryQuery(
|
|
28
|
+
files=[f.path for f in version.changed_files],
|
|
29
|
+
feature=feature,
|
|
30
|
+
intent=version.intent,
|
|
31
|
+
limit=5,
|
|
32
|
+
),
|
|
33
|
+
)
|
|
34
|
+
attempt_refs = [
|
|
35
|
+
AttemptRef(
|
|
36
|
+
version_id=a.version_id,
|
|
37
|
+
status=a.status,
|
|
38
|
+
result=a.result,
|
|
39
|
+
matched_on=a.matched_on,
|
|
40
|
+
)
|
|
41
|
+
for a in attempts
|
|
42
|
+
if a.version_id != version.version_id
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
impact = GraphImpactRepository(ctx.db).get(version.version_id)
|
|
46
|
+
hotspots = (
|
|
47
|
+
[
|
|
48
|
+
ImpactRef(entity=e.name, change_type=e.change_type, dependents=e.dependents_count)
|
|
49
|
+
for e in impact.hotspots
|
|
50
|
+
]
|
|
51
|
+
if impact
|
|
52
|
+
else []
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
diff_excerpt: str | None = None
|
|
56
|
+
if ctx.config.analysis.include_diff:
|
|
57
|
+
text = ctx.git.diff_text(version.parent_commit, version.git_commit)
|
|
58
|
+
if text:
|
|
59
|
+
diff_excerpt = text[: ctx.config.analysis.max_diff_bytes]
|
|
60
|
+
|
|
61
|
+
tests = version.tests if version.tests and version.tests.ran else None
|
|
62
|
+
return AnalysisInput(
|
|
63
|
+
version_id=version.version_id,
|
|
64
|
+
intent=version.intent,
|
|
65
|
+
feature=feature,
|
|
66
|
+
agent=version.agent,
|
|
67
|
+
model=version.model,
|
|
68
|
+
status=version.status.value,
|
|
69
|
+
is_adverse=version.status.is_adverse or bool(version.regressions),
|
|
70
|
+
files_changed=version.files_changed,
|
|
71
|
+
lines_added=version.lines_added,
|
|
72
|
+
lines_removed=version.lines_removed,
|
|
73
|
+
changed_paths=[f.path for f in version.changed_files],
|
|
74
|
+
test_summary=(
|
|
75
|
+
f"{tests.passed} passed / {tests.failed} failed / {tests.skipped} skipped"
|
|
76
|
+
if tests
|
|
77
|
+
else None
|
|
78
|
+
),
|
|
79
|
+
tests_passed=tests.passed if tests else None,
|
|
80
|
+
tests_failed=tests.failed if tests else None,
|
|
81
|
+
metric_deltas=[
|
|
82
|
+
MetricDelta(
|
|
83
|
+
name=m.name,
|
|
84
|
+
before=m.before,
|
|
85
|
+
after=m.after,
|
|
86
|
+
direction=m.direction.value,
|
|
87
|
+
improved=m.is_improvement,
|
|
88
|
+
worsened=m.is_worse,
|
|
89
|
+
)
|
|
90
|
+
for m in version.metrics
|
|
91
|
+
],
|
|
92
|
+
regressions=[r.detail or f"{r.metric or r.kind} regressed" for r in version.regressions],
|
|
93
|
+
previous_attempts=attempt_refs,
|
|
94
|
+
impact_hotspots=hotspots,
|
|
95
|
+
diff_excerpt=diff_excerpt,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def analyze_version(
|
|
100
|
+
ctx: ProjectContext,
|
|
101
|
+
ref: str,
|
|
102
|
+
*,
|
|
103
|
+
providers: list[str] | None = None,
|
|
104
|
+
persist: bool = True,
|
|
105
|
+
) -> Analysis:
|
|
106
|
+
version = get_version(ctx, ref)
|
|
107
|
+
data = build_analysis_input(ctx, version)
|
|
108
|
+
names = providers or ctx.config.analysis.providers
|
|
109
|
+
chain = build_providers(names, model=ctx.config.analysis.model)
|
|
110
|
+
analysis = run_analysis(data, chain)
|
|
111
|
+
if persist:
|
|
112
|
+
VersionRepository(ctx.db).replace_analysis(version.version_id, analysis)
|
|
113
|
+
return analysis
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
__all__ = ["analyze_version", "build_analysis_input"]
|