Viney commited on
Commit
fa2ab9a
·
1 Parent(s): 9c0217f

feat(prospection): public holdings extraction gated behind human confirmation

Browse files
.gitignore CHANGED
@@ -6,6 +6,7 @@ __pycache__/
6
  .venv/
7
  *.mp4
8
  *.pdf
 
9
  .langgraph_api/
10
  .worktrees/
11
  .superpowers/sdd/
 
6
  .venv/
7
  *.mp4
8
  *.pdf
9
+ !tests/fixtures/*.pdf
10
  .langgraph_api/
11
  .worktrees/
12
  .superpowers/sdd/
prospection/sources/geco_funds.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """prospection/sources/geco_funds.py — exposition actions déclarée.
2
+
3
+ Repli documenté : l'export GECO par SGP n'étant pas confirmé comme
4
+ automatisable, la poche actions se lit dans `libelle_type_activite` du CSV
5
+ AMF, déjà en base.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ _EQUITY_LABELS = ("action", "diversifi", "internationa", "monde", "amérique", "amerique")
10
+
11
+
12
+ def has_equity_funds(account: dict) -> bool:
13
+ label = (account.get("type_activite") or "").lower()
14
+ return any(marker in label for marker in _EQUITY_LABELS)
prospection/sources/holdings.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """prospection/sources/holdings.py — positions publiées et niveau de preuve.
2
+
3
+ Rien ne passe en `verified` sans confirmation humaine explicite : le
4
+ validateur de rédaction ne voit jamais une ligne non confirmée.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+
12
+ from prospection.sources.ticker_aliases import ALIASES
13
+ from prospection.store import prospects_db as db
14
+
15
+ REPORT_PATHS = ("/reporting", "/nos-fonds", "/documentation", "/fonds", "/publications")
16
+
17
+ _LINE_RE = re.compile(r"^(?P<label>[A-Za-zÀ-ÿ0-9 .&'\-]{3,60}?)\s+(?P<weight>\d{1,2}[.,]\d)\s*%$")
18
+
19
+
20
+ def discover_report_urls(account: dict, client=None) -> list[str]:
21
+ site = (account.get("site") or "").rstrip("/")
22
+ if not site:
23
+ return []
24
+ if client is None:
25
+ import httpx
26
+
27
+ client = httpx.Client(timeout=20.0)
28
+
29
+ found = []
30
+ for path in REPORT_PATHS:
31
+ url = f"{site}{path}"
32
+ try:
33
+ response = client.get(url, headers={"User-Agent": "amplegest-prospection/1.0"},
34
+ follow_redirects=True)
35
+ response.raise_for_status()
36
+ except Exception:
37
+ continue
38
+ found.append(url)
39
+ return found
40
+
41
+
42
+ def extract_holdings(pdf_path: Path, source_url: str) -> list[dict]:
43
+ import pdfplumber
44
+
45
+ rows: list[dict] = []
46
+ with pdfplumber.open(str(pdf_path)) as pdf:
47
+ lines = [l.strip() for page in pdf.pages
48
+ for l in (page.extract_text() or "").splitlines()]
49
+
50
+ for line in lines:
51
+ match = _LINE_RE.match(line)
52
+ if not match:
53
+ continue
54
+ label = match.group("label").strip()
55
+ rows.append({
56
+ "fund_name": None,
57
+ "raw_label": label,
58
+ "ticker": resolve_ticker(label),
59
+ "weight_pct": float(match.group("weight").replace(",", ".")),
60
+ "as_of": None,
61
+ "source_url": source_url,
62
+ "confidence": "probable",
63
+ "retrieved_at": datetime.utcnow().isoformat(timespec="seconds"),
64
+ "confirmed_at": None,
65
+ })
66
+ return rows
67
+
68
+
69
+ def resolve_ticker(label: str) -> str | None:
70
+ lowered = label.lower()
71
+ for alias, ticker in ALIASES.items():
72
+ if alias in lowered:
73
+ return ticker
74
+ return None
75
+
76
+
77
+ def evidence_level(account_id: int) -> str:
78
+ confirmed = [h for h in db.confirmed_holdings(account_id) if h.get("ticker")]
79
+ if confirmed:
80
+ return "holdings_verified"
81
+ account = next((a for a in db.list_accounts() if a["id"] == account_id), None)
82
+ with db.connect() as conn:
83
+ any_holding = conn.execute(
84
+ "SELECT 1 FROM holdings WHERE account_id=? LIMIT 1", (account_id,)
85
+ ).fetchone()
86
+ if any_holding or (account and account.get("tier") in (1, 2)):
87
+ return "universe_inferred"
88
+ return "no_evidence"
prospection/sources/ticker_aliases.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """prospection/sources/ticker_aliases.py — table d'alias curée, libellé -> ticker EDGAR.
2
+
3
+ Volontairement petite et manuelle. Une correspondance devinée produirait une
4
+ personnalisation fausse dans un email : mieux vaut ne rien dire.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ ALIASES: dict[str, str] = {
9
+ "apple": "AAPL",
10
+ "advanced micro devices": "AMD",
11
+ "alphabet": "GOOGL",
12
+ "google": "GOOGL",
13
+ "microsoft": "MSFT",
14
+ "nvidia": "NVDA",
15
+ "tesla": "TSLA",
16
+ "amazon": "AMZN",
17
+ "meta platforms": "META",
18
+ "broadcom": "AVGO",
19
+ "eli lilly": "LLY",
20
+ "visa": "V",
21
+ "netflix": "NFLX",
22
+ "salesforce": "CRM",
23
+ "adobe": "ADBE",
24
+ }
tests/fixtures/reporting_mensuel.pdf ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ %PDF-1.3
2
+ %���
3
+ 1 0 obj
4
+ <<
5
+ /Count 1
6
+ /Kids [3 0 R]
7
+ /MediaBox [0 0 595.28 841.89]
8
+ /Type /Pages
9
+ >>
10
+ endobj
11
+ 2 0 obj
12
+ <<
13
+ /OpenAction [3 0 R /FitH null]
14
+ /PageLayout /OneColumn
15
+ /Pages 1 0 R
16
+ /Type /Catalog
17
+ >>
18
+ endobj
19
+ 3 0 obj
20
+ <<
21
+ /Contents 4 0 R
22
+ /Parent 1 0 R
23
+ /Resources 6 0 R
24
+ /Type /Page
25
+ >>
26
+ endobj
27
+ 4 0 obj
28
+ <<
29
+ /Filter /FlateDecode
30
+ /Length 217
31
+ >>
32
+ stream
33
+ x�m�Ak�@��{~�w)�`�Y]us�$,�ĥ�װa�V#���
34
+ %=
35
+ q��7v)S�
36
+ endstream
37
+ endobj
38
+ 5 0 obj
39
+ <<
40
+ /BaseFont /Helvetica
41
+ /Encoding /WinAnsiEncoding
42
+ /Subtype /Type1
43
+ /Type /Font
44
+ >>
45
+ endobj
46
+ 6 0 obj
47
+ <<
48
+ /Font <</F1 5 0 R>>
49
+ /ProcSet [/PDF /Text /ImageB /ImageC /ImageI]
50
+ >>
51
+ endobj
52
+ 7 0 obj
53
+ <<
54
+ /CreationDate (D:20260829102135Z)
55
+ >>
56
+ endobj
57
+ xref
58
+ 0 8
59
+ 0000000000 65535 f
60
+ 0000000015 00000 n
61
+ 0000000102 00000 n
62
+ 0000000205 00000 n
63
+ 0000000285 00000 n
64
+ 0000000574 00000 n
65
+ 0000000671 00000 n
66
+ 0000000758 00000 n
67
+ trailer
68
+ <<
69
+ /Size 8
70
+ /Root 2 0 R
71
+ /Info 7 0 R
72
+ /ID [<C91A12C767E0F45E98F95EC734815254><C91A12C767E0F45E98F95EC734815254>]
73
+ >>
74
+ startxref
75
+ 813
76
+ %%EOF
tests/test_prospection_holdings.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """tests/test_prospection_holdings.py — positions publiques et niveau de preuve."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from prospection.sources import geco_funds, holdings
9
+ from prospection.store import prospects_db as db
10
+
11
+ FIXTURE = Path(__file__).parent / "fixtures" / "reporting_mensuel.pdf"
12
+
13
+
14
+ @pytest.fixture(autouse=True)
15
+ def tmp_db(tmp_path, monkeypatch):
16
+ monkeypatch.setattr(db, "DB_PATH", tmp_path / "prospects.db")
17
+ monkeypatch.setattr(holdings, "DB_PATH", tmp_path / "prospects.db", raising=False)
18
+ db.init_db()
19
+
20
+
21
+ def _account_id() -> int:
22
+ return db.upsert_account({"no_amf": "GP-19990001", "nom": "Boutique Gestion SAS",
23
+ "site": "https://boutique-gestion.fr", "statut": "vivant",
24
+ "independante": 1})
25
+
26
+
27
+ def test_has_equity_funds_reads_the_amf_activity_label():
28
+ assert geco_funds.has_equity_funds({"type_activite": "Actions"}) is True
29
+ assert geco_funds.has_equity_funds({"type_activite": "Monétaire"}) is False
30
+
31
+
32
+ def test_extract_holdings_reads_lines_and_weights():
33
+ rows = holdings.extract_holdings(FIXTURE, "https://x.fr/r.pdf")
34
+ labels = {r["raw_label"]: r["weight_pct"] for r in rows}
35
+ assert labels["Microsoft Corp"] == 5.4
36
+ assert labels["NVIDIA Corp"] == 4.8
37
+
38
+
39
+ def test_extract_holdings_never_marks_a_row_verified():
40
+ rows = holdings.extract_holdings(FIXTURE, "https://x.fr/r.pdf")
41
+ assert rows
42
+ assert all(r["confidence"] == "probable" for r in rows)
43
+ assert all(r["confirmed_at"] is None for r in rows)
44
+
45
+
46
+ def test_resolve_ticker_maps_known_labels_and_gives_up_otherwise():
47
+ assert holdings.resolve_ticker("Microsoft Corp") == "MSFT"
48
+ assert holdings.resolve_ticker("Air Liquide SA") is None
49
+
50
+
51
+ def test_evidence_level_requires_a_confirmed_edgar_holding():
52
+ account_id = _account_id()
53
+ holding_id = db.upsert_holding({"account_id": account_id, "fund_name": "Fonds A",
54
+ "raw_label": "Microsoft Corp", "ticker": "MSFT",
55
+ "weight_pct": 5.4, "as_of": "2026-07-31",
56
+ "source_url": "https://x.fr/r.pdf",
57
+ "confidence": "probable",
58
+ "retrieved_at": "2026-08-01T00:00:00"})
59
+ assert holdings.evidence_level(account_id) == "universe_inferred"
60
+ db.confirm_holding(holding_id, "2026-08-29T09:00:00")
61
+ assert holdings.evidence_level(account_id) == "holdings_verified"
62
+
63
+
64
+ def test_evidence_level_is_no_evidence_without_any_holding():
65
+ assert holdings.evidence_level(_account_id()) == "no_evidence"
66
+
67
+
68
+ def test_discover_report_urls_returns_empty_when_every_path_is_missing():
69
+ class _Client:
70
+ def get(self, url, headers=None, follow_redirects=True):
71
+ raise RuntimeError("404")
72
+
73
+ assert holdings.discover_report_urls({"site": "https://x.fr"}, client=_Client()) == []