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.
Files changed (95) hide show
  1. devmemory/__about__.py +3 -0
  2. devmemory/__init__.py +14 -0
  3. devmemory/__main__.py +6 -0
  4. devmemory/adapters/__init__.py +6 -0
  5. devmemory/adapters/databricks.py +346 -0
  6. devmemory/adapters/entire.py +444 -0
  7. devmemory/adapters/git.py +408 -0
  8. devmemory/adapters/graph.py +251 -0
  9. devmemory/adapters/metrics.py +150 -0
  10. devmemory/adapters/tests.py +227 -0
  11. devmemory/analysis/__init__.py +19 -0
  12. devmemory/analysis/base.py +128 -0
  13. devmemory/analysis/chain.py +53 -0
  14. devmemory/analysis/llm.py +236 -0
  15. devmemory/analysis/rules.py +110 -0
  16. devmemory/api/__init__.py +10 -0
  17. devmemory/api/app.py +390 -0
  18. devmemory/api/mappers.py +187 -0
  19. devmemory/api/schemas.py +201 -0
  20. devmemory/cli/__init__.py +1 -0
  21. devmemory/cli/_errors.py +36 -0
  22. devmemory/cli/_render.py +79 -0
  23. devmemory/cli/analytics.py +136 -0
  24. devmemory/cli/analyze.py +58 -0
  25. devmemory/cli/app.py +163 -0
  26. devmemory/cli/checkpoint.py +199 -0
  27. devmemory/cli/compare.py +104 -0
  28. devmemory/cli/doctor.py +151 -0
  29. devmemory/cli/history.py +56 -0
  30. devmemory/cli/impact.py +95 -0
  31. devmemory/cli/init.py +91 -0
  32. devmemory/cli/mcp.py +66 -0
  33. devmemory/cli/memory.py +70 -0
  34. devmemory/cli/restore.py +91 -0
  35. devmemory/cli/search.py +48 -0
  36. devmemory/cli/serve.py +64 -0
  37. devmemory/cli/show.py +139 -0
  38. devmemory/cli/status.py +72 -0
  39. devmemory/cli/task.py +333 -0
  40. devmemory/config.py +302 -0
  41. devmemory/domain/__init__.py +5 -0
  42. devmemory/domain/enums.py +151 -0
  43. devmemory/domain/errors.py +188 -0
  44. devmemory/domain/models.py +452 -0
  45. devmemory/domain/taskloop.py +212 -0
  46. devmemory/environment.py +67 -0
  47. devmemory/logging.py +148 -0
  48. devmemory/mcp/__init__.py +12 -0
  49. devmemory/mcp/server.py +225 -0
  50. devmemory/paths.py +112 -0
  51. devmemory/pipeline/__init__.py +7 -0
  52. devmemory/pipeline/checkpoint.py +443 -0
  53. devmemory/pipeline/feature_detect.py +53 -0
  54. devmemory/pipeline/regression.py +141 -0
  55. devmemory/pipeline/runlog.py +73 -0
  56. devmemory/pipeline/status_rules.py +44 -0
  57. devmemory/py.typed +0 -0
  58. devmemory/services/__init__.py +9 -0
  59. devmemory/services/agent_context.py +287 -0
  60. devmemory/services/analysis.py +116 -0
  61. devmemory/services/analytics.py +328 -0
  62. devmemory/services/brief.py +53 -0
  63. devmemory/services/context.py +88 -0
  64. devmemory/services/databricks_sync.py +121 -0
  65. devmemory/services/features.py +85 -0
  66. devmemory/services/impact.py +47 -0
  67. devmemory/services/memory.py +212 -0
  68. devmemory/services/projects.py +226 -0
  69. devmemory/services/restore.py +194 -0
  70. devmemory/services/taskloop/__init__.py +39 -0
  71. devmemory/services/taskloop/collectors.py +263 -0
  72. devmemory/services/taskloop/engine.py +426 -0
  73. devmemory/services/taskloop/requirements.py +358 -0
  74. devmemory/services/trace.py +152 -0
  75. devmemory/services/versions.py +287 -0
  76. devmemory/storage/__init__.py +9 -0
  77. devmemory/storage/artifacts.py +113 -0
  78. devmemory/storage/db.py +205 -0
  79. devmemory/storage/graph_impacts.py +63 -0
  80. devmemory/storage/migrations/0001_init.sql +15 -0
  81. devmemory/storage/migrations/0002_versions.sql +210 -0
  82. devmemory/storage/migrations/0003_graph.sql +14 -0
  83. devmemory/storage/migrations/0004_taskloop.sql +82 -0
  84. devmemory/storage/migrations/0005_project_brief.sql +12 -0
  85. devmemory/storage/repositories.py +286 -0
  86. devmemory/storage/tasks.py +342 -0
  87. devmemory/storage/versions.py +604 -0
  88. devmemory/web/static/assets/index-CbV5njRH.js +78 -0
  89. devmemory/web/static/assets/index-DD-7ceZx.css +1 -0
  90. devmemory/web/static/index.html +18 -0
  91. devmemory_cli-0.1.0.dev0.dist-info/METADATA +174 -0
  92. devmemory_cli-0.1.0.dev0.dist-info/RECORD +95 -0
  93. devmemory_cli-0.1.0.dev0.dist-info/WHEEL +4 -0
  94. devmemory_cli-0.1.0.dev0.dist-info/entry_points.txt +3 -0
  95. devmemory_cli-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,150 @@
