kairopsis 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.
- kairopsis/__init__.py +20 -0
- kairopsis/analytics.py +160 -0
- kairopsis/app.py +235 -0
- kairopsis/catalogue.py +93 -0
- kairopsis/config.py +74 -0
- kairopsis/docs/agent-setup.md +154 -0
- kairopsis/docs/human-guide.md +93 -0
- kairopsis/evaluation_comparison.py +65 -0
- kairopsis/fixtures.py +72 -0
- kairopsis/frequencies.py +27 -0
- kairopsis/live_config.py +46 -0
- kairopsis/metapyle_adapter.py +148 -0
- kairopsis/metapyle_catalogue.py +62 -0
- kairopsis/models.py +292 -0
- kairopsis/repository.py +221 -0
- kairopsis/risk.py +130 -0
- kairopsis/service.py +141 -0
- kairopsis/static/charts.js +421 -0
- kairopsis/static/icon.svg +1 -0
- kairopsis/static/research.css +650 -0
- kairopsis/static/research.js +809 -0
- kairopsis/static/vendor/AG-GRID-LICENSE.txt +21 -0
- kairopsis/static/vendor/PLOTLY-LICENSE.txt +21 -0
- kairopsis/static/vendor/ag-grid-community.min.js +1 -0
- kairopsis/static/vendor/ag-grid.css +7029 -0
- kairopsis/static/vendor/ag-theme-quartz.css +1187 -0
- kairopsis/static/vendor/manifest.json +44 -0
- kairopsis/static/vendor/plotly.min.js +3882 -0
- kairopsis/templates/app.html +19 -0
- kairopsis-0.1.0.dist-info/METADATA +47 -0
- kairopsis-0.1.0.dist-info/RECORD +33 -0
- kairopsis-0.1.0.dist-info/WHEEL +4 -0
- kairopsis-0.1.0.dist-info/entry_points.txt +3 -0
kairopsis/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Relative-value idea generation powered by metapyle."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def main() -> None:
|
|
5
|
+
"""Launch the installed application without runtime dependency resolution."""
|
|
6
|
+
import logging
|
|
7
|
+
from logging.handlers import RotatingFileHandler
|
|
8
|
+
|
|
9
|
+
import uvicorn
|
|
10
|
+
|
|
11
|
+
from .app import create_app
|
|
12
|
+
from .config import load_settings
|
|
13
|
+
|
|
14
|
+
settings = load_settings()
|
|
15
|
+
for path in (settings.config_dir, settings.cache_dir, settings.log_dir):
|
|
16
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
logging.getLogger("kairopsis").addHandler(RotatingFileHandler(
|
|
18
|
+
settings.log_dir / "kairopsis.log", maxBytes=1_000_000, backupCount=2, encoding="utf-8"
|
|
19
|
+
))
|
|
20
|
+
uvicorn.run(create_app(settings), host=settings.host, port=settings.port)
|
kairopsis/analytics.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Exact-date metrics, input risk normalization and intercept OLS."""
|
|
2
|
+
from bisect import bisect_right
|
|
3
|
+
from datetime import date, datetime
|
|
4
|
+
from math import fsum, isfinite
|
|
5
|
+
from typing import cast
|
|
6
|
+
from .frequencies import expected_session, horizon_target, previous_session, shift_years
|
|
7
|
+
from .risk import transform_inputs
|
|
8
|
+
from .models import AdjustmentEstimate, DataRequest, DataResponse, Evaluation, Fit, MetricPoint, ResolvedDefinition
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def condition_rules(definition: ResolvedDefinition, unit: str, percentile: float | None, change: float | None, eligible: bool) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
|
12
|
+
reasons, keys = [], []
|
|
13
|
+
settings = definition.settings
|
|
14
|
+
if eligible and percentile is not None and (percentile >= settings.upper_percentile or percentile <= 100 - settings.upper_percentile):
|
|
15
|
+
reasons.append(f"Historical standing {percentile:.1f}th percentile / {settings.history_years}y")
|
|
16
|
+
keys.append("upper" if percentile >= settings.upper_percentile else "lower")
|
|
17
|
+
if eligible and change is not None and settings.move_threshold is not None and abs(change) >= settings.move_threshold:
|
|
18
|
+
reasons.append(f"{change:+.2f} {unit} / {settings.horizon} meets configured {settings.move_threshold:g} {unit} rule")
|
|
19
|
+
keys.append("move")
|
|
20
|
+
return tuple(reasons), tuple(keys)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def regression(points: list[MetricPoint], current: date, definition: ResolvedDefinition, years: int) -> Fit | None:
|
|
24
|
+
start = shift_years(current, -years)
|
|
25
|
+
sample = [p for p in points if p.eligible and start <= p.date < current]
|
|
26
|
+
if len(sample) < definition.settings.minimum_fit or (sample[0].date - start).days > 7 or (previous_session(current) - sample[-1].date).days > 7:
|
|
27
|
+
return None
|
|
28
|
+
xs = [cast(float, (p.transformed_inputs or p.inputs)[1]) for p in sample]
|
|
29
|
+
ys = [cast(float, (p.transformed_inputs or p.inputs)[0]) for p in sample]
|
|
30
|
+
xm, ym = fsum(xs) / len(xs), fsum(ys) / len(ys)
|
|
31
|
+
xx = fsum((x - xm) ** 2 for x in xs)
|
|
32
|
+
if not xx:
|
|
33
|
+
return None
|
|
34
|
+
slope = fsum((x - xm) * (y - ym) for x, y in zip(xs, ys)) / xx
|
|
35
|
+
intercept = ym - slope * xm
|
|
36
|
+
yy = fsum((y - ym) ** 2 for y in ys)
|
|
37
|
+
sse = fsum((y - intercept - slope * x) ** 2 for x, y in zip(xs, ys))
|
|
38
|
+
if not all(isfinite(v) for v in (slope, intercept, sse, yy)):
|
|
39
|
+
return None
|
|
40
|
+
return Fit(slope=slope, intercept=intercept, r_squared=1-sse/yy if yy else None,
|
|
41
|
+
sample_count=len(sample), start=sample[0].date, end=sample[-1].date, x_min=min(xs), x_max=max(xs))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def evaluate(definition: ResolvedDefinition, data: DataResponse, request: DataRequest, now: datetime) -> Evaluation:
|
|
45
|
+
results = {s.binding.id: s for s in data.series}
|
|
46
|
+
bindings = (*definition.inputs, *definition.references)
|
|
47
|
+
if data.failures or any(b.id not in results for b in bindings):
|
|
48
|
+
raise ValueError("Retrieval incomplete: " + "; ".join(f.message for f in data.failures))
|
|
49
|
+
all_rows = {}
|
|
50
|
+
for binding in bindings:
|
|
51
|
+
result = results[binding.id]
|
|
52
|
+
if result.binding != binding:
|
|
53
|
+
raise ValueError("Provider returned a different resolved binding")
|
|
54
|
+
dates = [p.date for p in result.observations]
|
|
55
|
+
if len(dates) != len(set(dates)):
|
|
56
|
+
raise ValueError("Duplicate daily observations")
|
|
57
|
+
all_rows[binding.id] = {p.date: p for p in result.observations if request.start <= p.date <= request.end}
|
|
58
|
+
rows = [all_rows[b.id] for b in definition.inputs]
|
|
59
|
+
days = sorted(set().union(*(r.keys() for r in rows)))
|
|
60
|
+
transformed: dict[date, tuple[float | None, ...]] = {}
|
|
61
|
+
scales: dict[date, tuple[float | None, ...]] = {}
|
|
62
|
+
starts: dict[date, tuple[date | None, ...]] = {}
|
|
63
|
+
input_units = tuple(b.unit for b in definition.inputs)
|
|
64
|
+
estimates: tuple[AdjustmentEstimate, ...] = ()
|
|
65
|
+
if definition.settings.measure != "level":
|
|
66
|
+
transformed, scales, starts, input_units, estimates = transform_inputs(definition, all_rows, days)
|
|
67
|
+
method = definition.calculation
|
|
68
|
+
if method == "difference" and input_units[0] != input_units[1]:
|
|
69
|
+
raise ValueError("Difference requires matching units; no implicit conversion")
|
|
70
|
+
unit = input_units[0]
|
|
71
|
+
if method == "ratio":
|
|
72
|
+
unit = "×" if unit == input_units[1] else f"{unit}/{input_units[1]}"
|
|
73
|
+
points = []
|
|
74
|
+
limits = []
|
|
75
|
+
for day in days:
|
|
76
|
+
inputs = tuple(r[day].value if day in r else None for r in rows)
|
|
77
|
+
observed = tuple(r[day].observed_on if day in r else None for r in rows)
|
|
78
|
+
measured = transformed.get(day, inputs)
|
|
79
|
+
usable = all(v is not None for v in measured)
|
|
80
|
+
eligible = usable and all(d == day for d in observed) and day.weekday() < 5
|
|
81
|
+
value = None
|
|
82
|
+
if usable:
|
|
83
|
+
left = cast(float, measured[0])
|
|
84
|
+
if method in ("level", "regression"):
|
|
85
|
+
value = left
|
|
86
|
+
elif method == "difference":
|
|
87
|
+
value = left - cast(float, measured[1])
|
|
88
|
+
elif measured[1]:
|
|
89
|
+
value = left / cast(float, measured[1])
|
|
90
|
+
if value is not None and not isfinite(value):
|
|
91
|
+
value = None
|
|
92
|
+
points.append(MetricPoint(date=day, value=value, inputs=inputs, observed_on=observed, eligible=eligible and value is not None,
|
|
93
|
+
transformed_inputs=transformed.get(day, ()), risk_scales=scales.get(day, ()), period_start=starts.get(day, ())))
|
|
94
|
+
fit = None
|
|
95
|
+
sensitivity = []
|
|
96
|
+
current_day = points[-1].date if points else None
|
|
97
|
+
if method == "regression" and current_day:
|
|
98
|
+
fit = regression(points, current_day, definition, definition.settings.fit_years)
|
|
99
|
+
if not fit:
|
|
100
|
+
limits.append("Fit unavailable: insufficient full-window history or constant explanatory input")
|
|
101
|
+
else:
|
|
102
|
+
limits.append("Residual history uses the current fit retrospectively; association is descriptive")
|
|
103
|
+
x = (points[-1].transformed_inputs or points[-1].inputs)[1]
|
|
104
|
+
if x is not None and not fit.x_min <= x <= fit.x_max:
|
|
105
|
+
limits.append("Current explanatory input is outside fit range (extrapolation)")
|
|
106
|
+
selected = (points[-1].transformed_inputs or points[-1].inputs)[0]
|
|
107
|
+
for years in (1, 5):
|
|
108
|
+
alternative = regression(points, current_day, definition, years)
|
|
109
|
+
if not alternative:
|
|
110
|
+
sensitivity.append(f"{years}y comparison unavailable: insufficient fit history")
|
|
111
|
+
elif selected is not None and x is not None:
|
|
112
|
+
a = selected - fit.intercept - fit.slope * x
|
|
113
|
+
b = selected - alternative.intercept - alternative.slope * x
|
|
114
|
+
if a * b < 0:
|
|
115
|
+
sensitivity.append(f"Residual direction changes under {years}y fit")
|
|
116
|
+
points = [p.model_copy(update={"value": (cast(float, (p.transformed_inputs or p.inputs)[0]) - fit.intercept - fit.slope * cast(float, (p.transformed_inputs or p.inputs)[1]))
|
|
117
|
+
if fit and all(v is not None for v in (p.transformed_inputs or p.inputs)) else None,
|
|
118
|
+
"eligible": p.eligible and fit is not None}) for p in points]
|
|
119
|
+
current = points[-1] if points else None
|
|
120
|
+
for binding, estimate in zip(definition.inputs, estimates):
|
|
121
|
+
if estimate.limitation:
|
|
122
|
+
limits.append(f"{binding.name}: {estimate.limitation}")
|
|
123
|
+
percentile = change = None
|
|
124
|
+
change_start = None
|
|
125
|
+
eligible = bool(current and current.eligible and current.date == expected_session(request.end))
|
|
126
|
+
if not current or current.value is None:
|
|
127
|
+
limits.append("Current calculation unavailable (missing input or zero denominator)")
|
|
128
|
+
elif not current.eligible:
|
|
129
|
+
limits.append("Missing, carried, unknown or mismatched native observations; excluded from findings")
|
|
130
|
+
if current and current.date < expected_session(request.end):
|
|
131
|
+
limits.append(f"Stale observations: latest row {current.date}; requested as of {request.end}")
|
|
132
|
+
if data.outcome not in ("synthetic", "fresh"):
|
|
133
|
+
eligible = False
|
|
134
|
+
limits.append("Upstream retrieval freshness unverified; excluded from findings")
|
|
135
|
+
if current and current.value is not None:
|
|
136
|
+
value = current.value
|
|
137
|
+
history = [p.value for p in points if p.eligible and p.value is not None and shift_years(current.date, -definition.settings.history_years) <= p.date < current.date]
|
|
138
|
+
if len(history) >= definition.settings.minimum_history:
|
|
139
|
+
percentile = 100 * (sum(v < value for v in history) + .5 * sum(v == value for v in history)) / len(history)
|
|
140
|
+
else:
|
|
141
|
+
eligible = False
|
|
142
|
+
limits.append(f"Insufficient reference history: {len(history)}; require {definition.settings.minimum_history}")
|
|
143
|
+
target = horizon_target(current.date, definition.settings.horizon)
|
|
144
|
+
valid = [p for p in points if p.eligible and p.value is not None]
|
|
145
|
+
idx = bisect_right([p.date for p in valid], target) - 1
|
|
146
|
+
if definition.settings.measure != "level" and current.eligible:
|
|
147
|
+
change = value
|
|
148
|
+
change_start = min((d for d in current.period_start if d is not None), default=None)
|
|
149
|
+
elif idx >= 0 and (target - valid[idx].date).days <= (0 if definition.settings.horizon == "day" else 3) and current.eligible:
|
|
150
|
+
change = value - cast(float, valid[idx].value)
|
|
151
|
+
change_start = valid[idx].date
|
|
152
|
+
else:
|
|
153
|
+
limits.append(f"{definition.settings.horizon.title()} change unavailable: missing eligible baseline")
|
|
154
|
+
reasons, keys = condition_rules(definition, unit, percentile, change, eligible)
|
|
155
|
+
return Evaluation(definition=definition, request=request, data=data, evaluated_at=now, points=tuple(points), unit=unit,
|
|
156
|
+
input_units=input_units, adjustment_estimates=estimates,
|
|
157
|
+
current=current.value if current else None, observation_date=current.date if current else None,
|
|
158
|
+
input_dates=current.observed_on if current else (), percentile=percentile, change=change, change_start=change_start,
|
|
159
|
+
eligible=eligible, limitations=tuple(limits), fit=fit, sensitivity=tuple(sensitivity), reasons=reasons, conditions=reasons, condition_keys=keys,
|
|
160
|
+
finding="condition" if reasons else "quiet" if eligible else "unavailable")
|
kairopsis/app.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""One supported three-destination application; all evidence passes real persistence."""
|
|
2
|
+
import base64
|
|
3
|
+
import hashlib
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
10
|
+
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
|
|
11
|
+
from fastapi.staticfiles import StaticFiles
|
|
12
|
+
from fastapi.templating import Jinja2Templates
|
|
13
|
+
from pydantic import BaseModel, Field
|
|
14
|
+
|
|
15
|
+
from .catalogue import Catalogue
|
|
16
|
+
from .config import Settings
|
|
17
|
+
from .fixtures import MockMarketData
|
|
18
|
+
from .metapyle_adapter import MetapyleMarketData
|
|
19
|
+
from .models import AnalysisDefinition, DisplaySettings, SeriesBinding
|
|
20
|
+
from .repository import Repository
|
|
21
|
+
from .service import MarketData, Research
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CaptureRequest(BaseModel):
|
|
25
|
+
evaluation_id: str
|
|
26
|
+
display: DisplaySettings = Field(default_factory=DisplaySettings)
|
|
27
|
+
image: str = Field(max_length=16_000_000)
|
|
28
|
+
title: str = Field(default="", max_length=200)
|
|
29
|
+
thought: str = Field(default="", max_length=50000)
|
|
30
|
+
idea_id: str | None = None
|
|
31
|
+
note: str = Field(default="", max_length=50000)
|
|
32
|
+
mode: Literal["saved", "latest", "investigation"] = "investigation"
|
|
33
|
+
|
|
34
|
+
def png(self) -> bytes:
|
|
35
|
+
try:
|
|
36
|
+
return base64.b64decode(self.image.removeprefix("data:image/png;base64,"), validate=True)
|
|
37
|
+
except ValueError:
|
|
38
|
+
raise ValueError("Invalid PNG encoding") from None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class IdeaEdit(BaseModel):
|
|
42
|
+
title: str | None = Field(default=None, min_length=1, max_length=200)
|
|
43
|
+
thought: str | None = Field(default=None, max_length=50000)
|
|
44
|
+
shortlisted: bool | None = None
|
|
45
|
+
archived: bool | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class NoteEdit(BaseModel):
|
|
49
|
+
text: str = Field(max_length=50000)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def create_app(settings: Settings | None = None, market_data: MarketData | None = None,
|
|
53
|
+
clock: Callable[[], datetime] | None = None) -> FastAPI:
|
|
54
|
+
settings = settings or Settings()
|
|
55
|
+
clock = clock or (lambda: datetime.now(timezone.utc))
|
|
56
|
+
repository = Repository(settings.workspace, settings.mode)
|
|
57
|
+
catalogue = Catalogue(repository)
|
|
58
|
+
provider = market_data or (MockMarketData(clock) if settings.mode == "mock" else MetapyleMarketData(settings, clock))
|
|
59
|
+
research = Research(repository, catalogue, provider, clock, settings.timezone)
|
|
60
|
+
app = FastAPI(title="Kairopsis", docs_url=None, redoc_url=None)
|
|
61
|
+
app.state.repository, app.state.research = repository, research
|
|
62
|
+
app.state.settings = settings
|
|
63
|
+
package = Path(__file__).resolve().parent
|
|
64
|
+
templates = Jinja2Templates(directory=package / "templates")
|
|
65
|
+
app.mount("/static", StaticFiles(directory=package / "static"), name="static")
|
|
66
|
+
|
|
67
|
+
def asset_url(name: str) -> str:
|
|
68
|
+
version = hashlib.sha256((package / "static" / name).read_bytes()).hexdigest()[:16]
|
|
69
|
+
return f"/static/{name}?v={version}"
|
|
70
|
+
|
|
71
|
+
templates.env.globals["asset_url"] = asset_url
|
|
72
|
+
|
|
73
|
+
@app.exception_handler(FileNotFoundError)
|
|
74
|
+
async def missing(request: Request, error: FileNotFoundError):
|
|
75
|
+
return JSONResponse(status_code=404, content={"detail": "Record not found; existing evidence preserved"})
|
|
76
|
+
|
|
77
|
+
@app.exception_handler(ValueError)
|
|
78
|
+
async def invalid(request: Request, error: ValueError):
|
|
79
|
+
return JSONResponse(status_code=422, content={"detail": str(error)})
|
|
80
|
+
|
|
81
|
+
@app.exception_handler(OSError)
|
|
82
|
+
async def write_failed(request: Request, error: OSError):
|
|
83
|
+
return JSONResponse(status_code=503, content={"detail": "Evidence could not be read/saved. Check writable workspace and free disk space; retry. Export is incomplete."})
|
|
84
|
+
|
|
85
|
+
def page(request, name):
|
|
86
|
+
research.first_open()
|
|
87
|
+
return templates.TemplateResponse(request=request, name="app.html", context={"page": name, "mode": settings.mode})
|
|
88
|
+
|
|
89
|
+
@app.get("/", response_class=HTMLResponse)
|
|
90
|
+
def root():
|
|
91
|
+
return RedirectResponse("/analyses")
|
|
92
|
+
|
|
93
|
+
@app.get("/analyses", response_class=HTMLResponse)
|
|
94
|
+
def analyses_page(request: Request):
|
|
95
|
+
return page(request, "analyses")
|
|
96
|
+
|
|
97
|
+
@app.get("/analyses/{key}", response_class=HTMLResponse)
|
|
98
|
+
def chart_page(request: Request, key: str):
|
|
99
|
+
catalogue.resolve(key)
|
|
100
|
+
return page(request, "chart")
|
|
101
|
+
|
|
102
|
+
@app.get("/ideas", response_class=HTMLResponse)
|
|
103
|
+
def ideas_page(request: Request):
|
|
104
|
+
return page(request, "ideas")
|
|
105
|
+
|
|
106
|
+
@app.get("/ideas/{key}", response_class=HTMLResponse)
|
|
107
|
+
def idea_page(request: Request, key: str):
|
|
108
|
+
repository.get_idea(key)
|
|
109
|
+
return page(request, "idea")
|
|
110
|
+
|
|
111
|
+
@app.get("/library", response_class=HTMLResponse)
|
|
112
|
+
def library_page(request: Request):
|
|
113
|
+
return page(request, "library")
|
|
114
|
+
|
|
115
|
+
@app.get("/api/analyses")
|
|
116
|
+
def rows(scope: Literal["all", "flagged"] = "flagged", q: str = "", kind: Literal["all", "standalone", "pair"] = "all"):
|
|
117
|
+
research.first_open()
|
|
118
|
+
return research.rows(scope, q, kind)
|
|
119
|
+
|
|
120
|
+
@app.post("/api/refresh")
|
|
121
|
+
def refresh():
|
|
122
|
+
return research.refresh()
|
|
123
|
+
|
|
124
|
+
@app.get("/api/library")
|
|
125
|
+
def library():
|
|
126
|
+
return catalogue.records()
|
|
127
|
+
|
|
128
|
+
@app.post("/api/library/series")
|
|
129
|
+
def save_series(record: SeriesBinding):
|
|
130
|
+
return catalogue.save("series", record)
|
|
131
|
+
|
|
132
|
+
@app.post("/api/library/analyses")
|
|
133
|
+
def save_analysis(record: AnalysisDefinition):
|
|
134
|
+
return catalogue.save("analyses", record)
|
|
135
|
+
|
|
136
|
+
@app.delete("/api/library/{kind}/{key}")
|
|
137
|
+
def delete_catalogue(kind: Literal["series", "analyses"], key: str):
|
|
138
|
+
catalogue.delete(kind, key)
|
|
139
|
+
return {"ok": True}
|
|
140
|
+
|
|
141
|
+
@app.post("/api/analyses/{key}/preview")
|
|
142
|
+
def preview(key: str, options: dict):
|
|
143
|
+
return research.preview(key, options)
|
|
144
|
+
|
|
145
|
+
@app.get("/api/evaluations/{key}")
|
|
146
|
+
def evaluation(key: str):
|
|
147
|
+
return repository.get_evaluation(key)
|
|
148
|
+
|
|
149
|
+
@app.post("/api/ideas")
|
|
150
|
+
def add_chart(body: CaptureRequest):
|
|
151
|
+
evaluation = repository.get_evaluation(body.evaluation_id)
|
|
152
|
+
return repository.add_chart(evaluation, body.display, body.png(), clock(), body.title, body.thought, body.idea_id, body.note)
|
|
153
|
+
|
|
154
|
+
@app.get("/api/ideas")
|
|
155
|
+
def ideas(q: str = "", scope: Literal["active", "shortlist", "archived"] = "active"):
|
|
156
|
+
found = []
|
|
157
|
+
for idea in repository.list_ideas():
|
|
158
|
+
text = " ".join([idea.title, idea.thought, *[n.text for n in idea.notes], *[c.note for c in idea.charts]])
|
|
159
|
+
if q.casefold() not in text.casefold():
|
|
160
|
+
continue
|
|
161
|
+
if idea.archived != (scope == "archived") or (scope == "shortlist" and not idea.shortlisted):
|
|
162
|
+
continue
|
|
163
|
+
found.append({"idea": idea.model_dump(mode="json"), "changes": research.idea_changes(idea)})
|
|
164
|
+
return found
|
|
165
|
+
|
|
166
|
+
@app.get("/api/ideas/{key}")
|
|
167
|
+
def idea(key: str):
|
|
168
|
+
result = repository.get_idea(key)
|
|
169
|
+
return {"idea": result.model_dump(mode="json"), "snapshots": {c.id: repository.get_snapshot(key, c.snapshot_id).model_dump(mode="json") for c in result.charts},
|
|
170
|
+
"changes": research.idea_changes(result)}
|
|
171
|
+
|
|
172
|
+
@app.patch("/api/ideas/{key}")
|
|
173
|
+
def edit_idea(key: str, body: IdeaEdit):
|
|
174
|
+
return repository.update_idea(key, body.model_dump(exclude_none=True), clock())
|
|
175
|
+
|
|
176
|
+
@app.post("/api/ideas/{key}/notes")
|
|
177
|
+
def add_note(key: str, body: NoteEdit):
|
|
178
|
+
now = clock()
|
|
179
|
+
return repository.modify_idea(key, lambda idea: idea.add_note(body.text, now), now)
|
|
180
|
+
|
|
181
|
+
@app.patch("/api/ideas/{key}/notes/{note_id}")
|
|
182
|
+
def edit_note(key: str, note_id: str, body: NoteEdit):
|
|
183
|
+
now = clock()
|
|
184
|
+
return repository.modify_idea(key, lambda idea: idea.change_note(note_id, body.text, now), now)
|
|
185
|
+
|
|
186
|
+
@app.delete("/api/ideas/{key}/notes/{note_id}")
|
|
187
|
+
def delete_note(key: str, note_id: str):
|
|
188
|
+
now = clock()
|
|
189
|
+
return repository.modify_idea(key, lambda idea: idea.change_note(note_id, None, now), now)
|
|
190
|
+
|
|
191
|
+
@app.patch("/api/ideas/{key}/charts/{entry_id}")
|
|
192
|
+
def chart_note(key: str, entry_id: str, body: NoteEdit):
|
|
193
|
+
now = clock()
|
|
194
|
+
return repository.modify_idea(key, lambda idea: idea.annotate_chart(entry_id, body.text, now), now)
|
|
195
|
+
|
|
196
|
+
@app.delete("/api/ideas/{key}/charts/{entry_id}")
|
|
197
|
+
def remove_chart(key: str, entry_id: str):
|
|
198
|
+
return repository.modify_idea(key, lambda idea: idea.remove_chart(entry_id), clock())
|
|
199
|
+
|
|
200
|
+
def chart_snapshot(key, entry_id):
|
|
201
|
+
result = repository.get_idea(key)
|
|
202
|
+
entry = next((c for c in result.charts if c.id == entry_id), None)
|
|
203
|
+
if not entry:
|
|
204
|
+
raise FileNotFoundError()
|
|
205
|
+
return repository.get_snapshot(key, entry.snapshot_id)
|
|
206
|
+
|
|
207
|
+
@app.post("/api/ideas/{key}/charts/{entry_id}/latest")
|
|
208
|
+
def latest(key: str, entry_id: str):
|
|
209
|
+
saved = chart_snapshot(key, entry_id)
|
|
210
|
+
try:
|
|
211
|
+
return {"evaluation": research.evaluate(saved.evaluation.definition)}
|
|
212
|
+
except (ValueError, OSError) as error:
|
|
213
|
+
return {"unavailable": str(error)}
|
|
214
|
+
|
|
215
|
+
@app.get("/api/ideas/{key}/snapshots/{snapshot_id}/image")
|
|
216
|
+
def image(key: str, snapshot_id: str):
|
|
217
|
+
return Response(repository.snapshot_image(key, snapshot_id), media_type="image/png")
|
|
218
|
+
|
|
219
|
+
@app.post("/api/ideas/{key}/charts/{entry_id}/export")
|
|
220
|
+
def export(key: str, entry_id: str, body: CaptureRequest):
|
|
221
|
+
saved = chart_snapshot(key, entry_id)
|
|
222
|
+
evaluation = saved.evaluation if body.mode == "saved" else repository.get_evaluation(body.evaluation_id)
|
|
223
|
+
if body.mode == "saved" and body.evaluation_id != saved.evaluation.id:
|
|
224
|
+
raise ValueError("Export must match displayed saved evidence")
|
|
225
|
+
if (body.mode == "saved" and evaluation.id != saved.evaluation.id) or (body.mode == "latest" and evaluation.definition != saved.evaluation.definition) or body.mode == "investigation":
|
|
226
|
+
raise ValueError("Export must match displayed saved evidence or its preserved Latest definition")
|
|
227
|
+
if (body.display.years, body.display.view) != (saved.display.years, saved.display.view):
|
|
228
|
+
raise ValueError("Export must preserve displayed settings")
|
|
229
|
+
png = body.png()
|
|
230
|
+
with repository.transaction():
|
|
231
|
+
snapshot = repository.capture(key, evaluation, body.display, png, body.mode, clock())
|
|
232
|
+
return Response(png, media_type="image/png", headers={"X-Snapshot-ID": snapshot.id,
|
|
233
|
+
"Content-Disposition": 'attachment; filename="kairopsis-chart.png"'})
|
|
234
|
+
|
|
235
|
+
return app
|
kairopsis/catalogue.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Small active catalogue. Binding changes advance affected resolved definitions."""
|
|
2
|
+
from .fixtures import analysis_fixture, series_fixture
|
|
3
|
+
from .models import AnalysisDefinition, AnalysisSettings, ResolvedDefinition, SeriesBinding
|
|
4
|
+
from .repository import Repository
|
|
5
|
+
from .risk import adjusted_unit
|
|
6
|
+
from .metapyle_catalogue import publish_catalogue
|
|
7
|
+
from .repository import atomic_bytes
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Catalogue:
|
|
11
|
+
def __init__(self, repository: Repository):
|
|
12
|
+
self.repository = repository
|
|
13
|
+
with repository.transaction():
|
|
14
|
+
if repository.read_json("catalogue") is None:
|
|
15
|
+
repository.write_json("catalogue", {
|
|
16
|
+
"series": [s.model_dump(mode="json") for s in series_fixture()] if repository.mode == "mock" else [],
|
|
17
|
+
"analyses": [a.model_dump(mode="json") for a in analysis_fixture()] if repository.mode == "mock" else []})
|
|
18
|
+
self._publish(self.records())
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def metapyle_path(self):
|
|
22
|
+
return self.repository.root / "metapyle.yaml"
|
|
23
|
+
|
|
24
|
+
def _publish(self, records: dict) -> None:
|
|
25
|
+
publish_catalogue(self.metapyle_path, tuple(SeriesBinding.model_validate(s) for s in records["series"]))
|
|
26
|
+
|
|
27
|
+
def _commit(self, records: dict) -> None:
|
|
28
|
+
# JSON publishes metadata/analyses together. YAML is a recoverable
|
|
29
|
+
# projection, restored on a failed commit and rebuilt at startup.
|
|
30
|
+
previous = self.metapyle_path.read_bytes()
|
|
31
|
+
self._publish(records)
|
|
32
|
+
try:
|
|
33
|
+
self.repository.write_json("catalogue", records)
|
|
34
|
+
except OSError:
|
|
35
|
+
atomic_bytes(self.metapyle_path, previous)
|
|
36
|
+
raise
|
|
37
|
+
|
|
38
|
+
def records(self) -> dict:
|
|
39
|
+
return self.repository.read_json("catalogue")
|
|
40
|
+
|
|
41
|
+
def resolve(self, key: str, settings: AnalysisSettings | None = None) -> ResolvedDefinition:
|
|
42
|
+
records = self.records()
|
|
43
|
+
analysis = next((AnalysisDefinition.model_validate(a) for a in records["analyses"] if a["id"] == key), None)
|
|
44
|
+
if not analysis:
|
|
45
|
+
raise FileNotFoundError("Analysis not found")
|
|
46
|
+
series = {s["id"]: SeriesBinding.model_validate(s) for s in records["series"]}
|
|
47
|
+
if settings is not None:
|
|
48
|
+
analysis = AnalysisDefinition.model_validate({**analysis.model_dump(), "settings": settings})
|
|
49
|
+
reference_ids = risk_references(analysis)
|
|
50
|
+
if any(i not in series for i in reference_ids):
|
|
51
|
+
raise ValueError("Choose an existing risk reference series")
|
|
52
|
+
return ResolvedDefinition(id=analysis.id, revision=analysis.revision, name=analysis.name,
|
|
53
|
+
calculation=analysis.calculation, settings=analysis.settings, inputs=tuple(series[i] for i in analysis.series_ids),
|
|
54
|
+
references=tuple(series[i] for i in sorted(reference_ids - set(analysis.series_ids))))
|
|
55
|
+
|
|
56
|
+
def save(self, kind: str, record: SeriesBinding | AnalysisDefinition) -> dict:
|
|
57
|
+
with self.repository.transaction():
|
|
58
|
+
records = self.records()
|
|
59
|
+
old = next((r for r in records[kind] if r["id"] == record.id), None)
|
|
60
|
+
value = record.model_dump(mode="json")
|
|
61
|
+
value["revision"] = old["revision"] + 1 if old else 1
|
|
62
|
+
if kind == "analyses":
|
|
63
|
+
definition = AnalysisDefinition.model_validate(value)
|
|
64
|
+
series = {s["id"]: s for s in records["series"]}
|
|
65
|
+
if any(i not in series for i in definition.series_ids):
|
|
66
|
+
raise ValueError("Choose existing input series")
|
|
67
|
+
if any(i not in series for i in risk_references(definition)):
|
|
68
|
+
raise ValueError("Choose an existing risk reference series")
|
|
69
|
+
units = [adjusted_unit(series[i]["unit"], series[r.reference_id or i]["unit"] if r.method != "none" else series[i]["unit"],
|
|
70
|
+
r.method, definition.settings.measure) for i, r in zip(definition.series_ids, definition.settings.adjustments(len(definition.series_ids)))]
|
|
71
|
+
if definition.calculation == "difference" and len(set(units)) != 1:
|
|
72
|
+
raise ValueError("Difference requires matching units")
|
|
73
|
+
elif old:
|
|
74
|
+
records["analyses"] = [{**a, "revision": a["revision"] + 1} if record.id in set(a["series_ids"]) | risk_references(AnalysisDefinition.model_validate(a)) else a for a in records["analyses"]]
|
|
75
|
+
records[kind] = [value if r["id"] == record.id else r for r in records[kind]] if old else [*records[kind], value]
|
|
76
|
+
self._commit(records)
|
|
77
|
+
return value
|
|
78
|
+
|
|
79
|
+
def delete(self, kind: str, key: str) -> None:
|
|
80
|
+
with self.repository.transaction():
|
|
81
|
+
records = self.records()
|
|
82
|
+
if not any(r["id"] == key for r in records[kind]):
|
|
83
|
+
raise FileNotFoundError("Catalogue entry not found")
|
|
84
|
+
dependencies = [a["name"] for a in records["analyses"] if key in set(a["series_ids"]) | risk_references(AnalysisDefinition.model_validate(a))] if kind == "series" else []
|
|
85
|
+
if dependencies:
|
|
86
|
+
raise ValueError("Used by: " + ", ".join(dependencies))
|
|
87
|
+
records[kind] = [r for r in records[kind] if r["id"] != key]
|
|
88
|
+
self._commit(records)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def risk_references(analysis: AnalysisDefinition) -> set[str]:
|
|
92
|
+
return {r.reference_id for r in analysis.settings.adjustments(len(analysis.series_ids))
|
|
93
|
+
if r.method != "none" and r.reference_id is not None}
|
kairopsis/config.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""User-writable paths and launch settings; never infer paths from cwd."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import tomllib
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Literal
|
|
9
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
10
|
+
|
|
11
|
+
from platformdirs import user_cache_path, user_config_path, user_data_path, user_log_path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Settings:
|
|
16
|
+
workspace: Path = field(default_factory=lambda: user_data_path("Kairopsis", appauthor=False) / "mock")
|
|
17
|
+
config_dir: Path = field(default_factory=lambda: user_config_path("Kairopsis", appauthor=False))
|
|
18
|
+
cache_dir: Path = field(default_factory=lambda: user_cache_path("Kairopsis", appauthor=False))
|
|
19
|
+
log_dir: Path = field(default_factory=lambda: user_log_path("Kairopsis", appauthor=False))
|
|
20
|
+
mode: Literal["mock", "live"] = "mock"
|
|
21
|
+
host: str = "127.0.0.1"
|
|
22
|
+
port: int = 8765
|
|
23
|
+
timezone: str = "Europe/London"
|
|
24
|
+
|
|
25
|
+
def __post_init__(self) -> None:
|
|
26
|
+
package = Path(__file__).resolve().parent
|
|
27
|
+
for name in ("workspace", "config_dir", "cache_dir", "log_dir"):
|
|
28
|
+
path = Path(getattr(self, name)).expanduser()
|
|
29
|
+
if not path.is_absolute():
|
|
30
|
+
raise ValueError(f"{name} must be an absolute path, independent of the working directory")
|
|
31
|
+
path = path.resolve()
|
|
32
|
+
if path == package or package in path.parents:
|
|
33
|
+
raise ValueError(f"{name} must be outside the installed package")
|
|
34
|
+
object.__setattr__(self, name, path)
|
|
35
|
+
if self.mode not in ("mock", "live"):
|
|
36
|
+
raise ValueError("mode must be mock or live")
|
|
37
|
+
if not 1 <= self.port <= 65535:
|
|
38
|
+
raise ValueError("port must be between 1 and 65535")
|
|
39
|
+
try:
|
|
40
|
+
ZoneInfo(self.timezone)
|
|
41
|
+
except (ZoneInfoNotFoundError, ValueError):
|
|
42
|
+
raise ValueError("timezone must be an available IANA timezone, for example Europe/London") from None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_settings(argv: list[str] | None = None) -> Settings:
|
|
46
|
+
parser = argparse.ArgumentParser(description="Local Kairopsis research dashboard")
|
|
47
|
+
parser.add_argument("--config", type=Path, help="Absolute path to an optional TOML configuration")
|
|
48
|
+
parser.add_argument("--mode", choices=("mock", "live"))
|
|
49
|
+
parser.add_argument("--host")
|
|
50
|
+
parser.add_argument("--port", type=int)
|
|
51
|
+
parser.add_argument("--timezone")
|
|
52
|
+
for name in ("workspace", "config-dir", "cache-dir", "log-dir"):
|
|
53
|
+
parser.add_argument(f"--{name}", type=Path)
|
|
54
|
+
args = vars(parser.parse_args(argv))
|
|
55
|
+
config = args.pop("config") or os.environ.get("KAIROPSIS_CONFIG")
|
|
56
|
+
values: dict = {}
|
|
57
|
+
if config:
|
|
58
|
+
config_path = Path(config).expanduser()
|
|
59
|
+
if not config_path.is_absolute():
|
|
60
|
+
parser.error("--config must be an absolute path")
|
|
61
|
+
with config_path.open("rb") as stream:
|
|
62
|
+
values = tomllib.load(stream).get("kairopsis", {})
|
|
63
|
+
for name in args:
|
|
64
|
+
environment = os.environ.get(f"KAIROPSIS_{name.upper()}")
|
|
65
|
+
if environment is not None:
|
|
66
|
+
values[name] = int(environment) if name == "port" else environment
|
|
67
|
+
if args[name] is not None:
|
|
68
|
+
values[name] = args[name]
|
|
69
|
+
if "workspace" not in values:
|
|
70
|
+
values["workspace"] = user_data_path("Kairopsis", appauthor=False) / values.get("mode", "mock")
|
|
71
|
+
try:
|
|
72
|
+
return Settings(**values)
|
|
73
|
+
except (TypeError, ValueError) as error:
|
|
74
|
+
parser.error(str(error))
|