convolvger 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.
- convolvger/__init__.py +0 -0
- convolvger/cli/__init__.py +0 -0
- convolvger/cli/main.py +223 -0
- convolvger/core/__init__.py +33 -0
- convolvger/core/archive.py +106 -0
- convolvger/core/errors.py +6 -0
- convolvger/core/findings.py +83 -0
- convolvger/core/models.py +107 -0
- convolvger/core/results.py +39 -0
- convolvger/core/source.py +14 -0
- convolvger/providers/__init__.py +7 -0
- convolvger/providers/base.py +26 -0
- convolvger/providers/chatgpt/__init__.py +3 -0
- convolvger/providers/chatgpt/_content.py +111 -0
- convolvger/providers/chatgpt/_fetch.py +83 -0
- convolvger/providers/chatgpt/_parse.py +156 -0
- convolvger/providers/chatgpt/_turbostream.py +148 -0
- convolvger/providers/chatgpt/_urls.py +30 -0
- convolvger/providers/chatgpt/provider.py +22 -0
- convolvger/providers/claude/__init__.py +0 -0
- convolvger/providers/default.py +15 -0
- convolvger/providers/gemini/__init__.py +0 -0
- convolvger/providers/grok/__init__.py +0 -0
- convolvger/providers/registry.py +33 -0
- convolvger/py.typed +0 -0
- convolvger/renderers/__init__.py +4 -0
- convolvger/renderers/json.py +35 -0
- convolvger/renderers/markdown.py +175 -0
- convolvger/storage/__init__.py +0 -0
- convolvger/validation/__init__.py +0 -0
- convolvger/validation/aspects.py +70 -0
- convolvger/validation/report.py +53 -0
- convolvger-0.1.0.dist-info/METADATA +86 -0
- convolvger-0.1.0.dist-info/RECORD +37 -0
- convolvger-0.1.0.dist-info/WHEEL +4 -0
- convolvger-0.1.0.dist-info/entry_points.txt +3 -0
- convolvger-0.1.0.dist-info/licenses/LICENSE +21 -0
convolvger/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
convolvger/cli/main.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Command line interface for Convolvger."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from convolvger.core.archive import ArchiveError, load_archive
|
|
11
|
+
from convolvger.core.errors import ConvolvgerError
|
|
12
|
+
from convolvger.core.findings import Finding, Level
|
|
13
|
+
from convolvger.core.results import ParseError, ParseResult
|
|
14
|
+
from convolvger.core.source import RawSource
|
|
15
|
+
from convolvger.providers.default import build_registry
|
|
16
|
+
from convolvger.renderers import render_json, render_markdown
|
|
17
|
+
from convolvger.validation.report import Report
|
|
18
|
+
|
|
19
|
+
EXIT_OK = 0
|
|
20
|
+
EXIT_FAILED = 1
|
|
21
|
+
EXIT_WARNINGS = 2
|
|
22
|
+
|
|
23
|
+
FORMATS = ("md", "json")
|
|
24
|
+
FORMAT_HELP = f"Output format ({', '.join(FORMATS)})."
|
|
25
|
+
|
|
26
|
+
app = typer.Typer(
|
|
27
|
+
name="convolvger",
|
|
28
|
+
help="Archive public AI conversations into portable, provider-independent formats.",
|
|
29
|
+
no_args_is_help=True,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _slug(title: str | None, fallback: str) -> str:
|
|
34
|
+
base = (title or fallback).lower()
|
|
35
|
+
base = re.sub(r"[^a-z0-9]+", "-", base).strip("-")
|
|
36
|
+
return base[:60] or fallback
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _warn(message: str) -> None:
|
|
40
|
+
typer.echo(message, err=True)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _retrieve(source: str) -> tuple[RawSource, ParseResult]:
|
|
44
|
+
"""Detect, fetch, and parse a conversation source."""
|
|
45
|
+
registry = build_registry()
|
|
46
|
+
provider = registry.detect(source)
|
|
47
|
+
raw = provider.fetch(source)
|
|
48
|
+
return raw, provider.parse(raw)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _report_findings(findings: list[Finding]) -> None:
|
|
52
|
+
"""Report every finding, loudest first.
|
|
53
|
+
|
|
54
|
+
Notes are printed as well as warnings: a note does not change the
|
|
55
|
+
exit status, but it is still something the snapshot did not carry
|
|
56
|
+
and the operator should be able to see it.
|
|
57
|
+
"""
|
|
58
|
+
if not findings:
|
|
59
|
+
return
|
|
60
|
+
warnings = [item for item in findings if item.level is Level.WARNING]
|
|
61
|
+
notes = [item for item in findings if item.level is Level.NOTE]
|
|
62
|
+
counts = [
|
|
63
|
+
f"{len(group)} {label}"
|
|
64
|
+
for group, label in ((warnings, "warning(s)"), (notes, "note(s)"))
|
|
65
|
+
if group
|
|
66
|
+
]
|
|
67
|
+
_warn(f"\n{', '.join(counts)}:")
|
|
68
|
+
for item in warnings + notes:
|
|
69
|
+
where = f" ({item.message_id})" if item.message_id else ""
|
|
70
|
+
_warn(f" - [{item.level.value}] {item.code}{where}: {item.message}")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _fail(error: Exception) -> None:
|
|
74
|
+
_warn(f"Error: {error}")
|
|
75
|
+
if isinstance(error, ParseError) and error.findings:
|
|
76
|
+
_report_findings(error.findings)
|
|
77
|
+
raise typer.Exit(EXIT_FAILED)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@app.command()
|
|
81
|
+
def providers() -> None:
|
|
82
|
+
"""List supported conversation providers."""
|
|
83
|
+
for name in build_registry().names():
|
|
84
|
+
typer.echo(name)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@app.command()
|
|
88
|
+
def inspect(
|
|
89
|
+
source: Annotated[
|
|
90
|
+
str,
|
|
91
|
+
typer.Argument(help="Public conversation URL or local conversation source."),
|
|
92
|
+
],
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Inspect a conversation source without exporting it."""
|
|
95
|
+
try:
|
|
96
|
+
_, result = _retrieve(source)
|
|
97
|
+
except ConvolvgerError as error:
|
|
98
|
+
_fail(error)
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
conversation = result.conversation
|
|
102
|
+
typer.echo(f"Title: {conversation.title or '(untitled)'}")
|
|
103
|
+
typer.echo(f"Provider: {conversation.provider}")
|
|
104
|
+
typer.echo(f"Messages: {len(conversation.messages)}")
|
|
105
|
+
typer.echo(f"Visible: {sum(m.visible for m in conversation.messages)}")
|
|
106
|
+
typer.echo(f"Active: {sum(m.active for m in conversation.messages)}")
|
|
107
|
+
typer.echo(f"Blocks: {sum(len(m.content) for m in conversation.messages)}")
|
|
108
|
+
|
|
109
|
+
_report_findings(result.findings)
|
|
110
|
+
raise typer.Exit(EXIT_WARNINGS if result.warned else EXIT_OK)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@app.command()
|
|
114
|
+
def export(
|
|
115
|
+
source: Annotated[
|
|
116
|
+
str,
|
|
117
|
+
typer.Argument(help="Public conversation URL or local conversation source."),
|
|
118
|
+
],
|
|
119
|
+
output: Annotated[
|
|
120
|
+
Path | None,
|
|
121
|
+
typer.Option("--output", "-o", help="Output path, or - for stdout."),
|
|
122
|
+
] = None,
|
|
123
|
+
output_format: Annotated[
|
|
124
|
+
str,
|
|
125
|
+
typer.Option("--format", "-f", help=FORMAT_HELP),
|
|
126
|
+
] = "md",
|
|
127
|
+
include_hidden: Annotated[
|
|
128
|
+
bool,
|
|
129
|
+
typer.Option("--include-hidden", help="Include provider-hidden messages."),
|
|
130
|
+
] = False,
|
|
131
|
+
include_inactive: Annotated[
|
|
132
|
+
bool,
|
|
133
|
+
typer.Option("--include-inactive", help="Include deactivated branches."),
|
|
134
|
+
] = False,
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Export a conversation to a portable archive."""
|
|
137
|
+
if output_format not in FORMATS:
|
|
138
|
+
_warn(f"Error: unsupported format: {output_format}")
|
|
139
|
+
_warn(f"Supported formats: {', '.join(FORMATS)}")
|
|
140
|
+
raise typer.Exit(EXIT_FAILED)
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
raw, result = _retrieve(source)
|
|
144
|
+
except ConvolvgerError as error:
|
|
145
|
+
_fail(error)
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
if output_format == "json":
|
|
149
|
+
if include_hidden or include_inactive:
|
|
150
|
+
_warn("Note: include flags are ignored; JSON keeps every message.")
|
|
151
|
+
rendered = render_json(
|
|
152
|
+
result.conversation,
|
|
153
|
+
findings=result.findings,
|
|
154
|
+
fetched_at=raw.fetched_at,
|
|
155
|
+
)
|
|
156
|
+
else:
|
|
157
|
+
rendered = render_markdown(
|
|
158
|
+
result.conversation,
|
|
159
|
+
include_hidden=include_hidden,
|
|
160
|
+
include_inactive=include_inactive,
|
|
161
|
+
findings=result.findings,
|
|
162
|
+
fetched_at=raw.fetched_at,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
if output is not None and str(output) == "-":
|
|
166
|
+
sys.stdout.write(rendered)
|
|
167
|
+
else:
|
|
168
|
+
destination = output or Path(
|
|
169
|
+
f"{_slug(result.conversation.title, result.conversation.provider)}.{output_format}"
|
|
170
|
+
)
|
|
171
|
+
destination.write_text(rendered, encoding="utf-8")
|
|
172
|
+
_warn(f"Wrote {destination}")
|
|
173
|
+
|
|
174
|
+
_report_findings(result.findings)
|
|
175
|
+
raise typer.Exit(EXIT_WARNINGS if result.warned else EXIT_OK)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _yes_no(value: bool) -> str:
|
|
179
|
+
return "yes" if value else "no"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@app.command()
|
|
183
|
+
def verify(
|
|
184
|
+
archive: Annotated[
|
|
185
|
+
Path,
|
|
186
|
+
typer.Argument(help="Path to a JSON archive written by convolvger."),
|
|
187
|
+
],
|
|
188
|
+
) -> None:
|
|
189
|
+
"""Report what a JSON archive says about its own integrity.
|
|
190
|
+
|
|
191
|
+
The verdict is derived from the findings the archive already
|
|
192
|
+
carries. Nothing is re-fetched and nothing is re-parsed: this reads
|
|
193
|
+
a file and reports what it records.
|
|
194
|
+
"""
|
|
195
|
+
try:
|
|
196
|
+
envelope = load_archive(archive.read_text(encoding="utf-8"))
|
|
197
|
+
except (ArchiveError, OSError) as error:
|
|
198
|
+
_fail(error)
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
report = Report(findings=envelope.findings)
|
|
202
|
+
conversation = envelope.conversation
|
|
203
|
+
|
|
204
|
+
typer.echo(f"Archive: {archive}")
|
|
205
|
+
typer.echo(f"Schema: {envelope.schema_version}")
|
|
206
|
+
typer.echo(f"Tool: {envelope.tool} {envelope.tool_version}")
|
|
207
|
+
if envelope.retrieved_at is not None:
|
|
208
|
+
typer.echo(f"Retrieved: {envelope.retrieved_at.isoformat()}")
|
|
209
|
+
typer.echo(f"Provider: {conversation.provider}")
|
|
210
|
+
typer.echo(f"Messages: {len(conversation.messages)}")
|
|
211
|
+
typer.echo(f"Complete: {_yes_no(report.complete)}")
|
|
212
|
+
typer.echo(f"Faithful: {_yes_no(report.faithful)}")
|
|
213
|
+
if report.unrecognised:
|
|
214
|
+
typer.echo(f"Unknown: {len(report.unrecognised)} unclassifiable code(s)")
|
|
215
|
+
|
|
216
|
+
_report_findings(envelope.findings)
|
|
217
|
+
raise typer.Exit(
|
|
218
|
+
EXIT_OK if report.complete and report.faithful else EXIT_WARNINGS
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
if __name__ == "__main__":
|
|
223
|
+
app()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from convolvger.core.archive import ArchiveEnvelope
|
|
2
|
+
from convolvger.core.errors import ConvolvgerError, ProviderNotFoundError
|
|
3
|
+
from convolvger.core.models import (
|
|
4
|
+
CodeBlock,
|
|
5
|
+
ContentBlock,
|
|
6
|
+
Conversation,
|
|
7
|
+
KnownBlock,
|
|
8
|
+
Message,
|
|
9
|
+
MessageRole,
|
|
10
|
+
ReasoningBlock,
|
|
11
|
+
TextBlock,
|
|
12
|
+
UnknownBlock,
|
|
13
|
+
)
|
|
14
|
+
from convolvger.core.results import ParseError, ParseResult
|
|
15
|
+
from convolvger.core.source import RawSource
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"ArchiveEnvelope",
|
|
19
|
+
"CodeBlock",
|
|
20
|
+
"ContentBlock",
|
|
21
|
+
"Conversation",
|
|
22
|
+
"ConvolvgerError",
|
|
23
|
+
"KnownBlock",
|
|
24
|
+
"Message",
|
|
25
|
+
"MessageRole",
|
|
26
|
+
"ParseError",
|
|
27
|
+
"ParseResult",
|
|
28
|
+
"ProviderNotFoundError",
|
|
29
|
+
"RawSource",
|
|
30
|
+
"ReasoningBlock",
|
|
31
|
+
"TextBlock",
|
|
32
|
+
"UnknownBlock",
|
|
33
|
+
]
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""The archival package produced by the JSON export.
|
|
2
|
+
|
|
3
|
+
The Markdown renderer is a dissemination package and may omit content.
|
|
4
|
+
This is the archival package: it omits nothing the canonical model holds.
|
|
5
|
+
|
|
6
|
+
Extraction findings and the retrieval time describe the capture event
|
|
7
|
+
rather than the conversation, so they sit beside the conversation here,
|
|
8
|
+
matching ``ParseResult`` rather than contradicting it. ``conversation``
|
|
9
|
+
alone round-trips back to an equal ``Conversation``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
17
|
+
|
|
18
|
+
from convolvger.core.errors import ConvolvgerError
|
|
19
|
+
from convolvger.core.findings import Finding
|
|
20
|
+
from convolvger.core.models import Conversation
|
|
21
|
+
|
|
22
|
+
SCHEMA_VERSION = 2
|
|
23
|
+
"""Bumped only when a change would stop an older reader loading a file.
|
|
24
|
+
|
|
25
|
+
Version 2 replaced the ``warnings`` list of strings with ``findings``.
|
|
26
|
+
Adding a field is safe for a reader that allows unknown ones; removing
|
|
27
|
+
or renaming one is not.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ArchiveError(ConvolvgerError):
|
|
32
|
+
"""Raised when a file cannot be read as an archive of this version.
|
|
33
|
+
|
|
34
|
+
Lives here rather than in ``errors`` for the same reason
|
|
35
|
+
``ParseError`` lives beside the parser: it belongs to the format it
|
|
36
|
+
guards.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _tool_version() -> str:
|
|
41
|
+
try:
|
|
42
|
+
return version("convolvger")
|
|
43
|
+
except PackageNotFoundError:
|
|
44
|
+
return "unknown"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ArchiveEnvelope(BaseModel):
|
|
48
|
+
"""A conversation together with the provenance of its capture.
|
|
49
|
+
|
|
50
|
+
``extra="allow"``, unlike every other model here. An archive is a
|
|
51
|
+
permanent file that a later version of this tool must be able to
|
|
52
|
+
read: forbidding unknown fields would make every future addition a
|
|
53
|
+
breaking change, and ignoring them would drop them on
|
|
54
|
+
re-serialisation. Allowing them preserves a field this version does
|
|
55
|
+
not model. The canonical models keep ``forbid`` -- they guard an
|
|
56
|
+
internal invariant, not a file format.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
model_config = ConfigDict(extra="allow")
|
|
60
|
+
|
|
61
|
+
schema_version: int = SCHEMA_VERSION
|
|
62
|
+
tool: str = "convolvger"
|
|
63
|
+
tool_version: str = Field(default_factory=_tool_version)
|
|
64
|
+
retrieved_at: datetime | None = None
|
|
65
|
+
findings: list[Finding] = Field(default_factory=list)
|
|
66
|
+
conversation: Conversation
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def load_archive(text: str) -> ArchiveEnvelope:
|
|
70
|
+
"""Read an archive this version can vouch for, or refuse it.
|
|
71
|
+
|
|
72
|
+
The declared schema version is checked before anything else is
|
|
73
|
+
trusted, because the envelope alone will not refuse a file it cannot
|
|
74
|
+
truthfully represent. A version 1 archive validates quite happily:
|
|
75
|
+
its ``warnings`` list survives as an unknown field and ``findings``
|
|
76
|
+
defaults to empty, so a reader that skipped this check would
|
|
77
|
+
pronounce a file recording real damage entirely clean. Refusal is
|
|
78
|
+
the only honest answer for a format this version cannot read as its
|
|
79
|
+
own.
|
|
80
|
+
|
|
81
|
+
Nothing here touches the filesystem. The caller owns reading the
|
|
82
|
+
file, as it already owns writing one.
|
|
83
|
+
"""
|
|
84
|
+
try:
|
|
85
|
+
payload = json.loads(text)
|
|
86
|
+
except json.JSONDecodeError as error:
|
|
87
|
+
raise ArchiveError(f"Not JSON: {error}") from error
|
|
88
|
+
|
|
89
|
+
if not isinstance(payload, dict):
|
|
90
|
+
raise ArchiveError(
|
|
91
|
+
f"Not an archive: expected an object, found {type(payload).__name__}"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
declared = payload.get("schema_version")
|
|
95
|
+
if not isinstance(declared, int):
|
|
96
|
+
raise ArchiveError("Not an archive: no schema_version")
|
|
97
|
+
if declared != SCHEMA_VERSION:
|
|
98
|
+
raise ArchiveError(
|
|
99
|
+
f"Archive declares schema version {declared}; "
|
|
100
|
+
f"this version reads {SCHEMA_VERSION}"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
return ArchiveEnvelope.model_validate(payload)
|
|
105
|
+
except ValidationError as error:
|
|
106
|
+
raise ArchiveError(f"Archive is not a valid envelope: {error}") from error
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Structured observations recorded during extraction.
|
|
2
|
+
|
|
3
|
+
A finding is one thing this tool noticed, not a verdict. Each carries a
|
|
4
|
+
stable ``code`` callers can key on without matching prose, and a
|
|
5
|
+
``level`` that decides whether it raises the exit status.
|
|
6
|
+
|
|
7
|
+
Level governs notification only. Every finding is recorded in the
|
|
8
|
+
archive whatever its level: nothing is ever withheld from the record
|
|
9
|
+
because it was judged unremarkable.
|
|
10
|
+
|
|
11
|
+
Codes are append-only. Never rename a code and never change what it
|
|
12
|
+
means -- an archive written a year ago keys on the same string, and a
|
|
13
|
+
descriptive name tempts renaming when an interpretation shifts. If the
|
|
14
|
+
meaning changes, add a new code and stop emitting the old one.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from enum import StrEnum
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel, ConfigDict
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Level(StrEnum):
|
|
23
|
+
"""How loudly a finding is reported.
|
|
24
|
+
|
|
25
|
+
There is deliberately no ``error``: an unrecoverable problem raises
|
|
26
|
+
``ParseError``, and there is then no archive for a finding to sit in.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
NOTE = "note"
|
|
30
|
+
WARNING = "warning"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
LEVELS: dict[str, Level] = {
|
|
34
|
+
"deferred_slot_not_merged": Level.WARNING,
|
|
35
|
+
"deferred_value_unresolved": Level.WARNING,
|
|
36
|
+
"literal_object_key": Level.NOTE,
|
|
37
|
+
"message_content_withheld": Level.WARNING,
|
|
38
|
+
"message_has_no_content": Level.NOTE,
|
|
39
|
+
"message_weight_absent": Level.NOTE,
|
|
40
|
+
"non_standard_json_constant": Level.WARNING,
|
|
41
|
+
"unexpected_message_weight": Level.WARNING,
|
|
42
|
+
"unmodelled_content_type": Level.WARNING,
|
|
43
|
+
"unrecognised_role": Level.WARNING,
|
|
44
|
+
"unrecognised_stream_line": Level.WARNING,
|
|
45
|
+
}
|
|
46
|
+
"""The single place a code's severity is decided.
|
|
47
|
+
|
|
48
|
+
``message_has_no_content`` is a note because a public share snapshot
|
|
49
|
+
omits content by design: OpenAI states that custom instructions are not
|
|
50
|
+
shared with share-link viewers, so empty system messages appear in every
|
|
51
|
+
such conversation. ``message_content_withheld`` is a warning because it
|
|
52
|
+
marks content the snapshot was expected to carry and did not.
|
|
53
|
+
|
|
54
|
+
``message_weight_absent`` is a note because absence is not damage on
|
|
55
|
+
its own: a snapshot that never carried the field says nothing about
|
|
56
|
+
whether a branch was deactivated. It is recorded rather than assumed
|
|
57
|
+
away so that a provider dropping a field it always sent is visible.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Finding(BaseModel):
|
|
62
|
+
"""One recorded observation about an extraction."""
|
|
63
|
+
|
|
64
|
+
model_config = ConfigDict(extra="forbid")
|
|
65
|
+
|
|
66
|
+
code: str
|
|
67
|
+
level: Level
|
|
68
|
+
message: str
|
|
69
|
+
message_id: str | None = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def finding(code: str, message: str, message_id: str | None = None) -> Finding:
|
|
73
|
+
"""Build a finding, taking its level from ``LEVELS``.
|
|
74
|
+
|
|
75
|
+
The level is not a parameter: one code must never be emitted at two
|
|
76
|
+
different severities. It is still written into the output, so a
|
|
77
|
+
reader does not need this table to interpret an archive.
|
|
78
|
+
"""
|
|
79
|
+
if code not in LEVELS:
|
|
80
|
+
raise KeyError(f"Unknown finding code: {code}")
|
|
81
|
+
return Finding(
|
|
82
|
+
code=code, level=LEVELS[code], message=message, message_id=message_id
|
|
83
|
+
)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from enum import StrEnum
|
|
3
|
+
from typing import Annotated, Any, Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MessageRole(StrEnum):
|
|
9
|
+
USER = "user"
|
|
10
|
+
ASSISTANT = "assistant"
|
|
11
|
+
SYSTEM = "system"
|
|
12
|
+
TOOL = "tool"
|
|
13
|
+
UNKNOWN = "unknown"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TextBlock(BaseModel):
|
|
17
|
+
"""Plain prose or Markdown authored by a participant."""
|
|
18
|
+
|
|
19
|
+
model_config = ConfigDict(extra="forbid")
|
|
20
|
+
|
|
21
|
+
type: Literal["text"] = "text"
|
|
22
|
+
text: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CodeBlock(BaseModel):
|
|
26
|
+
"""Source code, a command, or a tool invocation payload."""
|
|
27
|
+
|
|
28
|
+
model_config = ConfigDict(extra="forbid")
|
|
29
|
+
|
|
30
|
+
type: Literal["code"] = "code"
|
|
31
|
+
text: str
|
|
32
|
+
language: str | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ReasoningBlock(BaseModel):
|
|
36
|
+
"""A reasoning summary exposed by the provider's public snapshot.
|
|
37
|
+
|
|
38
|
+
This is not hidden chain-of-thought. It holds only what the snapshot
|
|
39
|
+
itself discloses, kept separate from assistant output so renderers
|
|
40
|
+
never present the two as equivalent.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
model_config = ConfigDict(extra="forbid")
|
|
44
|
+
|
|
45
|
+
type: Literal["reasoning"] = "reasoning"
|
|
46
|
+
text: str
|
|
47
|
+
label: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class UnknownBlock(BaseModel):
|
|
51
|
+
"""A block whose type this version does not model.
|
|
52
|
+
|
|
53
|
+
Unrecognised content is preserved verbatim in ``raw`` rather than
|
|
54
|
+
dropped, so an archive never loses recoverable content to a provider
|
|
55
|
+
format that changed after this release.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
model_config = ConfigDict(extra="forbid")
|
|
59
|
+
|
|
60
|
+
type: str
|
|
61
|
+
text: str | None = None
|
|
62
|
+
raw: dict[str, Any] = Field(default_factory=dict)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
KnownBlock = Annotated[
|
|
66
|
+
TextBlock | CodeBlock | ReasoningBlock,
|
|
67
|
+
Field(discriminator="type"),
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
ContentBlock = Annotated[
|
|
71
|
+
KnownBlock | UnknownBlock,
|
|
72
|
+
Field(union_mode="left_to_right"),
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Message(BaseModel):
|
|
77
|
+
"""A single turn in a conversation.
|
|
78
|
+
|
|
79
|
+
``provider_metadata`` holds source fields this version does not model
|
|
80
|
+
canonically, so nothing recoverable is lost. Renderers must not read
|
|
81
|
+
it; it exists for validation and lossless export only.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
model_config = ConfigDict(extra="forbid")
|
|
85
|
+
|
|
86
|
+
id: str | None = None
|
|
87
|
+
role: MessageRole
|
|
88
|
+
author: str | None = None
|
|
89
|
+
timestamp: datetime | None = None
|
|
90
|
+
content: list[ContentBlock] = Field(default_factory=list)
|
|
91
|
+
visible: bool = True
|
|
92
|
+
active: bool = True
|
|
93
|
+
status: str | None = None
|
|
94
|
+
recipient: str | None = None
|
|
95
|
+
provider_metadata: dict[str, Any] = Field(default_factory=dict)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Conversation(BaseModel):
|
|
99
|
+
model_config = ConfigDict(extra="forbid")
|
|
100
|
+
|
|
101
|
+
id: str | None = None
|
|
102
|
+
provider: str
|
|
103
|
+
source_url: str
|
|
104
|
+
title: str | None = None
|
|
105
|
+
created_at: datetime | None = None
|
|
106
|
+
updated_at: datetime | None = None
|
|
107
|
+
messages: list[Message] = Field(default_factory=list)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Outcome types carrying non-fatal diagnostics alongside parsed data.
|
|
2
|
+
|
|
3
|
+
Findings are extraction metadata, not conversation content, so they
|
|
4
|
+
travel beside the canonical model rather than inside it. This keeps the
|
|
5
|
+
same source snapshot producing the same ``Conversation`` even as finding
|
|
6
|
+
text evolves between releases.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
10
|
+
|
|
11
|
+
from convolvger.core.errors import ConvolvgerError
|
|
12
|
+
from convolvger.core.findings import Finding, Level
|
|
13
|
+
from convolvger.core.models import Conversation
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ParseResult(BaseModel):
|
|
17
|
+
"""A parsed conversation plus any non-fatal extraction findings."""
|
|
18
|
+
|
|
19
|
+
model_config = ConfigDict(extra="forbid")
|
|
20
|
+
|
|
21
|
+
conversation: Conversation
|
|
22
|
+
findings: list[Finding] = Field(default_factory=list)
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def warned(self) -> bool:
|
|
26
|
+
"""True when a finding is severe enough to change the exit status."""
|
|
27
|
+
return any(item.level is Level.WARNING for item in self.findings)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ParseError(ConvolvgerError):
|
|
31
|
+
"""Raised when a source cannot be parsed into a conversation.
|
|
32
|
+
|
|
33
|
+
Carries any findings accumulated before the failure so a caller can
|
|
34
|
+
still report what was observed.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, message: str, findings: list[Finding] | None = None) -> None:
|
|
38
|
+
super().__init__(message)
|
|
39
|
+
self.findings = list(findings or [])
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RawSource(BaseModel):
|
|
7
|
+
"""Unparsed provider snapshot, before canonicalization."""
|
|
8
|
+
|
|
9
|
+
model_config = ConfigDict(extra="forbid")
|
|
10
|
+
|
|
11
|
+
url: str
|
|
12
|
+
content: str
|
|
13
|
+
content_type: str | None = None
|
|
14
|
+
fetched_at: datetime | None = None
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from typing import Protocol
|
|
2
|
+
|
|
3
|
+
from convolvger.core.results import ParseResult
|
|
4
|
+
from convolvger.core.source import RawSource
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Provider(Protocol):
|
|
8
|
+
"""Adapter for one AI provider's public conversation format."""
|
|
9
|
+
|
|
10
|
+
name: str
|
|
11
|
+
|
|
12
|
+
def matches(self, url: str) -> bool:
|
|
13
|
+
"""Return True if this provider handles the given URL."""
|
|
14
|
+
...
|
|
15
|
+
|
|
16
|
+
def fetch(self, url: str) -> RawSource:
|
|
17
|
+
"""Retrieve the raw public snapshot for the given URL."""
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
def parse(self, source: RawSource) -> ParseResult:
|
|
21
|
+
"""Convert a raw snapshot into a canonical conversation.
|
|
22
|
+
|
|
23
|
+
Returns the conversation with any non-fatal findings. Raises
|
|
24
|
+
``ParseError`` when the source cannot be parsed at all.
|
|
25
|
+
"""
|
|
26
|
+
...
|