sessionwiki 2.0.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.
- sessionwiki/__init__.py +74 -0
- sessionwiki/__main__.py +6 -0
- sessionwiki/_frontmatter.py +118 -0
- sessionwiki/_system_pages.py +44 -0
- sessionwiki/adapter_config.py +124 -0
- sessionwiki/adapters/__init__.py +134 -0
- sessionwiki/adapters/base.py +191 -0
- sessionwiki/adapters/claude_code.py +77 -0
- sessionwiki/adapters/codex_cli.py +226 -0
- sessionwiki/adapters/contrib/__init__.py +1 -0
- sessionwiki/adapters/contrib/chatgpt.py +252 -0
- sessionwiki/adapters/contrib/copilot_chat.py +121 -0
- sessionwiki/adapters/contrib/copilot_cli.py +115 -0
- sessionwiki/adapters/contrib/cursor.py +100 -0
- sessionwiki/adapters/contrib/gemini_cli.py +74 -0
- sessionwiki/adapters/contrib/obsidian.py +121 -0
- sessionwiki/adapters/opencode.py +169 -0
- sessionwiki/adapters/status.py +58 -0
- sessionwiki/adapters/zcode.py +45 -0
- sessionwiki/audit.py +174 -0
- sessionwiki/backlinks.py +224 -0
- sessionwiki/build.py +2786 -0
- sessionwiki/cache.py +401 -0
- sessionwiki/candidates.py +309 -0
- sessionwiki/categories.py +158 -0
- sessionwiki/changelog_timeline.py +422 -0
- sessionwiki/cli.py +1687 -0
- sessionwiki/compare.py +404 -0
- sessionwiki/completion.py +191 -0
- sessionwiki/confidence.py +179 -0
- sessionwiki/config.py +74 -0
- sessionwiki/config_schedule.py +77 -0
- sessionwiki/context_md.py +182 -0
- sessionwiki/convert.py +1746 -0
- sessionwiki/docs_pages.py +725 -0
- sessionwiki/doctor.py +113 -0
- sessionwiki/exporters.py +680 -0
- sessionwiki/freshness.py +153 -0
- sessionwiki/graph.py +1058 -0
- sessionwiki/graphify_bridge.py +509 -0
- sessionwiki/ingest_queue.py +101 -0
- sessionwiki/lifecycle.py +167 -0
- sessionwiki/link_checker.py +148 -0
- sessionwiki/lint/__init__.py +170 -0
- sessionwiki/lint/rules/__init__.py +69 -0
- sessionwiki/lint/rules/_helpers.py +119 -0
- sessionwiki/lint/rules/claim_verification.py +44 -0
- sessionwiki/lint/rules/content_freshness.py +41 -0
- sessionwiki/lint/rules/contradiction_detection.py +45 -0
- sessionwiki/lint/rules/duplicate_detection.py +164 -0
- sessionwiki/lint/rules/entity_consistency.py +29 -0
- sessionwiki/lint/rules/frontmatter_completeness.py +46 -0
- sessionwiki/lint/rules/frontmatter_count_consistency.py +61 -0
- sessionwiki/lint/rules/frontmatter_validity.py +71 -0
- sessionwiki/lint/rules/index_sync.py +68 -0
- sessionwiki/lint/rules/link_integrity.py +34 -0
- sessionwiki/lint/rules/orphan_detection.py +43 -0
- sessionwiki/lint/rules/stale_candidates.py +59 -0
- sessionwiki/lint/rules/stale_reference_detection.py +42 -0
- sessionwiki/lint/rules/summary_accuracy.py +36 -0
- sessionwiki/lint/rules/tags_topics_convention.py +58 -0
- sessionwiki/lint/rules/tools_consistency.py +65 -0
- sessionwiki/lock.py +126 -0
- sessionwiki/log_reader.py +129 -0
- sessionwiki/manifest.py +132 -0
- sessionwiki/mcp/__init__.py +29 -0
- sessionwiki/mcp/__main__.py +5 -0
- sessionwiki/mcp/server.py +1061 -0
- sessionwiki/models_page.py +291 -0
- sessionwiki/obsidian_output.py +208 -0
- sessionwiki/opencode/__init__.py +1 -0
- sessionwiki/opencode/autosync.py +102 -0
- sessionwiki/opencode/export.py +419 -0
- sessionwiki/opencode/install.py +152 -0
- sessionwiki/pipeline.py +150 -0
- sessionwiki/project_topics.py +213 -0
- sessionwiki/py.typed +0 -0
- sessionwiki/quarantine.py +273 -0
- sessionwiki/queue.py +29 -0
- sessionwiki/references.py +224 -0
- sessionwiki/render/__init__.py +20 -0
- sessionwiki/render/css.py +988 -0
- sessionwiki/render/docs_css.py +463 -0
- sessionwiki/render/js.py +1337 -0
- sessionwiki/schema.py +270 -0
- sessionwiki/search_facets.py +150 -0
- sessionwiki/search_tree.py +184 -0
- sessionwiki/serve.py +91 -0
- sessionwiki/session_export.py +333 -0
- sessionwiki/skill_installer.py +112 -0
- sessionwiki/sync/__init__.py +13 -0
- sessionwiki/sync/status.py +120 -0
- sessionwiki/synth/__init__.py +14 -0
- sessionwiki/synth/agent_delegate.py +327 -0
- sessionwiki/synth/base.py +125 -0
- sessionwiki/synth/cli_helpers.py +98 -0
- sessionwiki/synth/estimate.py +146 -0
- sessionwiki/synth/ollama.py +379 -0
- sessionwiki/synth/openai_compat.py +325 -0
- sessionwiki/synth/pipeline.py +1489 -0
- sessionwiki/tag_utils.py +62 -0
- sessionwiki/tags.py +359 -0
- sessionwiki/vault.py +347 -0
- sessionwiki/viz_heatmap.py +305 -0
- sessionwiki/viz_tokens.py +429 -0
- sessionwiki/viz_tools.py +241 -0
- sessionwiki/watch.py +154 -0
- sessionwiki/zcode/__init__.py +43 -0
- sessionwiki/zcode/autosync.py +150 -0
- sessionwiki/zcode/export.py +568 -0
- sessionwiki/zcode/install.py +386 -0
- sessionwiki-2.0.0.dist-info/METADATA +303 -0
- sessionwiki-2.0.0.dist-info/RECORD +118 -0
- sessionwiki-2.0.0.dist-info/WHEEL +5 -0
- sessionwiki-2.0.0.dist-info/entry_points.txt +2 -0
- sessionwiki-2.0.0.dist-info/licenses/LICENSE +22 -0
- sessionwiki-2.0.0.dist-info/licenses/NOTICE +14 -0
- sessionwiki-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"""Append-only page changelog + timeline renderer (v0.7 · closes #56).
|
|
2
|
+
|
|
3
|
+
An entity page (especially AI-model pages from #55) can declare a
|
|
4
|
+
`changelog:` list in its frontmatter. Each entry records a dated change
|
|
5
|
+
to a specific field, so sessionwiki can render:
|
|
6
|
+
|
|
7
|
+
1. A **timeline widget** on the page showing what changed when
|
|
8
|
+
2. An inline **pricing sparkline** if the changelog contains ≥2 numeric
|
|
9
|
+
price changes
|
|
10
|
+
3. A **"Recently updated"** list on the home page surfacing entities
|
|
11
|
+
that changed in the last 30 days
|
|
12
|
+
|
|
13
|
+
Append-only by design — entries are never edited, only added. If a
|
|
14
|
+
change is wrong, you add a new correcting entry rather than rewriting
|
|
15
|
+
history. The wiki log doubles as an audit trail.
|
|
16
|
+
|
|
17
|
+
Schema (inline JSON per entry, list of objects in YAML):
|
|
18
|
+
|
|
19
|
+
```yaml
|
|
20
|
+
changelog: [
|
|
21
|
+
{"date": "2026-01-15", "event": "Input pricing reduced", "field": "model.pricing.input_per_1m", "from": 4.00, "to": 3.00},
|
|
22
|
+
{"date": "2026-02-02", "event": "Context window expanded", "field": "model.context_window", "from": 100000, "to": 200000}
|
|
23
|
+
]
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Stdlib-only. No new dependencies.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import html
|
|
32
|
+
import json
|
|
33
|
+
from datetime import date, datetime, timedelta, timezone
|
|
34
|
+
from typing import Any, Iterable, Mapping, Optional, TypedDict
|
|
35
|
+
|
|
36
|
+
# ─── types ───────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ChangelogEntry(TypedDict, total=False):
|
|
40
|
+
date: str # ISO date
|
|
41
|
+
event: str # Human-readable description
|
|
42
|
+
field: str # Dotted path into the entity's schema
|
|
43
|
+
# `from` and `to` can be numeric OR string — e.g. a license change
|
|
44
|
+
# goes from `"proprietary"` to `"apache-2.0"`, pricing goes from 4.00
|
|
45
|
+
# to 3.00. We keep them as raw values and leave interpretation to the
|
|
46
|
+
# renderer.
|
|
47
|
+
from_value: Any
|
|
48
|
+
to_value: Any
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ─── parsing ─────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_changelog(meta: Mapping[str, Any]) -> tuple[list[ChangelogEntry], list[str]]:
|
|
55
|
+
"""Pull a `changelog` list from frontmatter.
|
|
56
|
+
|
|
57
|
+
Returns `(entries, warnings)`. Entries are sorted ascending by date.
|
|
58
|
+
Malformed entries (missing date, unparseable date, missing event)
|
|
59
|
+
are dropped with a warning — the rest still render.
|
|
60
|
+
|
|
61
|
+
The frontmatter parser stores the value as either a JSON string
|
|
62
|
+
(our inline-JSON convention) or a Python list (tests / programmatic
|
|
63
|
+
callers). Both are handled.
|
|
64
|
+
"""
|
|
65
|
+
raw = meta.get("changelog")
|
|
66
|
+
if not raw:
|
|
67
|
+
return [], []
|
|
68
|
+
|
|
69
|
+
data: Any = raw
|
|
70
|
+
if isinstance(raw, str):
|
|
71
|
+
try:
|
|
72
|
+
data = json.loads(raw)
|
|
73
|
+
except ValueError:
|
|
74
|
+
return [], ["changelog must be a JSON list"]
|
|
75
|
+
|
|
76
|
+
# The lightweight frontmatter parser in `build.py` (and
|
|
77
|
+
# `models_page._parse_frontmatter`) naively splits bracketed list
|
|
78
|
+
# values on commas. That mangles a JSON array of objects like
|
|
79
|
+
# `[{"a":1, "b":2}, {"c":3}]` into
|
|
80
|
+
# `['{"a":1', '"b":2}', '{"c":3}']`. If we see a list of
|
|
81
|
+
# string fragments that don't parse as JSON objects on their own,
|
|
82
|
+
# stitch them back together and re-parse as a JSON array.
|
|
83
|
+
if (
|
|
84
|
+
isinstance(data, list)
|
|
85
|
+
and data
|
|
86
|
+
and all(isinstance(x, str) for x in data)
|
|
87
|
+
):
|
|
88
|
+
stitched = "[" + ", ".join(data) + "]"
|
|
89
|
+
try:
|
|
90
|
+
reparsed = json.loads(stitched)
|
|
91
|
+
if isinstance(reparsed, list):
|
|
92
|
+
data = reparsed
|
|
93
|
+
except ValueError:
|
|
94
|
+
pass # fall through; the element-level validator will reject
|
|
95
|
+
|
|
96
|
+
if not isinstance(data, list):
|
|
97
|
+
return [], ["changelog must be a list"]
|
|
98
|
+
|
|
99
|
+
warnings: list[str] = []
|
|
100
|
+
entries: list[ChangelogEntry] = []
|
|
101
|
+
for i, item in enumerate(data):
|
|
102
|
+
if not isinstance(item, dict):
|
|
103
|
+
warnings.append(f"changelog[{i}] must be an object")
|
|
104
|
+
continue
|
|
105
|
+
raw_date = item.get("date")
|
|
106
|
+
if not raw_date:
|
|
107
|
+
warnings.append(f"changelog[{i}] missing required field: date")
|
|
108
|
+
continue
|
|
109
|
+
try:
|
|
110
|
+
date.fromisoformat(str(raw_date)[:10])
|
|
111
|
+
except (ValueError, TypeError):
|
|
112
|
+
warnings.append(f"changelog[{i}].date is not a valid ISO date")
|
|
113
|
+
continue
|
|
114
|
+
event = item.get("event")
|
|
115
|
+
if not event:
|
|
116
|
+
warnings.append(f"changelog[{i}] missing required field: event")
|
|
117
|
+
continue
|
|
118
|
+
entry: ChangelogEntry = {
|
|
119
|
+
"date": str(raw_date)[:10],
|
|
120
|
+
"event": str(event),
|
|
121
|
+
}
|
|
122
|
+
if "field" in item:
|
|
123
|
+
entry["field"] = str(item["field"])
|
|
124
|
+
if "from" in item:
|
|
125
|
+
entry["from_value"] = item["from"]
|
|
126
|
+
if "to" in item:
|
|
127
|
+
entry["to_value"] = item["to"]
|
|
128
|
+
entries.append(entry)
|
|
129
|
+
|
|
130
|
+
entries.sort(key=lambda e: e["date"])
|
|
131
|
+
return entries, warnings
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ─── timeline render ─────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def render_changelog_timeline(entries: list[ChangelogEntry]) -> str:
|
|
138
|
+
"""Render a vertical timeline of changelog entries. Empty input
|
|
139
|
+
returns an empty string so callers can `if html:` gate on whether
|
|
140
|
+
to show the surrounding section."""
|
|
141
|
+
if not entries:
|
|
142
|
+
return ""
|
|
143
|
+
# Timeline is ordered newest-first for reader flow
|
|
144
|
+
rows: list[str] = []
|
|
145
|
+
for entry in reversed(entries):
|
|
146
|
+
delta_html = _format_delta(entry)
|
|
147
|
+
field_html = (
|
|
148
|
+
f'<code class="timeline-field">{html.escape(entry["field"])}</code>'
|
|
149
|
+
if entry.get("field") else ""
|
|
150
|
+
)
|
|
151
|
+
rows.append(
|
|
152
|
+
'<li class="timeline-item">'
|
|
153
|
+
f'<span class="timeline-date">{html.escape(entry["date"])}</span>'
|
|
154
|
+
f'<span class="timeline-dot"></span>'
|
|
155
|
+
'<div class="timeline-body">'
|
|
156
|
+
f'<div class="timeline-event">{html.escape(entry["event"])}</div>'
|
|
157
|
+
+ (f'<div class="timeline-detail">{field_html} {delta_html}</div>'
|
|
158
|
+
if field_html or delta_html else "")
|
|
159
|
+
+ '</div>'
|
|
160
|
+
'</li>'
|
|
161
|
+
)
|
|
162
|
+
return (
|
|
163
|
+
'<ol class="changelog-timeline">'
|
|
164
|
+
+ "".join(rows)
|
|
165
|
+
+ '</ol>'
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _format_delta(entry: ChangelogEntry) -> str:
|
|
170
|
+
"""Format a from→to delta as HTML, picking a numeric or string
|
|
171
|
+
representation based on what the values look like."""
|
|
172
|
+
if "from_value" not in entry and "to_value" not in entry:
|
|
173
|
+
return ""
|
|
174
|
+
frm = entry.get("from_value")
|
|
175
|
+
to = entry.get("to_value")
|
|
176
|
+
|
|
177
|
+
def render_value(v: Any) -> str:
|
|
178
|
+
if v is None:
|
|
179
|
+
return '<span class="muted">null</span>'
|
|
180
|
+
if isinstance(v, bool):
|
|
181
|
+
return str(v).lower()
|
|
182
|
+
if isinstance(v, (int, float)):
|
|
183
|
+
return _format_number(v)
|
|
184
|
+
return html.escape(str(v))
|
|
185
|
+
|
|
186
|
+
frm_html = render_value(frm)
|
|
187
|
+
to_html = render_value(to)
|
|
188
|
+
# Add a color hint for numeric changes: up = positive delta in
|
|
189
|
+
# price is bad, in benchmark is good — so we stay neutral and
|
|
190
|
+
# just show the arrow direction.
|
|
191
|
+
arrow_cls = "timeline-arrow"
|
|
192
|
+
if isinstance(frm, (int, float)) and isinstance(to, (int, float)):
|
|
193
|
+
if to > frm:
|
|
194
|
+
arrow_cls += " timeline-arrow-up"
|
|
195
|
+
elif to < frm:
|
|
196
|
+
arrow_cls += " timeline-arrow-down"
|
|
197
|
+
return (
|
|
198
|
+
f'<span class="timeline-delta">'
|
|
199
|
+
f'<span class="timeline-from">{frm_html}</span>'
|
|
200
|
+
f'<span class="{arrow_cls}">→</span>'
|
|
201
|
+
f'<span class="timeline-to">{to_html}</span>'
|
|
202
|
+
f'</span>'
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _format_number(n: float) -> str:
|
|
207
|
+
"""Compact numeric formatting for timeline values.
|
|
208
|
+
|
|
209
|
+
* Large counts (≥1000) get K/M suffix.
|
|
210
|
+
* Small counts render as-is.
|
|
211
|
+
* Floats with 2+ decimals stay as-is (pricing).
|
|
212
|
+
* Whole-number floats drop the decimal.
|
|
213
|
+
"""
|
|
214
|
+
if isinstance(n, float):
|
|
215
|
+
if n.is_integer() and abs(n) < 1000:
|
|
216
|
+
return str(int(n))
|
|
217
|
+
if abs(n) < 1:
|
|
218
|
+
return f"{n:.3f}".rstrip("0").rstrip(".")
|
|
219
|
+
if abs(n) < 1000:
|
|
220
|
+
return f"{n:.2f}".rstrip("0").rstrip(".")
|
|
221
|
+
if abs(n) >= 1_000_000:
|
|
222
|
+
return f"{n / 1_000_000:.1f}M"
|
|
223
|
+
if abs(n) >= 1000:
|
|
224
|
+
return f"{n / 1000:.1f}K"
|
|
225
|
+
return str(n)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ─── pricing sparkline ───────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def extract_price_points(
|
|
232
|
+
entries: list[ChangelogEntry],
|
|
233
|
+
field_suffix: str = "pricing.input_per_1m",
|
|
234
|
+
) -> list[tuple[date, float]]:
|
|
235
|
+
"""Return `[(date, value), …]` for every numeric `to_value` whose
|
|
236
|
+
`field` ends with `field_suffix`. Used to feed the pricing sparkline.
|
|
237
|
+
|
|
238
|
+
Matches on suffix so `model.pricing.input_per_1m` and
|
|
239
|
+
`pricing.input_per_1m` both hit — schema callers sometimes use
|
|
240
|
+
different path roots.
|
|
241
|
+
"""
|
|
242
|
+
out: list[tuple[date, float]] = []
|
|
243
|
+
for e in entries:
|
|
244
|
+
field = e.get("field", "")
|
|
245
|
+
if not field.endswith(field_suffix):
|
|
246
|
+
continue
|
|
247
|
+
to_value = e.get("to_value")
|
|
248
|
+
if to_value is None:
|
|
249
|
+
continue
|
|
250
|
+
try:
|
|
251
|
+
val = float(to_value)
|
|
252
|
+
except (TypeError, ValueError):
|
|
253
|
+
continue
|
|
254
|
+
try:
|
|
255
|
+
d = date.fromisoformat(e["date"])
|
|
256
|
+
except ValueError:
|
|
257
|
+
continue
|
|
258
|
+
out.append((d, val))
|
|
259
|
+
return out
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def render_price_sparkline(
|
|
263
|
+
points: list[tuple[date, float]],
|
|
264
|
+
width: int = 220,
|
|
265
|
+
height: int = 40,
|
|
266
|
+
) -> str:
|
|
267
|
+
"""Render an inline SVG sparkline for a price series. Empty input
|
|
268
|
+
returns an empty string. Fewer than 2 points returns an empty
|
|
269
|
+
string (can't draw a line through 1 point)."""
|
|
270
|
+
if len(points) < 2:
|
|
271
|
+
return ""
|
|
272
|
+
values = [v for _, v in points]
|
|
273
|
+
min_v = min(values)
|
|
274
|
+
max_v = max(values)
|
|
275
|
+
pad_x = 4
|
|
276
|
+
pad_y = 4
|
|
277
|
+
plot_w = width - 2 * pad_x
|
|
278
|
+
plot_h = height - 2 * pad_y
|
|
279
|
+
|
|
280
|
+
if max_v == min_v:
|
|
281
|
+
# Flat series — draw a horizontal line at the middle
|
|
282
|
+
midy = pad_y + plot_h / 2
|
|
283
|
+
path_d = f"M {pad_x} {midy:.1f} L {pad_x + plot_w} {midy:.1f}"
|
|
284
|
+
else:
|
|
285
|
+
n = len(points)
|
|
286
|
+
xs = [pad_x + (i / (n - 1)) * plot_w for i in range(n)]
|
|
287
|
+
ys = [
|
|
288
|
+
pad_y + plot_h - ((v - min_v) / (max_v - min_v)) * plot_h
|
|
289
|
+
for v in values
|
|
290
|
+
]
|
|
291
|
+
parts = [f"M {xs[0]:.1f} {ys[0]:.1f}"]
|
|
292
|
+
for x, y in zip(xs[1:], ys[1:]):
|
|
293
|
+
parts.append(f"L {x:.1f} {y:.1f}")
|
|
294
|
+
path_d = " ".join(parts)
|
|
295
|
+
|
|
296
|
+
# End dots: first + last
|
|
297
|
+
first_x = pad_x
|
|
298
|
+
first_y = pad_y + plot_h - (
|
|
299
|
+
(values[0] - min_v) / (max_v - min_v) * plot_h if max_v != min_v else plot_h / 2
|
|
300
|
+
)
|
|
301
|
+
last_x = pad_x + plot_w
|
|
302
|
+
last_y = pad_y + plot_h - (
|
|
303
|
+
(values[-1] - min_v) / (max_v - min_v) * plot_h if max_v != min_v else plot_h / 2
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
tooltip = (
|
|
307
|
+
f"{points[0][0].isoformat()}: {values[0]:.2f} → "
|
|
308
|
+
f"{points[-1][0].isoformat()}: {values[-1]:.2f}"
|
|
309
|
+
)
|
|
310
|
+
return (
|
|
311
|
+
f'<svg class="price-sparkline" xmlns="http://www.w3.org/2000/svg" '
|
|
312
|
+
f'viewBox="0 0 {width} {height}" width="{width}" height="{height}" '
|
|
313
|
+
f'role="img" aria-label="{html.escape(tooltip)}">'
|
|
314
|
+
f'<title>{html.escape(tooltip)}</title>'
|
|
315
|
+
f'<path d="{path_d}" fill="none" '
|
|
316
|
+
'stroke="var(--accent, #7C3AED)" stroke-width="1.5" '
|
|
317
|
+
'stroke-linecap="round" stroke-linejoin="round"/>'
|
|
318
|
+
f'<circle cx="{first_x:.1f}" cy="{first_y:.1f}" r="2.2" fill="var(--accent, #7C3AED)"/>'
|
|
319
|
+
f'<circle cx="{last_x:.1f}" cy="{last_y:.1f}" r="2.2" fill="var(--accent, #7C3AED)"/>'
|
|
320
|
+
'</svg>'
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# ─── "recently updated" aggregator ───────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def find_recently_updated(
|
|
328
|
+
pages: Iterable[tuple[str, Mapping[str, Any]]],
|
|
329
|
+
now: Optional[date] = None,
|
|
330
|
+
within_days: int = 30,
|
|
331
|
+
) -> list[tuple[str, ChangelogEntry]]:
|
|
332
|
+
"""Given an iterable of `(slug, meta)` pairs, return a list of
|
|
333
|
+
`(slug, latest_changelog_entry)` for pages that have a changelog
|
|
334
|
+
entry dated within the last `within_days` days, sorted by latest
|
|
335
|
+
change descending.
|
|
336
|
+
|
|
337
|
+
Used by the home-page "Recently updated" widget so readers can see
|
|
338
|
+
which model pages have had their pricing, context, or benchmark
|
|
339
|
+
scores revised lately.
|
|
340
|
+
"""
|
|
341
|
+
if now is None:
|
|
342
|
+
now = datetime.now(timezone.utc).date()
|
|
343
|
+
cutoff = now - timedelta(days=within_days)
|
|
344
|
+
out: list[tuple[str, ChangelogEntry, date]] = []
|
|
345
|
+
for slug, meta in pages:
|
|
346
|
+
entries, _ = parse_changelog(meta)
|
|
347
|
+
if not entries:
|
|
348
|
+
continue
|
|
349
|
+
latest = entries[-1]
|
|
350
|
+
try:
|
|
351
|
+
d = date.fromisoformat(latest["date"])
|
|
352
|
+
except ValueError:
|
|
353
|
+
continue
|
|
354
|
+
if d < cutoff:
|
|
355
|
+
continue
|
|
356
|
+
out.append((slug, latest, d))
|
|
357
|
+
out.sort(key=lambda x: x[2], reverse=True)
|
|
358
|
+
return [(slug, entry) for slug, entry, _ in out]
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def render_recently_updated(
|
|
362
|
+
items: list[tuple[str, ChangelogEntry]],
|
|
363
|
+
link_prefix: str = "models/",
|
|
364
|
+
) -> str:
|
|
365
|
+
"""Render the "Recently updated" home-page list from the output of
|
|
366
|
+
`find_recently_updated`. Empty input returns an empty string."""
|
|
367
|
+
if not items:
|
|
368
|
+
return ""
|
|
369
|
+
rows: list[str] = []
|
|
370
|
+
for slug, entry in items:
|
|
371
|
+
rows.append(
|
|
372
|
+
f'<li class="recently-updated-item">'
|
|
373
|
+
f'<a href="{html.escape(link_prefix)}{html.escape(slug)}.html">'
|
|
374
|
+
f'<span class="recently-updated-slug">{html.escape(slug)}</span>'
|
|
375
|
+
f'</a>'
|
|
376
|
+
f'<span class="recently-updated-date muted">{html.escape(entry["date"])}</span>'
|
|
377
|
+
f'<span class="recently-updated-event">{html.escape(entry["event"])}</span>'
|
|
378
|
+
f'</li>'
|
|
379
|
+
)
|
|
380
|
+
return (
|
|
381
|
+
'<div class="recently-updated-card">'
|
|
382
|
+
'<div class="recently-updated-title muted">Recently updated · last 30 days</div>'
|
|
383
|
+
'<ul class="recently-updated-list">' + "".join(rows) + '</ul>'
|
|
384
|
+
'</div>'
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def render_recent_activity(log_events: list[Any]) -> str:
|
|
389
|
+
"""Render a "Recent activity" card driven from ``wiki/log.md`` (G-18 · #304).
|
|
390
|
+
|
|
391
|
+
``log_events`` is a list of :class:`sessionwiki.log_reader.LogEvent`
|
|
392
|
+
records — passed as ``Any`` here to avoid an import cycle. The home
|
|
393
|
+
page uses this as a fallback when no model-changelog updates are
|
|
394
|
+
available, so the "Recently updated" surface shows real activity
|
|
395
|
+
instead of sitting empty on corpora without model pages.
|
|
396
|
+
"""
|
|
397
|
+
if not log_events:
|
|
398
|
+
return ""
|
|
399
|
+
rows: list[str] = []
|
|
400
|
+
for ev in log_events:
|
|
401
|
+
# Prefer a processed-count detail when present; otherwise show title.
|
|
402
|
+
processed = ev.details.get("Processed") if hasattr(ev, "details") else None
|
|
403
|
+
right_label = f"{processed} processed" if processed else ev.title
|
|
404
|
+
# #ui-l4 (#580): the event label is truncated visually via CSS
|
|
405
|
+
# `text-overflow: ellipsis` when it exceeds the column width.
|
|
406
|
+
# Add `title=` so a hover surfaces the full text — keyboard
|
|
407
|
+
# users can read the full label without measuring columns.
|
|
408
|
+
full_label = ev.title if processed else right_label
|
|
409
|
+
rows.append(
|
|
410
|
+
f'<li class="recently-updated-item">'
|
|
411
|
+
f'<span class="recently-updated-slug">{html.escape(ev.operation)}</span>'
|
|
412
|
+
f'<span class="recently-updated-date muted">{html.escape(ev.date.isoformat())}</span>'
|
|
413
|
+
f'<span class="recently-updated-event" title="{html.escape(full_label)}">'
|
|
414
|
+
f'{html.escape(right_label)}</span>'
|
|
415
|
+
f'</li>'
|
|
416
|
+
)
|
|
417
|
+
return (
|
|
418
|
+
'<div class="recently-updated-card">'
|
|
419
|
+
f'<div class="recently-updated-title muted">Recent activity · last {len(log_events)} operations</div>'
|
|
420
|
+
'<ul class="recently-updated-list">' + "".join(rows) + '</ul>'
|
|
421
|
+
'</div>'
|
|
422
|
+
)
|