topiclayers 0.2.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Alessio Gandelli
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,201 @@
1
+ Metadata-Version: 2.4
2
+ Name: topiclayers
3
+ Version: 0.2.0
4
+ Summary: From any collection of posts to topical multilayer networks — hardened and social-scientist-friendly
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Keywords: topic-modeling,network-analysis,social-science,polarisation
8
+ Author: alessiogandelli
9
+ Author-email: alessiogandelli99@gmail.com
10
+ Requires-Python: >=3.12,<3.13
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Provides-Extra: labeling
17
+ Requires-Dist: bertopic (>=0.17.4,<0.18.0)
18
+ Requires-Dist: igraph (>=0.11.4)
19
+ Requires-Dist: jsonlines (>=4.0.0,<5.0.0)
20
+ Requires-Dist: litellm (>=1.40) ; extra == "labeling"
21
+ Requires-Dist: networkx (>=3.6,<4.0)
22
+ Requires-Dist: numpy (>=1.26,<3)
23
+ Requires-Dist: openai (>=3.0.0,<4.0.0)
24
+ Requires-Dist: pandas (>=2.2,<4)
25
+ Requires-Dist: python-dotenv (>=1.0.1,<2.0.0)
26
+ Requires-Dist: pyyaml (>=6.0,<7.0)
27
+ Requires-Dist: qdrant-client (>=1.19.0,<2.0.0)
28
+ Requires-Dist: scikit-learn (>=1.5)
29
+ Requires-Dist: sentence-transformers (>=6.0.0,<7.0.0)
30
+ Requires-Dist: umap-learn (>=0.5.12,<0.6.0)
31
+ Requires-Dist: uunet (>=2.1.1,<3.0.0)
32
+ Project-URL: Changelog, https://github.com/alessiogandelli/topiclayers/blob/main/CHANGELOG.md
33
+ Project-URL: Issues, https://github.com/alessiogandelli/topiclayers/issues
34
+ Project-URL: Repository, https://github.com/alessiogandelli/topiclayers
35
+ Description-Content-Type: text/markdown
36
+
37
+ # topiclayers
38
+
39
+ > From posts to topical multilayer networks — hardened and social-scientist-friendly.
40
+
41
+ ## Quickstart
42
+
43
+ ```bash
44
+ # Requires Python 3.12
45
+ pip install topiclayers
46
+ topiclayers run examples/generic.yml
47
+ ```
48
+
49
+ That's it. You get a GML network file in `./out/generic_toy/networks/`.
50
+
51
+ ## What does it do?
52
+
53
+ 1. **Loads** your posts (Twitter JSONL, CSV, Parquet).
54
+ 2. **Cleans** text (removes URLs, @mentions, newlines).
55
+ 3. **Models topics** using BERTopic with SentenceTransformer embeddings.
56
+ 4. **Labels topics** with an LLM (local via Ollama by default) and propagates topic labels through retweet chains.
57
+ 5. **Builds networks**: single-layer retweet, multilayer per-topic, and bipartite temporal-text networks.
58
+
59
+ ## Minimal config
60
+
61
+ Save this as `my_config.yml`:
62
+
63
+ ```yaml
64
+ input:
65
+ format: csv_posts
66
+ posts: my_data.csv
67
+
68
+ name: my_dataset
69
+
70
+ topic_model:
71
+ embedder: all-MiniLM-L6-v2
72
+ min_topic_size: 50
73
+
74
+ network:
75
+ type: multilayer_repost
76
+
77
+ output_dir: ./out/my_dataset
78
+ ```
79
+
80
+ Run it: `topiclayers run my_config.yml`
81
+
82
+ ## Checking progress
83
+
84
+ Long runs write a live status file to `<output_dir>/run_status.json`. Check the current stage, elapsed time, and ETA from another terminal without touching the running job:
85
+
86
+ ```bash
87
+ topiclayers status out/my_dataset
88
+ ```
89
+
90
+ Example output:
91
+
92
+ ```
93
+ Stage 3/7: embed
94
+ Status: running
95
+ Run: my_dataset
96
+ Progress: 412000/1200000 (34%)
97
+ Stage elapsed: 18m 12s
98
+ Stage ETA: ~35m 20s
99
+ Total elapsed: 21m 05s
100
+ ```
101
+
102
+ Stages are `load → data → embed → topic_model → label → network → manifest`. Stages with a per-item counter (data, embed, network) report a live ETA. Opaque stages (BERTopic's UMAP+HDBSCAN) fall back to the median duration of past runs, stored in `<output_dir>/cache/stage_timings.json`.
103
+
104
+ ## Topic labeling with an LLM (optional)
105
+
106
+ After clustering, the pipeline can ask a local LLM to write one short, readable
107
+ label per topic (instead of raw keyword lists like `-1_proclamation_plante_trending`).
108
+ Default is **Ollama** — free, local, nothing leaves your machine:
109
+
110
+ ```bash
111
+ # one-time setup
112
+ pip install 'topiclayers[labeling]'
113
+ ollama pull qwen2.5:7b
114
+
115
+ # start the server in another terminal
116
+ ollama serve
117
+ ```
118
+
119
+ Then just run the pipeline as usual (`label_model` is on by default). Any
120
+ [LiteLLM](https://docs.litellm.ai/docs/providers) model string works:
121
+
122
+ ```yaml
123
+ topic_model:
124
+ label_model: ollama/qwen2.5:7b # default
125
+ # label_model: gpt-4o-mini # OpenAI (needs OPENAI_API_KEY)
126
+ # label_model: anthropic/claude-3-haiku-20240307
127
+ # label_model: "" # disable labeling
128
+ ```
129
+
130
+ ### Custom / self-hosted OpenAI-compatible endpoints
131
+
132
+ Any server speaking the OpenAI protocol (Unsloth, llama.cpp, vLLM, LM Studio...)
133
+ works via `label_api_base`:
134
+
135
+ ```yaml
136
+ topic_model:
137
+ label_model: openai/unsloth/Qwen3.8-27B-GGUF # note the openai/ prefix
138
+ label_api_base: http://localhost:8888/v1
139
+ ```
140
+
141
+ with the token in `.env` (key resolution order: `LABEL_API_KEY` →
142
+ `OPENAI_LIKE_API_KEY` → `OPENAI_API_KEY`). You can also set `LABEL_API_BASE`
143
+ in `.env` instead of the YAML. Find the exact model name your server exposes
144
+ with `curl http://localhost:8888/v1/models`.
145
+
146
+ Labeling is non-fatal: if the model server is unreachable, topics keep their
147
+ keyword labels and everything else proceeds normally.
148
+
149
+ ## Input formats
150
+
151
+ | Format | Extension | Description |
152
+ |--------|-----------|-------------|
153
+ | `twitter_jsonl` | `.json`, `.jsonl` | Twitter/X API v2 JSONL |
154
+ | `csv_posts` | `.csv` | Generic CSV with `post_id`, `user_id`, `text` columns |
155
+ | `parquet_posts` | `.parquet` | Same schema as CSV, Parquet format |
156
+
157
+ CSV optional columns: `created_at`, `lang`, `interaction_type` (`original`/`repost`/`quote`/`reply`), `target_post_id`, `mentions` (semicolon-separated), `extra_*` passthrough columns.
158
+
159
+ ## What if something fails?
160
+
161
+ | Symptom | Likely cause | Fix |
162
+ |---------|-------------|-----|
163
+ | "All topics are -1" / "no topics found" | `min_topic_size` too high or dataset too small | Halve `min_topic_size` in config, or use a larger dataset (>100 posts) |
164
+ | "Module not found" | topiclayers not installed | `pip install -e .` |
165
+ | "OpenAI API key not set" | Using OpenAI embedder without key | Switch `embedder` to `all-MiniLM-L6-v2` (default, works offline) |
166
+ | "Qdrant connection refused" | Qdrant vector DB not running | Ignore — Qdrant is optional. Set `QDRANT_URL` in `.env` to enable |
167
+ | "Cannot create multilayer network" | All posts are outliers | Reduce `min_topic_size` or provide more data |
168
+ | "UMAP spectral layout failed" | Dataset too small (<10 posts) | Topic modeling needs more data; consider using topic labels from elsewhere |
169
+
170
+ ## Output files
171
+
172
+ For a dataset named `<name>` (e.g. `my_dataset`), output goes to `<output_dir>/`:
173
+
174
+ ```
175
+ <output_dir>/
176
+ ├── run_manifest.json # Reproducibility metadata
177
+ ├── run_status.json # Live stage/ETA tracking (while running)
178
+ ├── cache/
179
+ │ └── data/
180
+ │ ├── tweets_<name>.pkl/.csv # Full tweet table
181
+ │ ├── retweet_labeled_<name>.pkl/.csv # Retweets with topic labels
182
+ │ └── manifest_<name>.json # Cache validity key
183
+ │ └── stage_timings.json # Per-stage durations for ETA prediction
184
+ └── networks/
185
+ ├── <name>_retweet.gml # Single-layer retweet network
186
+ ├── <name>_retweet_network_ml.gml # Multilayer (uunet format)
187
+ ├── <name>_ttt.gml # Temporal-text bipartite network
188
+ └── projected/
189
+ └── <name>__prj_<topic>.gml # Per-topic projected networks
190
+ ```
191
+
192
+ ## Requirements
193
+
194
+ - Python 3.12 (uunet, used for multilayer networks, does not ship wheels for 3.13+ yet)
195
+ - Optional: Ollama + `topiclayers[labeling]` extra (LLM topic labeling), Docker (for Qdrant vector search), OpenAI API key (for OpenAI embeddings)
196
+
197
+ ## Planned: JOSS software paper
198
+
199
+ Once the API stabilises and the PLOS ONE core paper results are regenerated with this library,
200
+ a short JOSS (Journal of Open Source Software) paper will be submitted with a Zenodo DOI.
201
+
@@ -0,0 +1,164 @@
1
+ # topiclayers
2
+
3
+ > From posts to topical multilayer networks — hardened and social-scientist-friendly.
4
+
5
+ ## Quickstart
6
+
7
+ ```bash
8
+ # Requires Python 3.12
9
+ pip install topiclayers
10
+ topiclayers run examples/generic.yml
11
+ ```
12
+
13
+ That's it. You get a GML network file in `./out/generic_toy/networks/`.
14
+
15
+ ## What does it do?
16
+
17
+ 1. **Loads** your posts (Twitter JSONL, CSV, Parquet).
18
+ 2. **Cleans** text (removes URLs, @mentions, newlines).
19
+ 3. **Models topics** using BERTopic with SentenceTransformer embeddings.
20
+ 4. **Labels topics** with an LLM (local via Ollama by default) and propagates topic labels through retweet chains.
21
+ 5. **Builds networks**: single-layer retweet, multilayer per-topic, and bipartite temporal-text networks.
22
+
23
+ ## Minimal config
24
+
25
+ Save this as `my_config.yml`:
26
+
27
+ ```yaml
28
+ input:
29
+ format: csv_posts
30
+ posts: my_data.csv
31
+
32
+ name: my_dataset
33
+
34
+ topic_model:
35
+ embedder: all-MiniLM-L6-v2
36
+ min_topic_size: 50
37
+
38
+ network:
39
+ type: multilayer_repost
40
+
41
+ output_dir: ./out/my_dataset
42
+ ```
43
+
44
+ Run it: `topiclayers run my_config.yml`
45
+
46
+ ## Checking progress
47
+
48
+ Long runs write a live status file to `<output_dir>/run_status.json`. Check the current stage, elapsed time, and ETA from another terminal without touching the running job:
49
+
50
+ ```bash
51
+ topiclayers status out/my_dataset
52
+ ```
53
+
54
+ Example output:
55
+
56
+ ```
57
+ Stage 3/7: embed
58
+ Status: running
59
+ Run: my_dataset
60
+ Progress: 412000/1200000 (34%)
61
+ Stage elapsed: 18m 12s
62
+ Stage ETA: ~35m 20s
63
+ Total elapsed: 21m 05s
64
+ ```
65
+
66
+ Stages are `load → data → embed → topic_model → label → network → manifest`. Stages with a per-item counter (data, embed, network) report a live ETA. Opaque stages (BERTopic's UMAP+HDBSCAN) fall back to the median duration of past runs, stored in `<output_dir>/cache/stage_timings.json`.
67
+
68
+ ## Topic labeling with an LLM (optional)
69
+
70
+ After clustering, the pipeline can ask a local LLM to write one short, readable
71
+ label per topic (instead of raw keyword lists like `-1_proclamation_plante_trending`).
72
+ Default is **Ollama** — free, local, nothing leaves your machine:
73
+
74
+ ```bash
75
+ # one-time setup
76
+ pip install 'topiclayers[labeling]'
77
+ ollama pull qwen2.5:7b
78
+
79
+ # start the server in another terminal
80
+ ollama serve
81
+ ```
82
+
83
+ Then just run the pipeline as usual (`label_model` is on by default). Any
84
+ [LiteLLM](https://docs.litellm.ai/docs/providers) model string works:
85
+
86
+ ```yaml
87
+ topic_model:
88
+ label_model: ollama/qwen2.5:7b # default
89
+ # label_model: gpt-4o-mini # OpenAI (needs OPENAI_API_KEY)
90
+ # label_model: anthropic/claude-3-haiku-20240307
91
+ # label_model: "" # disable labeling
92
+ ```
93
+
94
+ ### Custom / self-hosted OpenAI-compatible endpoints
95
+
96
+ Any server speaking the OpenAI protocol (Unsloth, llama.cpp, vLLM, LM Studio...)
97
+ works via `label_api_base`:
98
+
99
+ ```yaml
100
+ topic_model:
101
+ label_model: openai/unsloth/Qwen3.8-27B-GGUF # note the openai/ prefix
102
+ label_api_base: http://localhost:8888/v1
103
+ ```
104
+
105
+ with the token in `.env` (key resolution order: `LABEL_API_KEY` →
106
+ `OPENAI_LIKE_API_KEY` → `OPENAI_API_KEY`). You can also set `LABEL_API_BASE`
107
+ in `.env` instead of the YAML. Find the exact model name your server exposes
108
+ with `curl http://localhost:8888/v1/models`.
109
+
110
+ Labeling is non-fatal: if the model server is unreachable, topics keep their
111
+ keyword labels and everything else proceeds normally.
112
+
113
+ ## Input formats
114
+
115
+ | Format | Extension | Description |
116
+ |--------|-----------|-------------|
117
+ | `twitter_jsonl` | `.json`, `.jsonl` | Twitter/X API v2 JSONL |
118
+ | `csv_posts` | `.csv` | Generic CSV with `post_id`, `user_id`, `text` columns |
119
+ | `parquet_posts` | `.parquet` | Same schema as CSV, Parquet format |
120
+
121
+ CSV optional columns: `created_at`, `lang`, `interaction_type` (`original`/`repost`/`quote`/`reply`), `target_post_id`, `mentions` (semicolon-separated), `extra_*` passthrough columns.
122
+
123
+ ## What if something fails?
124
+
125
+ | Symptom | Likely cause | Fix |
126
+ |---------|-------------|-----|
127
+ | "All topics are -1" / "no topics found" | `min_topic_size` too high or dataset too small | Halve `min_topic_size` in config, or use a larger dataset (>100 posts) |
128
+ | "Module not found" | topiclayers not installed | `pip install -e .` |
129
+ | "OpenAI API key not set" | Using OpenAI embedder without key | Switch `embedder` to `all-MiniLM-L6-v2` (default, works offline) |
130
+ | "Qdrant connection refused" | Qdrant vector DB not running | Ignore — Qdrant is optional. Set `QDRANT_URL` in `.env` to enable |
131
+ | "Cannot create multilayer network" | All posts are outliers | Reduce `min_topic_size` or provide more data |
132
+ | "UMAP spectral layout failed" | Dataset too small (<10 posts) | Topic modeling needs more data; consider using topic labels from elsewhere |
133
+
134
+ ## Output files
135
+
136
+ For a dataset named `<name>` (e.g. `my_dataset`), output goes to `<output_dir>/`:
137
+
138
+ ```
139
+ <output_dir>/
140
+ ├── run_manifest.json # Reproducibility metadata
141
+ ├── run_status.json # Live stage/ETA tracking (while running)
142
+ ├── cache/
143
+ │ └── data/
144
+ │ ├── tweets_<name>.pkl/.csv # Full tweet table
145
+ │ ├── retweet_labeled_<name>.pkl/.csv # Retweets with topic labels
146
+ │ └── manifest_<name>.json # Cache validity key
147
+ │ └── stage_timings.json # Per-stage durations for ETA prediction
148
+ └── networks/
149
+ ├── <name>_retweet.gml # Single-layer retweet network
150
+ ├── <name>_retweet_network_ml.gml # Multilayer (uunet format)
151
+ ├── <name>_ttt.gml # Temporal-text bipartite network
152
+ └── projected/
153
+ └── <name>__prj_<topic>.gml # Per-topic projected networks
154
+ ```
155
+
156
+ ## Requirements
157
+
158
+ - Python 3.12 (uunet, used for multilayer networks, does not ship wheels for 3.13+ yet)
159
+ - Optional: Ollama + `topiclayers[labeling]` extra (LLM topic labeling), Docker (for Qdrant vector search), OpenAI API key (for OpenAI embeddings)
160
+
161
+ ## Planned: JOSS software paper
162
+
163
+ Once the API stabilises and the PLOS ONE core paper results are regenerated with this library,
164
+ a short JOSS (Journal of Open Source Software) paper will be submitted with a Zenodo DOI.
@@ -0,0 +1,52 @@
1
+ [tool.poetry]
2
+ name = "topiclayers"
3
+ version = "0.2.0"
4
+ description = "From any collection of posts to topical multilayer networks — hardened and social-scientist-friendly"
5
+ authors = ["alessiogandelli <alessiogandelli99@gmail.com>"]
6
+ readme = "README.md"
7
+ packages = [{include = "topiclayers", from = "src"}]
8
+ license = "MIT"
9
+ keywords = ["topic-modeling", "network-analysis", "social-science", "polarisation"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Intended Audience :: Science/Research",
13
+ "Programming Language :: Python :: 3.12",
14
+ ]
15
+
16
+ [tool.poetry.dependencies]
17
+ python = ">=3.12,<3.13"
18
+ jsonlines = "^4.0.0"
19
+ pandas = ">=2.2,<4"
20
+ numpy = ">=1.26,<3"
21
+ sentence-transformers = "^6.0.0"
22
+ bertopic = "^0.17.4"
23
+ scikit-learn = ">=1.5"
24
+ umap-learn = "^0.5.12"
25
+ networkx = "^3.6"
26
+ igraph = ">=0.11.4"
27
+ uunet = "^2.1.1"
28
+ qdrant-client = "^1.19.0"
29
+ openai = "^3.0.0"
30
+ python-dotenv = "^1.0.1"
31
+ pyyaml = "^6.0"
32
+ litellm = {version = ">=1.40", optional = true}
33
+
34
+ [tool.poetry.extras]
35
+ labeling = ["litellm"]
36
+
37
+ [tool.poetry.group.dev.dependencies]
38
+ pytest = ">=8.0"
39
+ pytest-cov = ">=6.0"
40
+ jupyter = "^1.0.0"
41
+
42
+ [tool.poetry.urls]
43
+ Repository = "https://github.com/alessiogandelli/topiclayers"
44
+ Issues = "https://github.com/alessiogandelli/topiclayers/issues"
45
+ Changelog = "https://github.com/alessiogandelli/topiclayers/blob/main/CHANGELOG.md"
46
+
47
+ [tool.poetry.scripts]
48
+ topiclayers = "topiclayers.cli:main"
49
+
50
+ [build-system]
51
+ requires = ["poetry-core"]
52
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,3 @@
1
+ """topiclayers — from posts to topical multilayer networks."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,7 @@
1
+ """Input adapters: convert various formats into a list of Post dataclasses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .registry import AdapterRegistry, resolve_adapter
6
+
7
+ __all__ = ["AdapterRegistry", "resolve_adapter"]
@@ -0,0 +1,91 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import List
5
+
6
+ import pandas as pd
7
+
8
+ from topiclayers.adapters.registry import register
9
+ from topiclayers.schema import InteractionType, Post
10
+
11
+ _INTERACTION_MAP = {
12
+ "original": None,
13
+ "repost": InteractionType.REPOST,
14
+ "quote": InteractionType.QUOTE,
15
+ "reply": InteractionType.REPLY,
16
+ }
17
+
18
+
19
+ def _parse_interaction(value) -> InteractionType | None:
20
+ if value is None:
21
+ return None
22
+ if isinstance(value, float) and pd.isna(value):
23
+ return None
24
+ s = str(value).strip().lower()
25
+ if not s or s in ("nan", "none", ""):
26
+ return None
27
+ return _INTERACTION_MAP.get(s, None)
28
+
29
+
30
+ def _parse_mentions(value) -> list[str]:
31
+ if value is None:
32
+ return []
33
+ if isinstance(value, float) and pd.isna(value):
34
+ return []
35
+ s = str(value).strip()
36
+ if not s or s.lower() in ("nan", "none"):
37
+ return []
38
+ return [m.strip() for m in s.split(";") if m.strip()]
39
+
40
+
41
+ def _row_to_post(row, extra_cols: list[str]) -> Post:
42
+ extra = {}
43
+ for col in extra_cols:
44
+ val = row.get(col)
45
+ if isinstance(val, float) and pd.isna(val):
46
+ val = None
47
+ key = col.removeprefix("extra_")
48
+ extra[key] = val
49
+
50
+ return Post(
51
+ post_id=str(row["post_id"]),
52
+ user_id=str(row["user_id"]),
53
+ text=str(row.get("text", "")),
54
+ created_at=row.get("created_at") if not (isinstance(row.get("created_at"), float) and pd.isna(row.get("created_at"))) else None,
55
+ lang=row.get("lang") if not (isinstance(row.get("lang"), float) and pd.isna(row.get("lang"))) else None,
56
+ interaction_type=_parse_interaction(row.get("interaction_type")),
57
+ target_post_id=str(row["target_post_id"]) if row.get("target_post_id") and not (isinstance(row.get("target_post_id"), float) and pd.isna(row.get("target_post_id"))) else None,
58
+ mentions=_parse_mentions(row.get("mentions")),
59
+ extra=extra,
60
+ )
61
+
62
+
63
+ def load_csv(path: Path) -> List[Post]:
64
+ df = pd.read_csv(path)
65
+ required = {"post_id", "user_id", "text"}
66
+ missing = required - set(df.columns)
67
+ if missing:
68
+ raise ValueError(
69
+ f"CSV file at {path} is missing required columns: {missing}. "
70
+ f"Required columns are: post_id, user_id, text. "
71
+ f"Optional: created_at, lang, interaction_type, target_post_id, target_user_id, mentions, and extra_* columns."
72
+ )
73
+ extra_cols = [c for c in df.columns if c.startswith("extra_")]
74
+ return [_row_to_post(row, extra_cols) for _, row in df.iterrows()]
75
+
76
+
77
+ def load_parquet(path: Path) -> List[Post]:
78
+ df = pd.read_parquet(path)
79
+ required = {"post_id", "user_id", "text"}
80
+ missing = required - set(df.columns)
81
+ if missing:
82
+ raise ValueError(
83
+ f"Parquet file at {path} is missing required columns: {missing}. "
84
+ f"Required columns are: post_id, user_id, text."
85
+ )
86
+ extra_cols = [c for c in df.columns if c.startswith("extra_")]
87
+ return [_row_to_post(row, extra_cols) for _, row in df.iterrows()]
88
+
89
+
90
+ register("csv_posts", load_csv)
91
+ register("parquet_posts", load_parquet)
@@ -0,0 +1,47 @@
1
+ """Input adapter registry — maps format strings to loader functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Callable, List
8
+
9
+ from topiclayers.schema import Post
10
+
11
+
12
+ AdapterFn = Callable[[Path], List[Post]]
13
+
14
+ _registry: dict[str, AdapterFn] = {}
15
+
16
+
17
+ def register(format_name: str, fn: AdapterFn) -> None:
18
+ _registry[format_name] = fn
19
+
20
+
21
+ def resolve_adapter(path: Path) -> tuple[str, AdapterFn]:
22
+ """Auto-detect format from file extension and return (format_name, loader)."""
23
+ ext = path.suffix.lower()
24
+ if ext in (".jsonl", ".json", ".dat"):
25
+ fmt = "twitter_jsonl"
26
+ elif ext == ".csv":
27
+ fmt = "csv_posts"
28
+ elif ext == ".parquet":
29
+ fmt = "parquet_posts"
30
+ else:
31
+ raise ValueError(
32
+ f"Cannot auto-detect format for extension '{ext}'. "
33
+ f"Supported: .jsonl, .json, .dat, .csv, .parquet. "
34
+ f"Use explicit `format:` in config for other formats."
35
+ )
36
+ if fmt not in _registry:
37
+ raise KeyError(
38
+ f"No adapter registered for format '{fmt}'. "
39
+ f"Available adapters: {list(_registry.keys())}. "
40
+ f"Did you import topiclayers.adapters.{fmt.split('_')[0]}?"
41
+ )
42
+ return fmt, _registry[fmt]
43
+
44
+
45
+ class AdapterRegistry:
46
+ """Explicit registry for manual adapter lookups."""
47
+ pass
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import List
6
+
7
+ from topiclayers.adapters.registry import register
8
+ from topiclayers.schema import InteractionType, Post
9
+
10
+
11
+ def _map_interaction_type(ref_type: str | None) -> InteractionType | None:
12
+ if ref_type == "retweeted":
13
+ return InteractionType.REPOST
14
+ elif ref_type == "quoted":
15
+ return InteractionType.QUOTE
16
+ elif ref_type == "replied_to":
17
+ return InteractionType.REPLY
18
+ return None
19
+
20
+
21
+ def load(path: Path) -> List[Post]:
22
+ posts = []
23
+ with open(path) as f:
24
+ for line in f:
25
+ line = line.strip()
26
+ if not line:
27
+ continue
28
+ obj = json.loads(line)
29
+
30
+ post_id = str(obj.get("id", ""))
31
+ user_id = str(obj.get("author_id", ""))
32
+ text = obj.get("text", "")
33
+ created_at = obj.get("created_at")
34
+ lang = obj.get("lang")
35
+
36
+ ref_tweets = obj.get("referenced_tweets")
37
+ if ref_tweets and isinstance(ref_tweets, list) and len(ref_tweets) > 0:
38
+ ref = ref_tweets[0]
39
+ interaction_type = _map_interaction_type(ref.get("type"))
40
+ target_post_id = str(ref.get("id")) if ref.get("id") is not None else None
41
+ else:
42
+ interaction_type = None
43
+ target_post_id = None
44
+
45
+ entities = obj.get("entities", {})
46
+ mentions_raw = entities.get("mentions", []) if isinstance(entities, dict) else []
47
+ mentions = [m.get("username", "") for m in mentions_raw if isinstance(m, dict)]
48
+
49
+ extra = {
50
+ "conversation_id": str(obj.get("conversation_id")) if obj.get("conversation_id") is not None else None,
51
+ "context_annotations": obj.get("context_annotations", []),
52
+ "public_metrics": obj.get("public_metrics", {}),
53
+ "possibly_sensitive": obj.get("possibly_sensitive"),
54
+ "edit_history_tweet_ids": obj.get("edit_history_tweet_ids", []),
55
+ }
56
+
57
+ post = Post(
58
+ post_id=post_id,
59
+ user_id=user_id,
60
+ text=text,
61
+ created_at=created_at,
62
+ lang=lang,
63
+ interaction_type=interaction_type,
64
+ target_post_id=target_post_id,
65
+ mentions=mentions,
66
+ extra=extra,
67
+ )
68
+ posts.append(post)
69
+
70
+ return posts
71
+
72
+
73
+ register("twitter_jsonl", load)