ctxverify 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.
ctxverify/__init__.py ADDED
@@ -0,0 +1,47 @@
1
+ """ctxverify — a linter and verifier for context packs.
2
+
3
+ A context pack is a portable container for the knowledge an agent is given:
4
+ atomic claims, the evidence behind each claim, the entities involved, the
5
+ relations between them, and the contradictions and freshness states that a
6
+ reader needs in order to decide how much to trust it.
7
+
8
+ Compilers produce packs. ctxverify judges them.
9
+
10
+ from ctxverify import load_pack, run_checks
11
+
12
+ pack = load_pack("./pack")
13
+ report = run_checks(pack)
14
+ print(report.to_text())
15
+ raise SystemExit(report.exit_code())
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from .loader import PackLoadError, load_pack
21
+ from .model import FORMAT_VERSION, SUPPORTED_FORMAT_VERSIONS, Claim, Evidence, Pack
22
+ from .report import Finding, Report
23
+ from .rules import RULES, RULES_BY_ID, Rule
24
+ from .version import __version__
25
+
26
+ __all__ = [
27
+ "Claim",
28
+ "Evidence",
29
+ "Finding",
30
+ "FORMAT_VERSION",
31
+ "Pack",
32
+ "PackLoadError",
33
+ "RULES",
34
+ "RULES_BY_ID",
35
+ "Report",
36
+ "Rule",
37
+ "SUPPORTED_FORMAT_VERSIONS",
38
+ "__version__",
39
+ "load_pack",
40
+ "run_checks",
41
+ ]
42
+
43
+
44
+ def run_checks(pack, options=None): # re-exported lazily to avoid a cycle
45
+ from .checks import run_checks as _run_checks
46
+
47
+ return _run_checks(pack, options)
ctxverify/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
@@ -0,0 +1,14 @@
1
+ """Adapters: build a pack from a corpus that already exists.
2
+
3
+ An adapter is deliberately conservative. It reads what is on disk, records
4
+ where every piece came from, and does not try to repair anything. The linter
5
+ then judges the result — that separation is the point of the format.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .generic import export_markdown
11
+ from .llmwiki import export_llmwiki
12
+ from .obsidian import export_obsidian
13
+
14
+ __all__ = ["export_llmwiki", "export_markdown", "export_obsidian"]
@@ -0,0 +1,190 @@
1
+ """Export a plain markdown directory into a context pack.
2
+
3
+ Not every knowledge base is an Obsidian vault. Karpathy's LLM Wiki pattern
4
+ — a directory of markdown pages compiled from raw sources — has no
5
+ frontmatter, no aliases and no wikilinks by convention, and a tool that only
6
+ reads Obsidian vaults would not touch it.
7
+
8
+ This adapter reads any directory of markdown files: frontmatter if it is
9
+ there, the first heading and first paragraph if it is not. It is the
10
+ lowest-common-denominator adapter, and it is the one to start from when
11
+ writing an adapter for your own corpus.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import re
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+
21
+ from ..frontmatter import as_list, as_text, parse_frontmatter, split_frontmatter
22
+ from ..model import CLAIM_STATUSES, Claim, Entity, Evidence, Manifest, Pack, Source
23
+ from ..resolve import LinkIndex, strip_wikilink
24
+ from ..writer import evidence_record
25
+
26
+ SKIP_DIRS = {".git", ".obsidian", ".trash", "node_modules", ".venv", "__pycache__", "dist", "build"}
27
+
28
+ HEADING_RE = re.compile(r"^#{1,6}\s+(.+?)\s*$", re.MULTILINE)
29
+ WIKILINK_RE = re.compile(r"\[\[([^\[\]]+?)\]\]")
30
+ CODE_FENCE_RE = re.compile(r"```.*?```")
31
+
32
+
33
+ def _iter_markdown(root: Path):
34
+ for path in sorted(root.rglob("*.md")):
35
+ parts = set(path.relative_to(root).parts[:-1])
36
+ if parts & SKIP_DIRS:
37
+ continue
38
+ if any(part.startswith(".") for part in parts):
39
+ continue
40
+ yield path
41
+
42
+
43
+ def _first_heading(body: str) -> str:
44
+ match = HEADING_RE.search(body)
45
+ return match.group(1).strip() if match else ""
46
+
47
+
48
+ def _first_paragraph(body: str) -> str:
49
+ text = CODE_FENCE_RE.sub("", body)
50
+ for block in re.split(r"\n\s*\n", text):
51
+ stripped = block.strip()
52
+ if not stripped or stripped.startswith(("#", "```", "|", "-", "*", ">", "<!--")):
53
+ continue
54
+ return re.sub(r"\s+", " ", stripped)[:600]
55
+ return ""
56
+
57
+
58
+ def export_markdown(
59
+ corpus: str | Path,
60
+ pack_id: str | None = None,
61
+ include: list[str] | None = None,
62
+ exclude: list[str] | None = None,
63
+ ) -> Pack:
64
+ """Read a markdown corpus and return a :class:`Pack`."""
65
+ root = Path(corpus).expanduser().resolve()
66
+ if not root.exists():
67
+ raise FileNotFoundError(f"corpus not found: {root}")
68
+
69
+ include = include or []
70
+ exclude = exclude or []
71
+
72
+ pages: list[tuple[Path, str]] = []
73
+ for path in _iter_markdown(root):
74
+ rel = path.relative_to(root).as_posix()
75
+ if include and not any(pattern in rel for pattern in include):
76
+ continue
77
+ if exclude and any(pattern in rel for pattern in exclude):
78
+ continue
79
+ pages.append((path, rel))
80
+
81
+ index = LinkIndex(paths=[rel for _, rel in pages])
82
+
83
+ claims: list[Claim] = []
84
+ evidence: list[Evidence] = []
85
+ entities: list[Entity] = []
86
+ sources: list[Source] = []
87
+ entity_ids: dict[str, str] = {}
88
+
89
+ for number, (path, rel) in enumerate(pages, start=1):
90
+ text = path.read_text(encoding="utf-8", errors="replace")
91
+ front = parse_frontmatter(text)
92
+ _, body = split_frontmatter(text)
93
+
94
+ source_id = f"src-{number:03d}"
95
+ claim_id = f"clm-{number:03d}"
96
+ evidence_id = f"ev-{number:03d}"
97
+
98
+ title = as_text(front.get("title")).strip() or _first_heading(body) or path.stem
99
+ summary = (
100
+ as_text(front.get("summary")).strip()
101
+ or as_text(front.get("description")).strip()
102
+ or _first_paragraph(body)
103
+ or title
104
+ )
105
+
106
+ raw_status = as_text(front.get("status")).strip().lower()
107
+ status = raw_status if raw_status in CLAIM_STATUSES else "asserted"
108
+
109
+ confidence = front.get("confidence")
110
+ if not isinstance(confidence, (int, float)) or isinstance(confidence, bool):
111
+ confidence = None
112
+
113
+ topics = as_list(front.get("topics")) or as_list(front.get("tags"))
114
+
115
+ links = []
116
+ for raw in WIKILINK_RE.findall(body):
117
+ target = strip_wikilink(raw)
118
+ if not target:
119
+ continue
120
+ links.append({"target": raw, "resolved": index.resolve(raw)})
121
+
122
+ claim_entities: list[str] = []
123
+ for name in as_list(front.get("entities")):
124
+ key = name.strip().lower()
125
+ if not key:
126
+ continue
127
+ if key not in entity_ids:
128
+ entity_id = f"ent-{len(entity_ids) + 1:03d}"
129
+ entity_ids[key] = entity_id
130
+ entities.append(Entity(id=entity_id, name=name.strip()))
131
+ claim_entities.append(entity_ids[key])
132
+
133
+ claims.append(
134
+ Claim(
135
+ id=claim_id,
136
+ text=summary,
137
+ status=status,
138
+ confidence=float(confidence) if confidence is not None else None,
139
+ evidence=[evidence_id],
140
+ entities=claim_entities,
141
+ topics=topics,
142
+ links=links,
143
+ created_at=as_text(front.get("created")).strip() or None,
144
+ updated_at=as_text(front.get("updated")).strip() or None,
145
+ )
146
+ )
147
+
148
+ record = evidence_record(
149
+ evidence_id,
150
+ claim_id,
151
+ source_id,
152
+ summary,
153
+ locator={"path": rel, "line_start": 1, "line_end": 1},
154
+ retrieved_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
155
+ )
156
+ evidence.append(Evidence.from_dict(record))
157
+
158
+ sources.append(
159
+ Source(
160
+ id=source_id,
161
+ path=rel,
162
+ title=title,
163
+ kind="markdown",
164
+ sha256=hashlib.sha256(text.encode("utf-8")).hexdigest(),
165
+ modified_at=datetime.fromtimestamp(path.stat().st_mtime, timezone.utc).isoformat(
166
+ timespec="seconds"
167
+ ),
168
+ )
169
+ )
170
+
171
+ manifest = Manifest(
172
+ pack_id=pack_id or root.name,
173
+ title=f"{root.name} (markdown export)",
174
+ description=f"Context pack exported from the markdown corpus at {root}.",
175
+ generated_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
176
+ generator={"name": "ctxverify-adapter-markdown", "version": "0.1.0"},
177
+ sources=sources,
178
+ )
179
+
180
+ return Pack(
181
+ root=str(root),
182
+ manifest=manifest,
183
+ claims=claims,
184
+ evidence=evidence,
185
+ entities=entities,
186
+ relations=[],
187
+ contradictions=[],
188
+ freshness={},
189
+ freshness_policy={"default_ttl_days": 180},
190
+ )
@@ -0,0 +1,199 @@
1
+ """Export an llmwiki project into a context pack.
2
+
3
+ llmwiki (https://github.com/atomicstrata/llm-wiki-compiler) compiles raw
4
+ sources into a markdown wiki with citation markers such as
5
+ ``^[source.md:42-58]`` and ``[[wikilinks]]`` between concept pages.
6
+
7
+ This adapter reads that wiki as it stands. It does not import, does not
8
+ refresh, does not ask for API keys and does not need an LLM: it turns the
9
+ existing artifact into a pack so that any linter — this one included — can
10
+ judge it on the same terms as every other compiler's output.
11
+
12
+ The result is deliberately conservative. A page whose citation markers point
13
+ at a file that is not in ``sources/`` still produces an evidence record: the
14
+ claim is reported as uncited rather than the citation being quietly dropped,
15
+ because a missing citation and a silently dropped one look the same to a
16
+ reader and mean different things to whoever owns the pack.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ import re
23
+ from datetime import datetime, timezone
24
+ from pathlib import Path
25
+
26
+ from ..frontmatter import as_list, as_text, parse_frontmatter, split_frontmatter
27
+ from ..model import CLAIM_STATUSES, Claim, Entity, Evidence, Manifest, Pack, Source
28
+ from ..resolve import LinkIndex, strip_wikilink
29
+ from ..writer import evidence_record
30
+
31
+ CITATION_RE = re.compile(r"\^\[([^\]]+)\]")
32
+ WIKILINK_RE = re.compile(r"\[\[([^\[\]]+?)\]\]")
33
+ LINE_RANGE_RE = re.compile(r"^(?P<file>[^:]+):(?P<start>\d+)(?:-(?P<end>\d+))?$")
34
+
35
+ WIKI_DIRS = ("wiki/concepts", "wiki/queries", "wiki")
36
+
37
+
38
+ def _iter_pages(root: Path):
39
+ seen: set[Path] = set()
40
+ for sub in WIKI_DIRS:
41
+ directory = root / sub
42
+ if not directory.exists():
43
+ continue
44
+ for path in sorted(directory.rglob("*.md")):
45
+ if path in seen:
46
+ continue
47
+ seen.add(path)
48
+ yield path
49
+
50
+
51
+ def _paragraphs(body: str) -> list[str]:
52
+ chunks = re.split(r"\n\s*\n", body)
53
+ return [chunk.strip() for chunk in chunks if chunk.strip()]
54
+
55
+
56
+ def export_llmwiki(project: str | Path) -> Pack:
57
+ """Read an llmwiki project and return a :class:`Pack`."""
58
+ root = Path(project).expanduser().resolve()
59
+ if not root.exists():
60
+ raise FileNotFoundError(f"llmwiki project not found: {root}")
61
+
62
+ pages = list(_iter_pages(root))
63
+ page_paths = [path.relative_to(root).as_posix() for path in pages]
64
+ index = LinkIndex(paths=page_paths)
65
+
66
+ claims: list[Claim] = []
67
+ evidence: list[Evidence] = []
68
+ entities: list[Entity] = []
69
+ sources: list[Source] = []
70
+ source_ids: dict[str, str] = {}
71
+ entity_ids: dict[str, str] = {}
72
+ evidence_counter = 0
73
+
74
+ def source_id_for(filename: str) -> str:
75
+ key = filename.strip()
76
+ if key not in source_ids:
77
+ source_ids[key] = f"src-{len(source_ids) + 1:03d}"
78
+ sources.append(
79
+ Source(
80
+ id=source_ids[key],
81
+ path=f"sources/{key}",
82
+ title=key,
83
+ kind="file",
84
+ sha256=None,
85
+ )
86
+ )
87
+ return source_ids[key]
88
+
89
+ for number, path in enumerate(pages, start=1):
90
+ rel = path.relative_to(root).as_posix()
91
+ text = path.read_text(encoding="utf-8", errors="replace")
92
+ front = parse_frontmatter(text)
93
+ _, body = split_frontmatter(text)
94
+
95
+ claim_id = f"clm-{number:03d}"
96
+ title = as_text(front.get("title")).strip() or path.stem
97
+ summary = as_text(front.get("summary")).strip()
98
+
99
+ confidence = front.get("confidence")
100
+ if not isinstance(confidence, (int, float)) or isinstance(confidence, bool):
101
+ confidence = None
102
+
103
+ raw_status = as_text(front.get("status")).strip().lower()
104
+ status = raw_status if raw_status in CLAIM_STATUSES else "asserted"
105
+
106
+ declared_sources = as_list(front.get("sources"))
107
+ evidence_ids: list[str] = []
108
+
109
+ for paragraph in _paragraphs(body):
110
+ citations = CITATION_RE.findall(paragraph)
111
+ if not citations:
112
+ continue
113
+ quote = CITATION_RE.sub("", paragraph).strip()
114
+ quote = re.sub(r"\s+", " ", quote)[:1200]
115
+ for citation in citations:
116
+ match = LINE_RANGE_RE.match(citation.strip())
117
+ filename = match.group("file") if match else citation.strip()
118
+ line_start = int(match.group("start")) if match else None
119
+ line_end = int(match.group("end")) if match and match.group("end") else line_start
120
+ locator = {"path": f"sources/{filename.strip()}"}
121
+ if line_start is not None:
122
+ locator["line_start"] = line_start
123
+ locator["line_end"] = line_end or line_start
124
+ evidence_counter += 1
125
+ evidence_id = f"ev-{evidence_counter:03d}"
126
+ record = evidence_record(
127
+ evidence_id,
128
+ claim_id,
129
+ source_id_for(filename),
130
+ quote or title,
131
+ locator=locator,
132
+ retrieved_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
133
+ )
134
+ evidence.append(Evidence.from_dict(record))
135
+ evidence_ids.append(evidence_id)
136
+
137
+ links = []
138
+ for raw in WIKILINK_RE.findall(body):
139
+ target = strip_wikilink(raw)
140
+ if not target:
141
+ continue
142
+ links.append({"target": raw, "resolved": index.resolve(raw)})
143
+
144
+ claim_entities: list[str] = []
145
+ for name in as_list(front.get("entities")):
146
+ key = name.strip().lower()
147
+ if not key:
148
+ continue
149
+ if key not in entity_ids:
150
+ entity_id = f"ent-{len(entity_ids) + 1:03d}"
151
+ entity_ids[key] = entity_id
152
+ entities.append(Entity(id=entity_id, name=name.strip()))
153
+ claim_entities.append(entity_ids[key])
154
+
155
+ topics = as_list(front.get("topics")) or ([as_text(front.get("kind")).strip()] if front.get("kind") else [])
156
+
157
+ claims.append(
158
+ Claim(
159
+ id=claim_id,
160
+ text=summary or title,
161
+ status=status,
162
+ confidence=float(confidence) if confidence is not None else None,
163
+ evidence=evidence_ids,
164
+ entities=claim_entities,
165
+ topics=topics,
166
+ links=links,
167
+ created_at=as_text(front.get("createdAt")).strip() or None,
168
+ updated_at=as_text(front.get("updatedAt")).strip() or None,
169
+ )
170
+ )
171
+
172
+ for filename in declared_sources:
173
+ source_id_for(filename)
174
+
175
+ manifest = Manifest(
176
+ pack_id=root.name,
177
+ title=f"{root.name} (llmwiki export)",
178
+ description=f"Context pack exported from the llmwiki project at {root}.",
179
+ generated_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
180
+ generator={"name": "ctxverify-adapter-llmwiki", "version": "0.1.0"},
181
+ sources=sources,
182
+ )
183
+
184
+ for source in sources:
185
+ candidate = root / source.path
186
+ if candidate.exists():
187
+ source.sha256 = hashlib.sha256(candidate.read_bytes()).hexdigest()
188
+
189
+ return Pack(
190
+ root=str(root),
191
+ manifest=manifest,
192
+ claims=claims,
193
+ evidence=evidence,
194
+ entities=entities,
195
+ relations=[],
196
+ contradictions=[],
197
+ freshness={},
198
+ freshness_policy={"default_ttl_days": 180},
199
+ )