1
+ """Metrics adapter: collect arbitrary project metrics for a version.
2
+
3
+ Metrics come from a JSON file the project writes, or the JSON stdout of a
4
+ command. Two shapes are accepted per metric:
5
+
6
+ {"accuracy": 93.4} -> after only
7
+ {"accuracy": {"before": 89.2, "after": 93.4, "unit": "%"}}
8
+
9
+ The previous version's ``after`` fills any missing ``before`` (done by the
10
+ pipeline, not here). Direction comes from config, then a name heuristic.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import subprocess
17
+ from pathlib import Path
18
+
19
+ from devmemory.domain.enums import MetricDirection
20
+ from devmemory.domain.errors import CollectionError
21
+ from devmemory.domain.models import Metric
22
+ from devmemory.logging import get_logger
23
+
24
+ _log = get_logger(__name__)
25
+
26
+ _LOWER_IS_BETTER = (
27
+ "latency",
28
+ "duration",
29
+ "time_ms",
30
+ "response_time",
31
+ "loss",
32
+ "val_loss",
33
+ "train_loss",
34
+ "error_rate",
35
+ "errors",
36
+ "failures",
37
+ "cost",
38
+ "memory",
39
+ "mem_mb",
40
+ "p50",
41
+ "p90",
42
+ "p95",
43
+ "p99",
44
+ "size_bytes",
45
+ "bundle_size",
46
+ )
47
+
48
+
49
+ class MetricsAdapter:
50
+ def __init__(self, repo_path: Path | str) -> None:
51
+ self._cwd = Path(repo_path).resolve()
52
+
53
+ def collect(
54
+ self,
55
+ *,
56
+ file: str | None = None,
57
+ command: str | None = None,
58
+ directions: dict[str, MetricDirection] | None = None,
59
+ ) -> list[Metric]:
60
+ raw = self._load(file=file, command=command)
61
+ if not raw:
62
+ return []
63
+ directions = directions or {}
64
+ metrics: list[Metric] = []
65
+ for name, value in raw.items():
66
+ metric = _to_metric(str(name), value)
67
+ if metric is None:
68
+ continue
69
+ metric.direction = directions.get(metric.name, guess_direction(metric.name))
70
+ metrics.append(metric)
71
+ _log.info("metrics.collected", count=len(metrics), names=[m.name for m in metrics])
72
+ return metrics
73
+
74
+ def _load(self, *, file: str | None, command: str | None) -> dict[str, object]:
75
+ if file:
76
+ path = (self._cwd / file).resolve()
77
+ if not path.is_file():
78
+ raise CollectionError(f"metrics file not found: {path}")
79
+ return _as_dict(path.read_text(encoding="utf-8"), source=str(path))
80
+ if command:
81
+ try:
82
+ proc = subprocess.run( # noqa: S602 - user-configured command
83
+ command,
84
+ cwd=self._cwd,
85
+ shell=True,
86
+ capture_output=True,
87
+ text=True,
88
+ timeout=300,
89
+ )
90
+ except (OSError, subprocess.SubprocessError) as exc:
91
+ raise CollectionError(f"metrics command failed: {command}: {exc}") from exc
92
+ if proc.returncode != 0:
93
+ raise CollectionError(
94
+ f"metrics command exited {proc.returncode}: {proc.stderr.strip()[:200]}"
95
+ )
96
+ return _as_dict(proc.stdout, source=f"`{command}`")
97
+ return {}
98
+
99
+
100
+ def guess_direction(name: str) -> MetricDirection:
101
+ lowered = name.lower().replace("-", "_")
102
+ if any(hint in lowered for hint in _LOWER_IS_BETTER):
103
+ return MetricDirection.LOWER_IS_BETTER
104
+ return MetricDirection.HIGHER_IS_BETTER
105
+
106
+
107
+ def _as_dict(text: str, *, source: str) -> dict[str, object]:
108
+ try:
109
+ data = json.loads(text)
110
+ except json.JSONDecodeError as exc:
111
+ raise CollectionError(f"metrics from {source} are not valid JSON: {exc}") from exc
112
+ if isinstance(data, dict) and "metrics" in data and isinstance(data["metrics"], dict):
113
+ data = data["metrics"]
114
+ if not isinstance(data, dict):
115
+ raise CollectionError(f"metrics from {source} must be a JSON object")
116
+ return data
117
+
118
+
119
+ def _to_metric(name: str, value: object) -> Metric | None:
120
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
121
+ return Metric(name=name, after=float(value))
122
+ if isinstance(value, dict):
123
+ after = _num(value.get("after")) or _num(value.get("value"))
124
+ before = _num(value.get("before"))
125
+ if after is None and before is None:
126
+ return None
127
+ return Metric(
128
+ name=name,
129
+ before=before,
130
+ after=after,
131
+ unit=str(value["unit"]) if value.get("unit") is not None else None,
132
+ metadata={
133
+ k: v for k, v in value.items() if k not in ("before", "after", "value", "unit")
134
+ },
135
+ )
136
+ return None
137
+
138
+
139
+ def _num(value: object) -> float | None:
140
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
141
+ return float(value)
142
+ if isinstance(value, str):
143
+ try:
144
+ return float(value)
145
+ except ValueError:
146
+ return None
147
+ return None
148
+
149
+
150
+ __all__ = ["MetricsAdapter", "guess_direction"]
@@ -0,0 +1,227 @@
1
+ """Test adapter: run the project's configured test command and normalize the result.
2
+
3
+ DevMemory does not care which framework you use. It runs a shell command and
4
+ parses what comes back - a JUnit XML report if one is configured, otherwise the
5
+ stdout of pytest / go test / cargo test / a generic "N passed, M failed" line.
6
+ The *outcome* is a fact; whether that outcome is good is decided elsewhere.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ import shlex
13
+ import subprocess
14
+ import time
15
+ from pathlib import Path
16
+ from xml.etree import ElementTree
17
+
18
+ from devmemory.domain.errors import CollectionError
19
+ from devmemory.domain.models import TestOutcome
20
+ from devmemory.logging import get_logger
21
+
22
+ _log = get_logger(__name__)
23
+
24
+ _OUTPUT_CAP = 20_000
25
+
26
+ # pytest summary: "3 failed, 128 passed, 2 skipped, 1 error in 4.20s"
27
+ _PYTEST_TOKEN = re.compile(r"(\d+)\s+(passed|failed|skipped|error|errors|xfailed|xpassed)")
28
+ _PYTEST_FAIL_LINE = re.compile(r"^(?:FAILED|ERROR)\s+(\S+)", re.MULTILINE)
29
+
30
+ # go test: "ok pkg 0.1s" / "--- FAIL: TestFoo"
31
+ _GO_FAIL = re.compile(r"^--- FAIL:\s+(\S+)", re.MULTILINE)
32
+ _GO_PASS = re.compile(r"^--- PASS:\s+(\S+)", re.MULTILINE)
33
+
34
+ # generic: "12 passed", "3 failing", "Tests: 5 failed, 40 passed"
35
+ _GENERIC = re.compile(
36
+ r"(?P<n>\d+)\s+(?P<kind>passed|passing|failed|failing|skipped|pending|errors?)",
37
+ re.IGNORECASE,
38
+ )
39
+
40
+
41
+ class TestAdapter:
42
+ __test__ = False # this runs tests; it is not itself a pytest test case
43
+
44
+ def __init__(self, repo_path: Path | str) -> None:
45
+ self._cwd = Path(repo_path).resolve()
46
+
47
+ def run(
48
+ self,
49
+ command: str,
50
+ *,
51
+ parser: str = "auto",
52
+ junit_xml: str | None = None,
53
+ timeout: int = 900,
54
+ ) -> TestOutcome:
55
+ """Execute ``command`` and return a normalized :class:`TestOutcome`."""
56
+ start = time.perf_counter()
57
+ try:
58
+ proc = subprocess.run( # noqa: S602 - user-configured command, run in their repo
59
+ command,
60
+ cwd=self._cwd,
61
+ shell=True,
62
+ capture_output=True,
63
+ text=True,
64
+ encoding="utf-8",
65
+ errors="replace",
66
+ timeout=timeout,
67
+ )
68
+ except subprocess.TimeoutExpired as exc:
69
+ raise CollectionError(
70
+ f"test command timed out after {timeout}s: {command}",
71
+ ) from exc
72
+ except OSError as exc:
73
+ raise CollectionError(f"could not run test command {command!r}: {exc}") from exc
74
+
75
+ duration = round(time.perf_counter() - start, 2)
76
+ combined = f"{proc.stdout}\n{proc.stderr}"
77
+
78
+ if junit_xml:
79
+ outcome = self._from_junit(self._cwd / junit_xml)
80
+ else:
81
+ outcome = self._parse(combined, parser)
82
+
83
+ outcome.command = command
84
+ outcome.exit_code = proc.returncode
85
+ outcome.duration_seconds = duration
86
+ outcome.output = _tail(combined, _OUTPUT_CAP)
87
+
88
+ # A command that exited non-zero with no parsed failures still failed.
89
+ if proc.returncode != 0 and outcome.failed == 0 and outcome.errors == 0:
90
+ outcome.errors = max(outcome.errors, 1)
91
+
92
+ _log.info(
93
+ "tests.collected",
94
+ command=command,
95
+ passed=outcome.passed,
96
+ failed=outcome.failed,
97
+ exit_code=proc.returncode,
98
+ )
99
+ return outcome
100
+
101
+ # -- parsing --------------------------------------------------------
102
+
103
+ def _parse(self, text: str, parser: str) -> TestOutcome:
104
+ chosen = parser
105
+ if chosen == "auto":
106
+ chosen = self._sniff(text)
107
+ if chosen == "pytest":
108
+ return self._parse_pytest(text)
109
+ if chosen == "go":
110
+ return self._parse_go(text)
111
+ if chosen == "junitxml":
112
+ raise CollectionError("parser=junitxml requires tests.junit_xml to be set")
113
+ return self._parse_generic(text)
114
+
115
+ @staticmethod
116
+ def _sniff(text: str) -> str:
117
+ head = text[:4000].lower()
118
+ if "=== test session starts ===" in head or "\npassed" in head or "pytest" in head:
119
+ return "pytest"
120
+ if "--- fail:" in head or "--- pass:" in head or "\nok " in head:
121
+ return "go"
122
+ return "generic"
123
+
124
+ def _parse_pytest(self, text: str) -> TestOutcome:
125
+ # Take the last summary line so a mid-run "1 failed" doesn't shadow the total.
126
+ summary_line = ""
127
+ for line in text.splitlines():
128
+ if _PYTEST_TOKEN.search(line) and (
129
+ "passed" in line or "failed" in line or "error" in line
130
+ ):
131
+ summary_line = line
132
+ counts = {"passed": 0, "failed": 0, "skipped": 0, "errors": 0}
133
+ for count, kind in _PYTEST_TOKEN.findall(summary_line or text):
134
+ key = "errors" if kind.startswith("error") else kind
135
+ if key in counts:
136
+ counts[key] = max(counts[key], int(count))
137
+ failing = _PYTEST_FAIL_LINE.findall(text)
138
+ total = sum(counts.values())
139
+ return TestOutcome(
140
+ framework="pytest",
141
+ total=total,
142
+ passed=counts["passed"],
143
+ failed=counts["failed"],
144
+ skipped=counts["skipped"],
145
+ errors=counts["errors"],
146
+ failing=failing[:50],
147
+ )
148
+
149
+ def _parse_go(self, text: str) -> TestOutcome:
150
+ failing = _GO_FAIL.findall(text)
151
+ passing = _GO_PASS.findall(text)
152
+ return TestOutcome(
153
+ framework="go",
154
+ total=len(failing) + len(passing),
155
+ passed=len(passing),
156
+ failed=len(failing),
157
+ failing=failing[:50],
158
+ )
159
+
160
+ def _parse_generic(self, text: str) -> TestOutcome:
161
+ counts = {"passed": 0, "failed": 0, "skipped": 0, "errors": 0}
162
+ for m in _GENERIC.finditer(text):
163
+ kind = m.group("kind").lower()
164
+ n = int(m.group("n"))
165
+ if kind in ("passed", "passing"):
166
+ counts["passed"] = max(counts["passed"], n)
167
+ elif kind in ("failed", "failing"):
168
+ counts["failed"] = max(counts["failed"], n)
169
+ elif kind in ("skipped", "pending"):
170
+ counts["skipped"] = max(counts["skipped"], n)
171
+ elif kind.startswith("error"):
172
+ counts["errors"] = max(counts["errors"], n)
173
+ return TestOutcome(
174
+ framework="generic",
175
+ total=sum(counts.values()),
176
+ passed=counts["passed"],
177
+ failed=counts["failed"],
178
+ skipped=counts["skipped"],
179
+ errors=counts["errors"],
180
+ )
181
+
182
+ def _from_junit(self, path: Path) -> TestOutcome:
183
+ if not path.is_file():
184
+ raise CollectionError(f"JUnit report not found: {path}")
185
+ try:
186
+ root = ElementTree.parse(path).getroot() # noqa: S314 - our own CI's report
187
+ except ElementTree.ParseError as exc:
188
+ raise CollectionError(f"could not parse JUnit report {path}: {exc}") from exc
189
+
190
+ suites = [root] if root.tag == "testsuite" else root.findall(".//testsuite")
191
+ total = failures = errors = skipped = 0
192
+ failing: list[str] = []
193
+ for suite in suites:
194
+ total += int(suite.get("tests", 0))
195
+ failures += int(suite.get("failures", 0))
196
+ errors += int(suite.get("errors", 0))
197
+ skipped += int(suite.get("skipped", 0))
198
+ for case in suite.findall("testcase"):
199
+ if case.find("failure") is not None or case.find("error") is not None:
200
+ name = case.get("name", "")
201
+ classname = case.get("classname", "")
202
+ failing.append(f"{classname}::{name}" if classname else name)
203
+ return TestOutcome(
204
+ framework="junit",
205
+ total=total,
206
+ passed=total - failures - errors - skipped,
207
+ failed=failures,
208
+ errors=errors,
209
+ skipped=skipped,
210
+ failing=failing[:50],
211
+ )
212
+
213
+
214
+ def _tail(text: str, cap: int) -> str:
215
+ text = text.strip()
216
+ return text if len(text) <= cap else "…(truncated)…\n" + text[-cap:]
217
+
218
+
219
+ def split_command(command: str) -> list[str]:
220
+ """POSIX-ish split for display; execution always uses the shell."""
221
+ try:
222
+ return shlex.split(command, posix=True)
223
+ except ValueError:
224
+ return command.split()
225
+
226
+
227
+ __all__ = ["TestAdapter", "split_command"]
@@ -0,0 +1,19 @@
1
+ """AI analysis layer.
2
+
3
+ Analysis is *interpretation*, stored separately from facts and never able to
4
+ overwrite them. Providers are tried in a fallback chain; ``rules`` (deterministic,
5
+ offline) is always the tail.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from devmemory.analysis.base import AnalysisInput, AnalysisProvider, fact_guard
11
+ from devmemory.analysis.chain import build_providers, run_analysis
12
+
13
+ __all__ = [
14
+ "AnalysisInput",
15
+ "AnalysisProvider",
16
+ "build_providers",
17
+ "fact_guard",
18
+ "run_analysis",
19
+ ]
@@ -0,0 +1,128 @@
1
+ """The analysis contract: a normalized input, a provider interface, a fact guard.
2
+
3
+ Analysis is *interpretation*. It is stored in its own table and can only ever
4
+ populate the interpretive fields (``summary``, ``reasoning``, ``recommendation``,
5
+ ``warnings``, ``risk``). It can never touch a fact - Git line counts, test
6
+ results, metric values, the Entire checkpoint - because those live on the
7
+ ``DevelopmentVersion`` and are never handed to a provider for writing.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import abc
13
+
14
+ from pydantic import BaseModel, Field
15
+
16
+ from devmemory.domain.models import Analysis
17
+ from devmemory.logging import redact_secrets
18
+
19
+ _RISK_LEVELS = ("low", "medium", "high")
20
+ _MAX_SUMMARY = 1200
21
+ _MAX_FIELD = 2000
22
+
23
+
24
+ class MetricDelta(BaseModel):
25
+ name: str
26
+ before: float | None
27
+ after: float | None
28
+ direction: str
29
+ improved: bool
30
+ worsened: bool
31
+
32
+
33
+ class AttemptRef(BaseModel):
34
+ version_id: str
35
+ status: str
36
+ result: str
37
+ matched_on: list[str]
38
+
39
+
40
+ class ImpactRef(BaseModel):
41
+ entity: str
42
+ change_type: str
43
+ dependents: int
44
+
45
+
46
+ class AnalysisInput(BaseModel):
47
+ """Everything a provider is allowed to see. Normalized facts, no raw source
48
+ unless ``diff_excerpt`` was explicitly enabled in config."""
49
+
50
+ version_id: str
51
+ intent: str | None
52
+ feature: str | None
53
+ agent: str | None
54
+ model: str | None
55
+ status: str
56
+ is_adverse: bool
57
+ files_changed: int
58
+ lines_added: int
59
+ lines_removed: int
60
+ changed_paths: list[str] = Field(default_factory=list)
61
+ test_summary: str | None = None
62
+ tests_passed: int | None = None
63
+ tests_failed: int | None = None
64
+ metric_deltas: list[MetricDelta] = Field(default_factory=list)
65
+ regressions: list[str] = Field(default_factory=list)
66
+ previous_attempts: list[AttemptRef] = Field(default_factory=list)
67
+ impact_hotspots: list[ImpactRef] = Field(default_factory=list)
68
+ diff_excerpt: str | None = None
69
+
70
+
71
+ class AnalysisProvider(abc.ABC):
72
+ """One way to turn an :class:`AnalysisInput` into an :class:`Analysis`."""
73
+
74
+ #: stable identifier, also stored on the Analysis row
75
+ name: str = "base"
76
+
77
+ @abc.abstractmethod
78
+ def analyze(self, data: AnalysisInput) -> Analysis | None:
79
+ """Return an Analysis, or ``None`` to fall through to the next provider."""
80
+
81
+
82
+ def fact_guard(analysis: Analysis, data: AnalysisInput, *, provider: str) -> Analysis:
83
+ """Sanitize a provider's Analysis so it cannot misrepresent the facts.
84
+
85
+ - ``provider`` / ``model`` are set by us, never by the model.
86
+ - ``risk`` is clamped to low|medium|high and can never sit *below* what the
87
+ recorded status implies (an adverse version is at least ``medium``).
88
+ - all free text is secret-scrubbed and length-capped.
89
+ """
90
+ risk = (analysis.risk or "").strip().lower()
91
+ if risk not in _RISK_LEVELS:
92
+ risk = "medium" if data.is_adverse else "low"
93
+
94
+ warnings = [redact_secrets(w)[:_MAX_FIELD] for w in analysis.warnings if w.strip()]
95
+ if data.is_adverse and risk == "low":
96
+ risk = "medium"
97
+ warnings.append(
98
+ "Risk raised to 'medium': this version is recorded as "
99
+ f"{data.status} with {len(data.regressions)} regression(s)."
100
+ )
101
+
102
+ return analysis.model_copy(
103
+ update={
104
+ "summary": redact_secrets(analysis.summary)[:_MAX_SUMMARY],
105
+ "reasoning": _clip(analysis.reasoning),
106
+ "recommendation": _clip(analysis.recommendation),
107
+ "warnings": warnings,
108
+ "risk": risk,
109
+ "provider": provider,
110
+ "model": analysis.model,
111
+ }
112
+ )
113
+
114
+
115
+ def _clip(text: str | None) -> str | None:
116
+ if not text or not text.strip():
117
+ return None
118
+ return redact_secrets(text)[:_MAX_FIELD]
119
+
120
+
121
+ __all__ = [
122
+ "AnalysisInput",
123
+ "AnalysisProvider",
124
+ "AttemptRef",
125
+ "ImpactRef",
126
+ "MetricDelta",
127
+ "fact_guard",
128
+ ]
@@ -0,0 +1,53 @@
1
+ """The provider fallback chain.
2
+
3
+ Try each configured provider in order; the first that returns an Analysis wins,
4
+ after passing through :func:`fact_guard`. ``rules`` is always appended as the
5
+ guaranteed tail, so :func:`run_analysis` never raises and never returns ``None``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from devmemory.analysis.base import AnalysisInput, AnalysisProvider, fact_guard
11
+ from devmemory.analysis.llm import LLMProvider
12
+ from devmemory.analysis.rules import RulesProvider
13
+ from devmemory.domain.models import Analysis
14
+ from devmemory.logging import get_logger
15
+
16
+ _log = get_logger(__name__)
17
+
18
+ _LLM_PROVIDERS = {"anthropic", "openai", "gemini"}
19
+
20
+
21
+ def build_providers(names: list[str], *, model: str | None = None) -> list[AnalysisProvider]:
22
+ providers: list[AnalysisProvider] = []
23
+ seen: set[str] = set()
24
+ for raw in names:
25
+ name = raw.strip().lower()
26
+ if not name or name in seen:
27
+ continue
28
+ seen.add(name)
29
+ if name == "rules":
30
+ providers.append(RulesProvider())
31
+ elif name in _LLM_PROVIDERS:
32
+ providers.append(LLMProvider(name, model))
33
+ else:
34
+ _log.warning("analysis.unknown_provider", provider=name)
35
+ if not any(isinstance(p, RulesProvider) for p in providers):
36
+ providers.append(RulesProvider()) # guaranteed tail
37
+ return providers
38
+
39
+
40
+ def run_analysis(data: AnalysisInput, providers: list[AnalysisProvider]) -> Analysis:
41
+ for provider in providers:
42
+ try:
43
+ result = provider.analyze(data)
44
+ except Exception as exc:
45
+ _log.warning("analysis.provider_error", provider=provider.name, error=str(exc))
46
+ continue
47
+ if result is not None:
48
+ return fact_guard(result, data, provider=provider.name)
49
+ # build_providers guarantees a RulesProvider tail, so this is unreachable
50
+ return fact_guard(RulesProvider().analyze(data), data, provider="rules")
51
+
52
+
53
+ __all__ = ["build_providers", "run_analysis"]