kiwime-export 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.
- kiwime_export/__init__.py +17 -0
- kiwime_export/__main__.py +144 -0
- kiwime_export/manifest.py +65 -0
- kiwime_export/options.py +70 -0
- kiwime_export/py.typed +0 -0
- kiwime_export/resume.py +231 -0
- kiwime_export/sections.py +172 -0
- kiwime_export/server.py +263 -0
- kiwime_export/store_client.py +29 -0
- kiwime_export/upload.py +325 -0
- kiwime_export/writer.py +140 -0
- kiwime_export-0.1.0.dist-info/METADATA +9 -0
- kiwime_export-0.1.0.dist-info/RECORD +14 -0
- kiwime_export-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""kiwiMe export worker.
|
|
2
|
+
|
|
3
|
+
Serializes the compiled Expert Context Package (manifest, identity, memories,
|
|
4
|
+
graph, cases, assets, permissions) per docs/schema/core.md "Export layer".
|
|
5
|
+
|
|
6
|
+
Default behavior is **minimum public**: identity + confirmed cases +
|
|
7
|
+
topic-level capability summary. No raw document text leaves the package
|
|
8
|
+
unless the user explicitly opts in. See ``options.ExportOptions``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .manifest import build_manifest
|
|
12
|
+
from .options import ExportOptions
|
|
13
|
+
from .writer import write_package
|
|
14
|
+
|
|
15
|
+
__all__ = ["ExportOptions", "build_manifest", "write_package"]
|
|
16
|
+
|
|
17
|
+
__version__ = "0.0.0"
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Entry point: ``python -m kiwime_export``.
|
|
2
|
+
|
|
3
|
+
Reads JSON-RPC requests line-by-line from stdin, writes responses to stdout.
|
|
4
|
+
Logs go to stderr (per docs/architecture/ipc-protocol.md §1: stdout is the
|
|
5
|
+
protocol channel).
|
|
6
|
+
|
|
7
|
+
Production wiring: the host spawns this with ``--direct <sqlite_path>`` so
|
|
8
|
+
the worker reads the same store database the rest of the system writes to.
|
|
9
|
+
Without ``--direct`` we fall back to a no-op stub, which is only useful for
|
|
10
|
+
the standalone smoke tests in ``tests/`` — every export call would otherwise
|
|
11
|
+
return an empty package.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import sqlite3
|
|
18
|
+
import sys
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from kiwime_store import repo
|
|
22
|
+
|
|
23
|
+
from .server import dispatch
|
|
24
|
+
from .store_client import StoreClient
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _UnwiredStore:
|
|
28
|
+
"""Placeholder used only when ``--direct`` is omitted (tests).
|
|
29
|
+
|
|
30
|
+
Returns empty results for every query so the binary can boot in
|
|
31
|
+
isolation without a database.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def query_memories(self, profile_id: str) -> list[dict[str, Any]]:
|
|
35
|
+
return []
|
|
36
|
+
|
|
37
|
+
def query_graph(self, profile_id: str) -> dict[str, list[dict[str, Any]]]:
|
|
38
|
+
return {"nodes": [], "edges": []}
|
|
39
|
+
|
|
40
|
+
def query_capabilities(self, profile_id: str) -> list[dict[str, Any]]:
|
|
41
|
+
return []
|
|
42
|
+
|
|
43
|
+
def query_cases(self, profile_id: str) -> list[dict[str, Any]]:
|
|
44
|
+
return []
|
|
45
|
+
|
|
46
|
+
def query_assets(self, profile_id: str) -> list[dict[str, Any]]:
|
|
47
|
+
return []
|
|
48
|
+
|
|
49
|
+
def query_permissions(self, profile_id: str) -> dict[str, Any]:
|
|
50
|
+
return {}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SqliteStoreClient:
|
|
54
|
+
"""Direct SQLite reader. The same database file the store sidecar uses.
|
|
55
|
+
|
|
56
|
+
We deliberately open in read-only mode (``mode=ro``) — the export
|
|
57
|
+
worker must never mutate state. Also ``immutable=0`` so SQLite still
|
|
58
|
+
sees concurrent writes from the store sidecar (otherwise it would
|
|
59
|
+
cache the page set and miss new memories compiled mid-session).
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, db_path: str) -> None:
|
|
63
|
+
uri = f"file:{db_path}?mode=ro"
|
|
64
|
+
self._conn = sqlite3.connect(uri, uri=True, check_same_thread=False)
|
|
65
|
+
self._conn.row_factory = sqlite3.Row
|
|
66
|
+
|
|
67
|
+
def query_memories(self, profile_id: str) -> list[dict[str, Any]]:
|
|
68
|
+
return repo.list_memories(self._conn, profile_id)
|
|
69
|
+
|
|
70
|
+
def query_graph(self, profile_id: str) -> dict[str, list[dict[str, Any]]]:
|
|
71
|
+
return repo.get_graph(self._conn, profile_id)
|
|
72
|
+
|
|
73
|
+
def query_capabilities(self, profile_id: str) -> list[dict[str, Any]]:
|
|
74
|
+
return repo.list_capabilities(self._conn, profile_id)
|
|
75
|
+
|
|
76
|
+
def query_cases(self, profile_id: str) -> list[dict[str, Any]]:
|
|
77
|
+
# ``case_record`` rows aren't yet exposed via a repo helper. Read
|
|
78
|
+
# directly; columns track migrations/0001_init.sql.
|
|
79
|
+
try:
|
|
80
|
+
rows = self._conn.execute(
|
|
81
|
+
"SELECT * FROM case_record WHERE profile_id = ?",
|
|
82
|
+
(profile_id,),
|
|
83
|
+
).fetchall()
|
|
84
|
+
except sqlite3.OperationalError:
|
|
85
|
+
return []
|
|
86
|
+
return [dict(r) for r in rows]
|
|
87
|
+
|
|
88
|
+
def query_assets(self, profile_id: str) -> list[dict[str, Any]]:
|
|
89
|
+
# Assets live under ``source`` — there's no profile_id column on
|
|
90
|
+
# ``asset`` itself; we join through ``source``.
|
|
91
|
+
try:
|
|
92
|
+
rows = self._conn.execute(
|
|
93
|
+
"""
|
|
94
|
+
SELECT a.* FROM asset a
|
|
95
|
+
JOIN source s ON s.id = a.source_id
|
|
96
|
+
WHERE s.profile_id = ? AND a.deleted_at IS NULL
|
|
97
|
+
""",
|
|
98
|
+
(profile_id,),
|
|
99
|
+
).fetchall()
|
|
100
|
+
except sqlite3.OperationalError:
|
|
101
|
+
return []
|
|
102
|
+
return [dict(r) for r in rows]
|
|
103
|
+
|
|
104
|
+
def query_permissions(self, profile_id: str) -> dict[str, Any]:
|
|
105
|
+
# No persisted permission rows yet — the manifest section is the
|
|
106
|
+
# canonical record of "what this export contains". Surface the
|
|
107
|
+
# known sources so downstream consumers see scope at a glance.
|
|
108
|
+
try:
|
|
109
|
+
sources = repo.list_sources(self._conn, profile_id)
|
|
110
|
+
except Exception:
|
|
111
|
+
sources = []
|
|
112
|
+
return {
|
|
113
|
+
"sources": [
|
|
114
|
+
{"id": s.get("id"), "connector": s.get("connector"), "kind": s.get("kind")}
|
|
115
|
+
for s in sources
|
|
116
|
+
],
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def main() -> int:
|
|
121
|
+
parser = argparse.ArgumentParser(prog="kiwime-export")
|
|
122
|
+
parser.add_argument(
|
|
123
|
+
"--direct",
|
|
124
|
+
metavar="SQLITE_PATH",
|
|
125
|
+
help="Open the store SQLite directly (read-only). Required for real exports.",
|
|
126
|
+
)
|
|
127
|
+
args, _unknown = parser.parse_known_args()
|
|
128
|
+
|
|
129
|
+
# Without --direct: stub mode with empty results. The binary still
|
|
130
|
+
# serves RPC so smoke tests pass, but every export will be empty.
|
|
131
|
+
store: StoreClient = SqliteStoreClient(args.direct) if args.direct else _UnwiredStore()
|
|
132
|
+
|
|
133
|
+
for line in sys.stdin:
|
|
134
|
+
line = line.strip()
|
|
135
|
+
if not line:
|
|
136
|
+
continue
|
|
137
|
+
response = dispatch(line, store)
|
|
138
|
+
sys.stdout.write(response + "\n")
|
|
139
|
+
sys.stdout.flush()
|
|
140
|
+
return 0
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
if __name__ == "__main__":
|
|
144
|
+
sys.exit(main())
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Manifest builder.
|
|
2
|
+
|
|
3
|
+
The manifest is the spine of the package: every file is checksummed, every
|
|
4
|
+
included section is listed, every redaction is named. It is what makes the
|
|
5
|
+
package *auditable* — a downstream importer can verify integrity and see
|
|
6
|
+
exactly what the user chose to share.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .options import ExportOptions
|
|
16
|
+
|
|
17
|
+
# Bump on breaking changes. v0 = pre-1.0 draft.
|
|
18
|
+
MANIFEST_VERSION = 0
|
|
19
|
+
GENERATOR = "kiwiMe/0.0.0"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _sha256_hex(data: str | bytes) -> str:
|
|
23
|
+
if isinstance(data, str):
|
|
24
|
+
data = data.encode("utf-8")
|
|
25
|
+
return hashlib.sha256(data).hexdigest()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _utc_now_iso() -> str:
|
|
29
|
+
"""ISO 8601 UTC, millisecond precision, ``Z`` suffix (per schema/core.md)."""
|
|
30
|
+
now = datetime.now(UTC)
|
|
31
|
+
return now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_manifest(
|
|
35
|
+
profile_id: str,
|
|
36
|
+
opts: ExportOptions,
|
|
37
|
+
files: dict[str, str | bytes],
|
|
38
|
+
*,
|
|
39
|
+
generated_at: str | None = None,
|
|
40
|
+
) -> dict[str, Any]:
|
|
41
|
+
"""Build the manifest object.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
profile_id: The profile being exported.
|
|
45
|
+
opts: User's per-export toggles.
|
|
46
|
+
files: Mapping ``filename -> content`` of every file included in the
|
|
47
|
+
package *other than* ``manifest.json`` itself. Checksums are
|
|
48
|
+
computed over these.
|
|
49
|
+
generated_at: Optional override for the timestamp (tests inject a
|
|
50
|
+
fixed value).
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
The manifest dict, ready to be JSON-serialized.
|
|
54
|
+
"""
|
|
55
|
+
sections = sorted(files.keys())
|
|
56
|
+
checksums = {name: _sha256_hex(content) for name, content in files.items()}
|
|
57
|
+
return {
|
|
58
|
+
"version": MANIFEST_VERSION,
|
|
59
|
+
"profile_id": profile_id,
|
|
60
|
+
"generated_at": generated_at or _utc_now_iso(),
|
|
61
|
+
"generator": GENERATOR,
|
|
62
|
+
"sections": sections,
|
|
63
|
+
"redactions": opts.redactions(),
|
|
64
|
+
"checksums": checksums,
|
|
65
|
+
}
|
kiwime_export/options.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Export options.
|
|
2
|
+
|
|
3
|
+
Defaults are deliberately conservative: **minimum public**. No raw document
|
|
4
|
+
text leaves the package, no episodic memories, no provenance ingestion-layer
|
|
5
|
+
refs, no assets. The user must explicitly opt in to expand the package.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
ExportFormat = Literal["directory", "tarball"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class ExportOptions:
|
|
18
|
+
"""User-controlled toggles for what goes into the Expert Context Package.
|
|
19
|
+
|
|
20
|
+
Every field is opt-in by default. The package contains the *compiled*
|
|
21
|
+
expert context, not the user's source material.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
include_episodic: bool = False
|
|
25
|
+
"""If False, episodic memories are filtered out (only identity + semantic)."""
|
|
26
|
+
|
|
27
|
+
include_provenance: bool = False
|
|
28
|
+
"""If False, ingestion-layer refs are stripped from graph/cases/memories."""
|
|
29
|
+
|
|
30
|
+
include_asset_ids: list[str] = field(default_factory=list)
|
|
31
|
+
"""Explicit allow-list of asset ids. Empty means: no assets exported."""
|
|
32
|
+
|
|
33
|
+
include_raw_text: bool = False
|
|
34
|
+
"""If False, the ``text`` field of memories/documents is summarized only.
|
|
35
|
+
|
|
36
|
+
Note: this is the load-bearing privacy switch. With ``False`` (default),
|
|
37
|
+
no raw user document text is in the package.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
format: ExportFormat = "directory"
|
|
41
|
+
"""Either a directory of files or an additional ``package.tar.gz``."""
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def from_dict(cls, d: dict[str, object] | None) -> ExportOptions:
|
|
45
|
+
if not d:
|
|
46
|
+
return cls()
|
|
47
|
+
return cls(
|
|
48
|
+
include_episodic=bool(d.get("include_episodic", False)),
|
|
49
|
+
include_provenance=bool(d.get("include_provenance", False)),
|
|
50
|
+
include_asset_ids=list(d.get("include_asset_ids", []) or []), # type: ignore[arg-type]
|
|
51
|
+
include_raw_text=bool(d.get("include_raw_text", False)),
|
|
52
|
+
format=d.get("format", "directory"), # type: ignore[arg-type]
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def redactions(self) -> list[str]:
|
|
56
|
+
"""List of section names that were redacted by these options.
|
|
57
|
+
|
|
58
|
+
Surfaced in ``manifest.json`` so a downstream importer can see what
|
|
59
|
+
the user chose to omit, without leaking what was actually in there.
|
|
60
|
+
"""
|
|
61
|
+
out: list[str] = []
|
|
62
|
+
if not self.include_episodic:
|
|
63
|
+
out.append("episodic_memories")
|
|
64
|
+
if not self.include_raw_text:
|
|
65
|
+
out.append("raw_document_text")
|
|
66
|
+
if not self.include_provenance:
|
|
67
|
+
out.append("provenance_refs")
|
|
68
|
+
if not self.include_asset_ids:
|
|
69
|
+
out.append("assets")
|
|
70
|
+
return out
|
kiwime_export/py.typed
ADDED
|
File without changes
|
kiwime_export/resume.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Static resume page — render the compiled expert context as one HTML file.
|
|
2
|
+
|
|
3
|
+
The "public resume" use case: a user exports a single self-contained
|
|
4
|
+
``resume.html`` (inline CSS, zero external requests, no JS required) and
|
|
5
|
+
hosts it anywhere — GitHub Pages, their own domain — as a live, provenance-
|
|
6
|
+
aware alternative to a PDF resume.
|
|
7
|
+
|
|
8
|
+
Privacy stance mirrors the package exporter: this renders only *compiled*
|
|
9
|
+
context (identity summary, capabilities, confirmed cases), never raw
|
|
10
|
+
document text, and applies the same governance filters (dismissed/archived
|
|
11
|
+
excluded, superseded memories skipped, candidate cases skipped). The page
|
|
12
|
+
is meant to be published, so the bar is "what the user already chose to
|
|
13
|
+
stand behind", not "everything the store knows".
|
|
14
|
+
|
|
15
|
+
The optional ``narrative`` (an LLM-written professional summary) is
|
|
16
|
+
generated by the compiler's assistant role — the host orchestrates that
|
|
17
|
+
call and passes the text in, so this worker stays LLM-free.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import html
|
|
23
|
+
from datetime import UTC, datetime
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from .store_client import StoreClient
|
|
27
|
+
|
|
28
|
+
# Governance statuses that must never appear on a public page.
|
|
29
|
+
_EXCLUDED_STATUSES = frozenset({"dismissed", "archived"})
|
|
30
|
+
|
|
31
|
+
# recency column values → human phrasing, in display order.
|
|
32
|
+
_RECENCY_LABEL = {
|
|
33
|
+
"recent": "current focus",
|
|
34
|
+
"stable": "established",
|
|
35
|
+
"dormant": "earlier work",
|
|
36
|
+
}
|
|
37
|
+
_RECENCY_ORDER = {"recent": 0, "stable": 1, "dormant": 2}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def collect_resume_data(profile_id: str, store: StoreClient) -> dict[str, Any]:
|
|
41
|
+
"""Gather everything the template needs, applying governance filters.
|
|
42
|
+
|
|
43
|
+
Returns a plain dict so it can double as the ``resume.json`` payload
|
|
44
|
+
(machine-readable twin of the page) and be unit-tested without HTML
|
|
45
|
+
parsing.
|
|
46
|
+
"""
|
|
47
|
+
memories = store.query_memories(profile_id)
|
|
48
|
+
identities = [
|
|
49
|
+
m
|
|
50
|
+
for m in memories
|
|
51
|
+
if m.get("kind") == "identity"
|
|
52
|
+
and not m.get("superseded_by")
|
|
53
|
+
and m.get("status") not in _EXCLUDED_STATUSES
|
|
54
|
+
]
|
|
55
|
+
identities.sort(key=lambda m: m.get("created_at", ""), reverse=True)
|
|
56
|
+
identity = identities[0] if identities else None
|
|
57
|
+
|
|
58
|
+
capabilities = [
|
|
59
|
+
c
|
|
60
|
+
for c in store.query_capabilities(profile_id)
|
|
61
|
+
if c.get("status") not in _EXCLUDED_STATUSES
|
|
62
|
+
]
|
|
63
|
+
# Confidence within recency bucket: current focus first, strongest first.
|
|
64
|
+
capabilities.sort(
|
|
65
|
+
key=lambda c: (
|
|
66
|
+
_RECENCY_ORDER.get(str(c.get("recency")), 3),
|
|
67
|
+
-float(c.get("confidence") or 0.0),
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
cases = [c for c in store.query_cases(profile_id) if c.get("status") == "confirmed"]
|
|
72
|
+
cases.sort(key=lambda c: -float(c.get("confidence") or 0.0))
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
"identity": {
|
|
76
|
+
"summary": (identity.get("summary") or "").strip() if identity else "",
|
|
77
|
+
"text": (identity.get("text") or "").strip() if identity else "",
|
|
78
|
+
},
|
|
79
|
+
"capabilities": [
|
|
80
|
+
{
|
|
81
|
+
"name": c.get("name") or "",
|
|
82
|
+
"description": c.get("description") or "",
|
|
83
|
+
"confidence": float(c.get("confidence") or 0.0),
|
|
84
|
+
"recency": str(c.get("recency") or "stable"),
|
|
85
|
+
}
|
|
86
|
+
for c in capabilities
|
|
87
|
+
],
|
|
88
|
+
"cases": [
|
|
89
|
+
{
|
|
90
|
+
"title": c.get("title") or "",
|
|
91
|
+
"summary": c.get("summary") or "",
|
|
92
|
+
"outcome": c.get("outcome") or "",
|
|
93
|
+
}
|
|
94
|
+
for c in cases
|
|
95
|
+
],
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _esc(s: str) -> str:
|
|
100
|
+
return html.escape(s, quote=True)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _confidence_bar(confidence: float) -> str:
|
|
104
|
+
pct = max(0, min(100, round(confidence * 100)))
|
|
105
|
+
return (
|
|
106
|
+
f'<div class="bar" role="img" aria-label="confidence {pct}%">'
|
|
107
|
+
f'<div class="bar-fill" style="width:{pct}%"></div></div>'
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def render_resume_html(
|
|
112
|
+
data: dict[str, Any],
|
|
113
|
+
*,
|
|
114
|
+
narrative: str = "",
|
|
115
|
+
display_name: str = "",
|
|
116
|
+
generated_at: str | None = None,
|
|
117
|
+
) -> str:
|
|
118
|
+
"""Render the resume page. Self-contained: inline CSS, no JS, no fetches.
|
|
119
|
+
|
|
120
|
+
``narrative`` is the optional LLM-written summary (host passes it in);
|
|
121
|
+
when absent the page falls back to the compiled identity text, and when
|
|
122
|
+
that's absent too, the section is omitted — never filled with boilerplate.
|
|
123
|
+
"""
|
|
124
|
+
ts = generated_at or datetime.now(UTC).strftime("%Y-%m-%d")
|
|
125
|
+
name = display_name.strip() or "Expert Context"
|
|
126
|
+
summary = narrative.strip() or data["identity"]["text"] or data["identity"]["summary"]
|
|
127
|
+
|
|
128
|
+
cap_items: list[str] = []
|
|
129
|
+
for c in data["capabilities"]:
|
|
130
|
+
cap_items.append(
|
|
131
|
+
'<li class="cap">'
|
|
132
|
+
f'<div class="cap-head"><span class="cap-name">{_esc(c["name"])}</span>'
|
|
133
|
+
f'<span class="tag">{_esc(_RECENCY_LABEL.get(c["recency"], c["recency"]))}</span></div>'
|
|
134
|
+
f"{_confidence_bar(c['confidence'])}"
|
|
135
|
+
f'<p class="cap-desc">{_esc(c["description"])}</p>'
|
|
136
|
+
"</li>"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
case_items: list[str] = []
|
|
140
|
+
for c in data["cases"]:
|
|
141
|
+
outcome = (
|
|
142
|
+
f'<p class="case-outcome"><strong>Outcome:</strong> {_esc(c["outcome"])}</p>'
|
|
143
|
+
if c["outcome"]
|
|
144
|
+
else ""
|
|
145
|
+
)
|
|
146
|
+
case_items.append(
|
|
147
|
+
'<li class="case">'
|
|
148
|
+
f"<h3>{_esc(c['title'])}</h3>"
|
|
149
|
+
f"<p>{_esc(c['summary'])}</p>"
|
|
150
|
+
f"{outcome}"
|
|
151
|
+
"</li>"
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
summary_section = (
|
|
155
|
+
f'<section><h2>Profile</h2><p class="summary">{_esc(summary)}</p></section>'
|
|
156
|
+
if summary
|
|
157
|
+
else ""
|
|
158
|
+
)
|
|
159
|
+
caps_section = (
|
|
160
|
+
f'<section><h2>Capabilities</h2><ul class="caps">{"".join(cap_items)}</ul></section>'
|
|
161
|
+
if cap_items
|
|
162
|
+
else ""
|
|
163
|
+
)
|
|
164
|
+
cases_section = (
|
|
165
|
+
f'<section><h2>Selected cases</h2><ul class="cases">{"".join(case_items)}</ul></section>'
|
|
166
|
+
if case_items
|
|
167
|
+
else ""
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
return f"""<!doctype html>
|
|
171
|
+
<html lang="en">
|
|
172
|
+
<head>
|
|
173
|
+
<meta charset="utf-8">
|
|
174
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
175
|
+
<meta name="robots" content="index, follow">
|
|
176
|
+
<title>{_esc(name)}</title>
|
|
177
|
+
<style>
|
|
178
|
+
:root {{ color-scheme: light dark; }}
|
|
179
|
+
* {{ box-sizing: border-box; margin: 0; }}
|
|
180
|
+
body {{
|
|
181
|
+
font: 16px/1.65 system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
182
|
+
max-width: 46rem; margin: 0 auto; padding: 3rem 1.5rem 4rem;
|
|
183
|
+
color: #1a2233; background: #fdfdfc;
|
|
184
|
+
}}
|
|
185
|
+
@media (prefers-color-scheme: dark) {{
|
|
186
|
+
body {{ color: #d7dce6; background: #0d1117; }}
|
|
187
|
+
.tag {{ background: #1f2937; color: #93a4bd; }}
|
|
188
|
+
.bar {{ background: #1f2937; }}
|
|
189
|
+
header, section {{ border-color: #232b3a; }}
|
|
190
|
+
}}
|
|
191
|
+
header {{ border-bottom: 2px solid #e5e4df; padding-bottom: 1.25rem; margin-bottom: 2rem; }}
|
|
192
|
+
h1 {{ font-size: 1.75rem; letter-spacing: -0.02em; }}
|
|
193
|
+
.meta {{ font-size: .8rem; opacity: .55; margin-top: .35rem; }}
|
|
194
|
+
section {{ margin-bottom: 2.25rem; }}
|
|
195
|
+
h2 {{
|
|
196
|
+
font-size: .8rem; text-transform: uppercase; letter-spacing: .12em;
|
|
197
|
+
opacity: .6; margin-bottom: 1rem;
|
|
198
|
+
}}
|
|
199
|
+
.summary {{ font-size: 1.05rem; }}
|
|
200
|
+
ul.caps, ul.cases {{ list-style: none; padding: 0; display: grid; gap: 1.1rem; }}
|
|
201
|
+
.cap-head {{ display: flex; align-items: baseline; gap: .6rem; }}
|
|
202
|
+
.cap-name {{ font-weight: 600; }}
|
|
203
|
+
.tag {{
|
|
204
|
+
font-size: .68rem; text-transform: uppercase; letter-spacing: .06em;
|
|
205
|
+
background: #eceae4; color: #5b6472; border-radius: 999px; padding: .1rem .55rem;
|
|
206
|
+
}}
|
|
207
|
+
.bar {{ height: 4px; background: #eceae4; border-radius: 2px; margin: .4rem 0; max-width: 16rem; }}
|
|
208
|
+
.bar-fill {{ height: 100%; background: #22a3a9; border-radius: 2px; }}
|
|
209
|
+
.cap-desc, .case p {{ font-size: .92rem; opacity: .85; }}
|
|
210
|
+
.case h3 {{ font-size: 1rem; margin-bottom: .25rem; }}
|
|
211
|
+
.case-outcome {{ font-size: .88rem; margin-top: .35rem; }}
|
|
212
|
+
footer {{ margin-top: 3rem; font-size: .75rem; opacity: .45; }}
|
|
213
|
+
footer a {{ color: inherit; }}
|
|
214
|
+
</style>
|
|
215
|
+
</head>
|
|
216
|
+
<body>
|
|
217
|
+
<header>
|
|
218
|
+
<h1>{_esc(name)}</h1>
|
|
219
|
+
<p class="meta">Compiled expert context · generated {_esc(ts)}</p>
|
|
220
|
+
</header>
|
|
221
|
+
{summary_section}
|
|
222
|
+
{caps_section}
|
|
223
|
+
{cases_section}
|
|
224
|
+
<footer>
|
|
225
|
+
<p>Generated from a continuously compiled expert context —
|
|
226
|
+
every capability and case above traces back to real work.
|
|
227
|
+
Built with <a href="https://github.com/kiwiberry-ai/kiwime">kiwiMe</a>.</p>
|
|
228
|
+
</footer>
|
|
229
|
+
</body>
|
|
230
|
+
</html>
|
|
231
|
+
"""
|