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.
Files changed (37) hide show
  1. convolvger/__init__.py +0 -0
  2. convolvger/cli/__init__.py +0 -0
  3. convolvger/cli/main.py +223 -0
  4. convolvger/core/__init__.py +33 -0
  5. convolvger/core/archive.py +106 -0
  6. convolvger/core/errors.py +6 -0
  7. convolvger/core/findings.py +83 -0
  8. convolvger/core/models.py +107 -0
  9. convolvger/core/results.py +39 -0
  10. convolvger/core/source.py +14 -0
  11. convolvger/providers/__init__.py +7 -0
  12. convolvger/providers/base.py +26 -0
  13. convolvger/providers/chatgpt/__init__.py +3 -0
  14. convolvger/providers/chatgpt/_content.py +111 -0
  15. convolvger/providers/chatgpt/_fetch.py +83 -0
  16. convolvger/providers/chatgpt/_parse.py +156 -0
  17. convolvger/providers/chatgpt/_turbostream.py +148 -0
  18. convolvger/providers/chatgpt/_urls.py +30 -0
  19. convolvger/providers/chatgpt/provider.py +22 -0
  20. convolvger/providers/claude/__init__.py +0 -0
  21. convolvger/providers/default.py +15 -0
  22. convolvger/providers/gemini/__init__.py +0 -0
  23. convolvger/providers/grok/__init__.py +0 -0
  24. convolvger/providers/registry.py +33 -0
  25. convolvger/py.typed +0 -0
  26. convolvger/renderers/__init__.py +4 -0
  27. convolvger/renderers/json.py +35 -0
  28. convolvger/renderers/markdown.py +175 -0
  29. convolvger/storage/__init__.py +0 -0
  30. convolvger/validation/__init__.py +0 -0
  31. convolvger/validation/aspects.py +70 -0
  32. convolvger/validation/report.py +53 -0
  33. convolvger-0.1.0.dist-info/METADATA +86 -0
  34. convolvger-0.1.0.dist-info/RECORD +37 -0
  35. convolvger-0.1.0.dist-info/WHEEL +4 -0
  36. convolvger-0.1.0.dist-info/entry_points.txt +3 -0
  37. 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,6 @@
1
+ class ConvolvgerError(Exception):
2
+ """Base class for all Convolvger errors."""
3
+
4
+
5
+ class ProviderNotFoundError(ConvolvgerError):
6
+ """Raised when no provider can handle the requested source."""
@@ -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,7 @@
1
+ from convolvger.providers.base import Provider
2
+ from convolvger.providers.registry import ProviderRegistry
3
+
4
+ __all__ = [
5
+ "Provider",
6
+ "ProviderRegistry",
7
+ ]
@@ -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
+ ...
@@ -0,0 +1,3 @@
1
+ from convolvger.providers.chatgpt.provider import ChatGPTProvider
2
+
3
+ __all__ = ["ChatGPTProvider"]