feat(pipeline): implement parliamentary embedding pipeline MVP
- Add 4 migration files: mp_votes, mp_metadata, svd_vectors, fused_embeddings - Extend database.py with 5 new helper methods and table init - Add pipeline/ package: extract_mp_votes, fetch_mp_metadata, text_pipeline, svd_pipeline (with Procrustes alignment), fusion - Add full test suite (17 tests) covering all pipeline modules and migrations - Fix Procrustes alignment bug: scipy scale is a norm value, not a multiplier - Fix DuckDB date type handling in test assertions (datetime.date vs string) - Remove duckdb.py shim; tests now run against real duckdb + scipy via uv Ref: thoughts/shared/plans/2026-03-21-parliamentary-embedding-pipeline-plan.md
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import duckdb
|
||||
|
||||
from database import MotionDatabase
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def extract_mp_votes(db_path: Optional[str] = None, limit: Optional[int] = None):
|
||||
"""Extract individual MP votes from motions.voting_results and store them
|
||||
in the mp_votes table.
|
||||
|
||||
Returns a dict with summary counts:
|
||||
- motions_scanned: number of motions inspected
|
||||
- mp_rows_inserted: number of mp_votes rows inserted
|
||||
- motions_skipped: number of motions skipped because mp_votes already existed
|
||||
"""
|
||||
db = MotionDatabase(db_path=db_path) if db_path else MotionDatabase()
|
||||
|
||||
conn = duckdb.connect(db.db_path)
|
||||
try:
|
||||
# support optional limit to only scan a subset of motions
|
||||
if limit is not None:
|
||||
rows = conn.execute(
|
||||
"SELECT id, voting_results, date FROM motions LIMIT ?", (limit,)
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT id, voting_results, date FROM motions"
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
mp_rows_inserted = 0
|
||||
motions_skipped = 0
|
||||
motions_scanned = 0
|
||||
|
||||
for motion_id, voting_results_json, date in rows:
|
||||
motions_scanned += 1
|
||||
try:
|
||||
if db.mp_votes_exists_for_motion(motion_id):
|
||||
_logger.debug(
|
||||
"Skipping motion %s because mp_votes already exist", motion_id
|
||||
)
|
||||
motions_skipped += 1
|
||||
continue
|
||||
|
||||
# voting_results may be stored as JSON text or as native JSON; ensure it's a dict
|
||||
if isinstance(voting_results_json, str):
|
||||
voting_results = json.loads(voting_results_json)
|
||||
else:
|
||||
voting_results = voting_results_json
|
||||
|
||||
for actor, vote in (voting_results or {}).items():
|
||||
# Individual MP names contain a comma (e.g. "Last, F.")
|
||||
if "," not in actor:
|
||||
continue
|
||||
|
||||
inserted_id = db.insert_mp_vote(
|
||||
motion_id=motion_id, mp_name=actor, vote=vote, date=date, party=None
|
||||
)
|
||||
if inserted_id and inserted_id > 0:
|
||||
mp_rows_inserted += 1
|
||||
|
||||
except Exception as e:
|
||||
_logger.error("Error processing motion %s: %s", motion_id, e)
|
||||
|
||||
return {
|
||||
"motions_scanned": motions_scanned,
|
||||
"mp_rows_inserted": mp_rows_inserted,
|
||||
"motions_skipped": motions_skipped,
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
from database import MotionDatabase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def normalize_mp_name(
|
||||
achternaam: str, initialen: Optional[str], tussenvoegsel: Optional[str]
|
||||
) -> str:
|
||||
"""Reconstruct ActorNaam format used in voting_results keys.
|
||||
|
||||
Format: "{Tussenvoegsel} {Achternaam}, {Initialen}" with sensible stripping when
|
||||
tussenvoegsel is missing.
|
||||
"""
|
||||
parts = []
|
||||
if tussenvoegsel:
|
||||
parts.append(tussenvoegsel)
|
||||
parts.append(achternaam)
|
||||
name = " ".join(parts).strip()
|
||||
|
||||
# Ensure the displayed name starts with an uppercase letter so
|
||||
# ORDER BY mp_name behaves predictably across databases that may
|
||||
# sort uppercase before lowercase. Only change the first character
|
||||
# to upper-case to avoid lowercasing other letters (e.g. hyphenated
|
||||
# or already capitalized parts).
|
||||
if name and name[0].islower():
|
||||
name = name[0].upper() + name[1:]
|
||||
if initialen:
|
||||
name = f"{name}, {initialen}"
|
||||
return name
|
||||
|
||||
|
||||
def fetch_mp_metadata(
|
||||
db_path: str, odata_url: str = "https://odata.example/FractieZetelPersoon"
|
||||
) -> int:
|
||||
"""Fetch MP party membership and tenure from OData and upsert into DB.
|
||||
|
||||
Returns the number of records processed (inserted or updated).
|
||||
"""
|
||||
session = requests.Session()
|
||||
try:
|
||||
resp = session.get(odata_url)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
logger.error("Failed to fetch MP metadata: %s", e)
|
||||
raise
|
||||
|
||||
values = data.get("value") if isinstance(data, dict) else None
|
||||
if values is None:
|
||||
logger.error("Unexpected OData payload; missing 'value' list")
|
||||
return 0
|
||||
|
||||
db = MotionDatabase(db_path)
|
||||
processed = 0
|
||||
|
||||
for item in values:
|
||||
try:
|
||||
persoon = item.get("Persoon") or {}
|
||||
fractiezetel = item.get("FractieZetel") or {}
|
||||
fractie = fractiezetel.get("Fractie") or {}
|
||||
|
||||
achternaam = persoon.get("Achternaam")
|
||||
initialen = persoon.get("Initialen")
|
||||
tussenvoegsel = persoon.get("Tussenvoegsel")
|
||||
persoon_id = persoon.get("Id")
|
||||
|
||||
party = fractie.get("NaamNL")
|
||||
van = item.get("Van")
|
||||
tot_en_met = item.get("TotEnMet")
|
||||
|
||||
if not achternaam:
|
||||
logger.debug("Skipping record without achternaam: %s", item)
|
||||
continue
|
||||
|
||||
mp_name = normalize_mp_name(achternaam, initialen, tussenvoegsel)
|
||||
|
||||
db.upsert_mp_metadata(
|
||||
mp_name=mp_name,
|
||||
party=party,
|
||||
van=van,
|
||||
tot_en_met=tot_en_met,
|
||||
persoon_id=persoon_id,
|
||||
)
|
||||
processed += 1
|
||||
except Exception:
|
||||
logger.exception("Error processing OData item: %s", item)
|
||||
|
||||
logger.info("Processed %d MP metadata records", processed)
|
||||
return processed
|
||||
@@ -0,0 +1,116 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict
|
||||
|
||||
import duckdb
|
||||
|
||||
from database import MotionDatabase
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def fuse_for_window(
|
||||
window_id: str, db_path: str = None, model: str = None
|
||||
) -> Dict[str, int]:
|
||||
"""Fuse SVD vectors with text embeddings for motions in a window.
|
||||
|
||||
Parameters:
|
||||
- window_id: id of the window to process
|
||||
- db_path: optional path to duckdb database (if None MotionDatabase default is used)
|
||||
- model: optional model name to filter text embeddings
|
||||
|
||||
Returns a dict with counts: inserted, skipped_missing_text, skipped_missing_svd, errors
|
||||
"""
|
||||
# Create MotionDatabase using provided path if given, otherwise use default
|
||||
if db_path:
|
||||
db = MotionDatabase(db_path=db_path)
|
||||
conn = duckdb.connect(db_path)
|
||||
else:
|
||||
db = MotionDatabase()
|
||||
# MotionDatabase always exposes the path it uses
|
||||
conn = duckdb.connect(db.db_path)
|
||||
|
||||
# Fetch svd vectors for the window and entity_type=motion
|
||||
rows = conn.execute(
|
||||
"SELECT entity_id, vector FROM svd_vectors WHERE window_id = ? AND entity_type = ?",
|
||||
(window_id, "motion"),
|
||||
).fetchall()
|
||||
# debug
|
||||
_logger.debug("Found %d svd rows for window %s", len(rows), window_id)
|
||||
|
||||
inserted = 0
|
||||
skipped_missing_text = 0
|
||||
skipped_missing_svd = 0
|
||||
errors = 0
|
||||
|
||||
for entity_id, svd_json in rows:
|
||||
try:
|
||||
svd_vec = json.loads(svd_json)
|
||||
except Exception:
|
||||
_logger.exception("Invalid SVD vector JSON for entity %s", entity_id)
|
||||
skipped_missing_svd += 1
|
||||
continue
|
||||
|
||||
# Look up text embedding for this motion (most recent). If model is provided
|
||||
# filter by model as well.
|
||||
if model:
|
||||
emb_row = conn.execute(
|
||||
"SELECT vector FROM embeddings WHERE motion_id = ? AND model = ? ORDER BY created_at DESC LIMIT 1",
|
||||
(int(entity_id), model),
|
||||
).fetchone()
|
||||
else:
|
||||
emb_row = conn.execute(
|
||||
"SELECT vector FROM embeddings WHERE motion_id = ? ORDER BY created_at DESC LIMIT 1",
|
||||
(int(entity_id),),
|
||||
).fetchone()
|
||||
|
||||
if not emb_row:
|
||||
skipped_missing_text += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
text_vec = json.loads(emb_row[0])
|
||||
except Exception:
|
||||
_logger.exception("Invalid text embedding JSON for motion %s", entity_id)
|
||||
skipped_missing_text += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
fused = list(svd_vec) + list(text_vec)
|
||||
except Exception:
|
||||
_logger.exception("Error concatenating vectors for motion %s", entity_id)
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
# store fused embedding and check result
|
||||
try:
|
||||
res = db.store_fused_embedding(
|
||||
int(entity_id),
|
||||
window_id,
|
||||
fused,
|
||||
svd_dims=len(svd_vec),
|
||||
text_dims=len(text_vec),
|
||||
)
|
||||
if res and res > 0:
|
||||
inserted += 1
|
||||
else:
|
||||
errors += 1
|
||||
_logger.error(
|
||||
"Failed to store fused embedding for motion %s (db returned %s)",
|
||||
entity_id,
|
||||
res,
|
||||
)
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
"Exception while storing fused embedding for motion %s", entity_id
|
||||
)
|
||||
errors += 1
|
||||
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"inserted": inserted,
|
||||
"skipped_missing_text": skipped_missing_text,
|
||||
"skipped_missing_svd": skipped_missing_svd,
|
||||
"errors": errors,
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional, Dict, List, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
from scipy.sparse import csr_matrix
|
||||
from scipy.sparse.linalg import svds
|
||||
from scipy.linalg import orthogonal_procrustes
|
||||
|
||||
_HAS_SCIPY = True
|
||||
except Exception:
|
||||
# Provide lightweight fallbacks for environments without scipy
|
||||
csr_matrix = lambda x: x
|
||||
|
||||
def svds(a, k=1):
|
||||
# fallback to numpy.linalg.svd on dense arrays
|
||||
U, s, Vt = np.linalg.svd(np.array(a), full_matrices=False)
|
||||
# return last k components to mimic scipy.svds behaviour
|
||||
return U[:, -k:], s[-k:], Vt[-k:, :]
|
||||
|
||||
def orthogonal_procrustes(A, B):
|
||||
# simple orthogonal Procrustes via SVD: find R minimizing ||A R - B||
|
||||
U, _, Vt = np.linalg.svd(A.T.dot(B))
|
||||
R = U.dot(Vt)
|
||||
scale = 1.0
|
||||
return R, scale
|
||||
|
||||
_HAS_SCIPY = False
|
||||
import duckdb
|
||||
|
||||
from database import MotionDatabase
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Map textual votes to numeric values for SVD
|
||||
VOTE_MAP = {
|
||||
"Voor": 1.0,
|
||||
"voor": 1.0,
|
||||
"Tegen": -1.0,
|
||||
"tegen": -1.0,
|
||||
"Geen stem": 0.0,
|
||||
"Onbekend": 0.0,
|
||||
"Onbekend stem": 0.0,
|
||||
"Blanco": 0.0,
|
||||
}
|
||||
|
||||
|
||||
def _safe_k(mat: np.ndarray, k: int) -> int:
|
||||
"""Return a safe k for svds: must be < min(mat.shape)."""
|
||||
if mat is None:
|
||||
return 0
|
||||
m, n = mat.shape
|
||||
min_dim = min(m, n)
|
||||
# svds requires k < min_dim
|
||||
if min_dim <= 1:
|
||||
return 0
|
||||
return min(k, min_dim - 1)
|
||||
|
||||
|
||||
def _build_vote_matrix(
|
||||
db: MotionDatabase, start_date: str, end_date: str
|
||||
) -> Tuple[np.ndarray, List[str], List[int]]:
|
||||
"""Build dense vote matrix (mp x motion) for votes between start_date and end_date.
|
||||
|
||||
Returns (matrix, mp_names, motion_ids)
|
||||
"""
|
||||
conn = duckdb.connect(db.db_path)
|
||||
rows = conn.execute(
|
||||
"SELECT motion_id, mp_name, vote FROM mp_votes WHERE date BETWEEN ? AND ?",
|
||||
(start_date, end_date),
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
if not rows:
|
||||
return np.zeros((0, 0)), [], []
|
||||
|
||||
motion_ids = sorted({int(r[0]) for r in rows})
|
||||
mp_names = sorted({r[1] for r in rows})
|
||||
|
||||
m = len(mp_names)
|
||||
n = len(motion_ids)
|
||||
mat = np.zeros((m, n), dtype=float)
|
||||
|
||||
mp_index = {name: i for i, name in enumerate(mp_names)}
|
||||
motion_index = {mid: j for j, mid in enumerate(motion_ids)}
|
||||
|
||||
for motion_id, mp_name, vote in rows:
|
||||
i = mp_index[mp_name]
|
||||
j = motion_index[int(motion_id)]
|
||||
val = VOTE_MAP.get(
|
||||
vote, VOTE_MAP.get(vote.strip() if isinstance(vote, str) else vote, 0.0)
|
||||
)
|
||||
try:
|
||||
mat[i, j] = float(val)
|
||||
except Exception:
|
||||
mat[i, j] = 0.0
|
||||
|
||||
return mat, mp_names, motion_ids
|
||||
|
||||
|
||||
def _procrustes_align(
|
||||
reference_anchor: np.ndarray,
|
||||
current_anchor: np.ndarray,
|
||||
min_overlap: int = 3,
|
||||
) -> np.ndarray:
|
||||
"""Align current_anchor to reference_anchor using orthogonal Procrustes.
|
||||
|
||||
This function will only attempt alignment when there is a reasonable number of
|
||||
overlapping rows (default: min_overlap). If the overlap is too small or if any
|
||||
input is invalid, the original current_anchor is returned unchanged.
|
||||
|
||||
Returns transformed_current_anchor
|
||||
"""
|
||||
# basic validation
|
||||
if reference_anchor is None or current_anchor is None:
|
||||
return current_anchor
|
||||
|
||||
if not isinstance(reference_anchor, np.ndarray) or not isinstance(
|
||||
current_anchor, np.ndarray
|
||||
):
|
||||
return current_anchor
|
||||
|
||||
# Determine overlap by number of available rows. If too small, skip alignment.
|
||||
n_ref = reference_anchor.shape[0]
|
||||
n_cur = current_anchor.shape[0]
|
||||
overlap = min(n_ref, n_cur)
|
||||
if overlap < min_overlap:
|
||||
_logger.debug(
|
||||
"Procrustes alignment skipped: overlap %s < min_overlap %s",
|
||||
overlap,
|
||||
min_overlap,
|
||||
)
|
||||
return current_anchor
|
||||
|
||||
# Use only the overlapping rows to compute the orthogonal transform.
|
||||
ref_sub = reference_anchor[:overlap, :]
|
||||
cur_sub = current_anchor[:overlap, :]
|
||||
|
||||
try:
|
||||
# orthogonal_procrustes(A, B) returns R, scale such that A @ R = B * scale
|
||||
# We want to transform current_anchor to align with reference_anchor so
|
||||
# call orthogonal_procrustes(cur_sub, ref_sub) and apply resulting R/scale
|
||||
R, _scale = orthogonal_procrustes(cur_sub, ref_sub)
|
||||
transformed = current_anchor.dot(R)
|
||||
return transformed
|
||||
except Exception:
|
||||
_logger.exception("Procrustes alignment failed")
|
||||
return current_anchor
|
||||
|
||||
|
||||
def run_svd_for_window(
|
||||
db: MotionDatabase,
|
||||
window_id: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
k: int = 50,
|
||||
) -> Dict:
|
||||
"""Run SVD on votes in given date window and store vectors in DB.
|
||||
|
||||
Returns metadata dict with keys: k_used, stored_mp, stored_motion
|
||||
"""
|
||||
mat, mp_names, motion_ids = _build_vote_matrix(db, start_date, end_date)
|
||||
|
||||
if mat.size == 0 or mat.shape[0] == 0 or mat.shape[1] == 0:
|
||||
return {"k_used": 0, "stored_mp": 0, "stored_motion": 0}
|
||||
|
||||
k_used = _safe_k(mat, k)
|
||||
|
||||
if k_used <= 0:
|
||||
return {"k_used": 0, "stored_mp": 0, "stored_motion": 0}
|
||||
|
||||
# use sparse svds for efficiency
|
||||
try:
|
||||
A = csr_matrix(mat)
|
||||
U, s, Vt = svds(A, k=k_used)
|
||||
# svds does not guarantee ordering of singular values; sort descending
|
||||
idx = np.argsort(s)[::-1]
|
||||
s = s[idx]
|
||||
U = U[:, idx]
|
||||
Vt = Vt[idx, :]
|
||||
|
||||
# weight by singular values
|
||||
mp_vecs = (U * s.reshape(1, -1)).tolist() # m x k
|
||||
motion_vecs = (Vt.T * s.reshape(1, -1)).tolist() # n x k
|
||||
|
||||
stored_mp = 0
|
||||
stored_motion = 0
|
||||
for i, mp_name in enumerate(mp_names):
|
||||
db.store_svd_vector(window_id, "mp", mp_name, mp_vecs[i])
|
||||
stored_mp += 1
|
||||
|
||||
for j, motion_id in enumerate(motion_ids):
|
||||
db.store_svd_vector(window_id, "motion", str(motion_id), motion_vecs[j])
|
||||
stored_motion += 1
|
||||
|
||||
return {
|
||||
"k_used": k_used,
|
||||
"stored_mp": stored_mp,
|
||||
"stored_motion": stored_motion,
|
||||
}
|
||||
|
||||
except Exception:
|
||||
_logger.exception("SVD failed for window")
|
||||
return {"k_used": 0, "stored_mp": 0, "stored_motion": 0}
|
||||
@@ -0,0 +1,122 @@
|
||||
import logging
|
||||
import json
|
||||
from typing import Optional, List, Tuple
|
||||
|
||||
import duckdb
|
||||
|
||||
from database import MotionDatabase, db as default_db
|
||||
import ai_provider
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_MODEL = "qwen/qwen3-embedding-4b"
|
||||
|
||||
|
||||
def _select_text(
|
||||
db: MotionDatabase, model: str, limit: Optional[int] = None
|
||||
) -> List[Tuple[int, Optional[str]]]:
|
||||
"""Select motions that do not yet have an embedding for `model`.
|
||||
|
||||
Returns list of (motion_id, text).
|
||||
"""
|
||||
conn = duckdb.connect(db.db_path)
|
||||
params = [model]
|
||||
# prefer layman_explanation > description > title (keep compatibility with existing tests)
|
||||
sql = (
|
||||
"SELECT m.id, COALESCE(m.layman_explanation, m.description, m.title) AS text"
|
||||
" FROM motions m"
|
||||
" LEFT JOIN embeddings e ON e.motion_id = m.id AND e.model = ?"
|
||||
" WHERE e.id IS NULL"
|
||||
)
|
||||
if limit:
|
||||
sql += " LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
try:
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
conn.close()
|
||||
results: List[Tuple[int, Optional[str]]] = []
|
||||
for r in rows:
|
||||
text_val = r[1]
|
||||
# treat empty strings as no text
|
||||
if text_val is None:
|
||||
text = None
|
||||
else:
|
||||
text = str(text_val).strip() or None
|
||||
results.append((int(r[0]), text))
|
||||
return results
|
||||
except Exception as exc:
|
||||
_logger.error("Error selecting motions for embeddings: %s", exc)
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
def ensure_text_embeddings(
|
||||
db_path: Optional[str] = None, model: Optional[str] = None
|
||||
) -> Tuple[int, int, int, int]:
|
||||
"""Ensure all motions have text embeddings for `model`.
|
||||
|
||||
Returns tuple (stored_count, skipped_existing, skipped_no_text, errors).
|
||||
"""
|
||||
model = model or DEFAULT_MODEL
|
||||
db = MotionDatabase(db_path) if db_path else default_db
|
||||
|
||||
# motions to process
|
||||
to_process = _select_text(db, model)
|
||||
|
||||
# how many already exist
|
||||
conn = duckdb.connect(db.db_path)
|
||||
try:
|
||||
total_motions = conn.execute("SELECT COUNT(*) FROM motions").fetchone()[0]
|
||||
except Exception:
|
||||
total_motions = 0
|
||||
|
||||
try:
|
||||
existing = conn.execute(
|
||||
"SELECT COUNT(DISTINCT motion_id) FROM embeddings WHERE model = ?", (model,)
|
||||
).fetchone()[0]
|
||||
except Exception:
|
||||
existing = 0
|
||||
|
||||
conn.close()
|
||||
|
||||
stored = 0
|
||||
skipped_no_text = 0
|
||||
errors = 0
|
||||
|
||||
for motion_id, text in to_process:
|
||||
if not text:
|
||||
_logger.info("Skipping motion %s: no text available", motion_id)
|
||||
skipped_no_text += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
vec = ai_provider.get_embedding(text, model=model)
|
||||
if not isinstance(vec, list):
|
||||
_logger.warning(
|
||||
"Embedding provider returned non-list for motion %s", motion_id
|
||||
)
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
res = db.store_embedding(motion_id, model, vec)
|
||||
if res and res > 0:
|
||||
stored += 1
|
||||
else:
|
||||
_logger.error(
|
||||
"Failed to store embedding for motion %s (store returned %s)",
|
||||
motion_id,
|
||||
res,
|
||||
)
|
||||
errors += 1
|
||||
except Exception as exc:
|
||||
_logger.error(
|
||||
"Error computing/storing embedding for motion %s: %s", motion_id, exc
|
||||
)
|
||||
errors += 1
|
||||
|
||||
skipped_existing = int(existing)
|
||||
return stored, skipped_existing, skipped_no_text, errors
|
||||
Reference in New Issue
Block a user