Spaces:
Sleeping
Sleeping
Commit ·
396c071
1
Parent(s): 78f4a0d
enrich + multilingual: integration snippets + CN/JP/FR/IN evidence
Browse files- 28 discovered tools now ship with curated description + PyTorch
integration snippet pulled from authoritative docs via Bright Data
Web Unlocker, condensed by local Qwen2.5-3B (no paid APIs).
- Multilingual pass via Bright Data geo-SERP (CN/JP/IN/FR queries)
adds 47 review-queue rows with real foreign-host evidence URLs
(tencent.com, baidu.com, zenn.dev, ayinedjimi-consultants.fr) and
promoted 2 additional edges through the corroboration gate.
- forge/enrich_with_examples.py +207 -0
- forge/forge.db +0 -0
- forge/multilingual.py +175 -0
forge/enrich_with_examples.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Enrich discovered-tool descriptions with PyTorch integration snippets
|
| 2 |
+
sourced from authoritative docs via Bright Data Web Unlocker, then condensed
|
| 3 |
+
by the local Qwen2.5-3B model.
|
| 4 |
+
|
| 5 |
+
For each discovered tool:
|
| 6 |
+
1. Pick the curated authoritative URL (PyPI / GitHub README / HF docs).
|
| 7 |
+
2. BD-scrape it as markdown.
|
| 8 |
+
3. Ask the local LLM to extract a minimal PyTorch trainer integration
|
| 9 |
+
snippet + one-line summary.
|
| 10 |
+
4. Append `\n\n**Integration (source: <url>):**\n```python\n<snippet>\n```` to
|
| 11 |
+
the node description.
|
| 12 |
+
|
| 13 |
+
The original curated one-liner from enrich.py stays as the lead — this just
|
| 14 |
+
appends real, cited code beneath it. Output: each discovered tool ends up with
|
| 15 |
+
description = "<one-liner>\n\n**Integration (source: ...):**\n```python\n...```"
|
| 16 |
+
which is exactly what the UI panel needs to be educational.
|
| 17 |
+
|
| 18 |
+
Run: python -m forge.enrich_with_examples
|
| 19 |
+
python -m forge.enrich_with_examples --only dora,flashattention
|
| 20 |
+
python -m forge.enrich_with_examples --dry-run
|
| 21 |
+
"""
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import argparse
|
| 25 |
+
import json
|
| 26 |
+
import re
|
| 27 |
+
import sys
|
| 28 |
+
import time
|
| 29 |
+
import urllib.request
|
| 30 |
+
|
| 31 |
+
from . import db, scraper
|
| 32 |
+
|
| 33 |
+
# Curated authoritative sources. README/docs that document USAGE, not abstracts.
|
| 34 |
+
SOURCES: dict[str, str] = {
|
| 35 |
+
# frameworks
|
| 36 |
+
"accelerate": "https://github.com/huggingface/accelerate/blob/main/README.md",
|
| 37 |
+
"megatron": "https://github.com/NVIDIA/Megatron-LM/blob/main/README.md",
|
| 38 |
+
"pytorch": "https://raw.githubusercontent.com/pytorch/tutorials/main/recipes_source/recipes/amp_recipe.py",
|
| 39 |
+
"pytorchfsdp": "https://raw.githubusercontent.com/pytorch/examples/main/distributed/FSDP2/example.py",
|
| 40 |
+
"tensorrtmodeloptimizer": "https://raw.githubusercontent.com/NVIDIA/TensorRT-Model-Optimizer/main/examples/llm_ptq/README.md",
|
| 41 |
+
"tensorrtllm": "https://github.com/NVIDIA/TensorRT-LLM/blob/main/README.md",
|
| 42 |
+
"transformerengine": "https://github.com/NVIDIA/TransformerEngine/blob/main/README.rst",
|
| 43 |
+
"transformers": "https://huggingface.co/docs/transformers/training",
|
| 44 |
+
# optimizers — point at PyTorch source files (static HTML on GitHub raw).
|
| 45 |
+
"adagrad": "https://raw.githubusercontent.com/pytorch/pytorch/main/torch/optim/adagrad.py",
|
| 46 |
+
"pagedoptimizers": "https://raw.githubusercontent.com/bitsandbytes-foundation/bitsandbytes/main/README.md",
|
| 47 |
+
"rmsprop": "https://raw.githubusercontent.com/pytorch/pytorch/main/torch/optim/rmsprop.py",
|
| 48 |
+
"sgd": "https://raw.githubusercontent.com/pytorch/pytorch/main/torch/optim/sgd.py",
|
| 49 |
+
"shampoo": "https://github.com/facebookresearch/optimizers/blob/main/README.md",
|
| 50 |
+
# quantization
|
| 51 |
+
"4bitquantization": "https://huggingface.co/docs/transformers/main/quantization/bitsandbytes",
|
| 52 |
+
"doublequantization": "https://huggingface.co/docs/bitsandbytes/main/en/explanations/resources",
|
| 53 |
+
"fp8": "https://raw.githubusercontent.com/NVIDIA/TransformerEngine/main/docs/examples/fp8_primer.ipynb",
|
| 54 |
+
"ptq": "https://raw.githubusercontent.com/huggingface/optimum-quanto/main/README.md",
|
| 55 |
+
"bitsandbytes": "https://github.com/bitsandbytes-foundation/bitsandbytes/blob/main/README.md",
|
| 56 |
+
# schedulers — all live in the same big file; the prompt keys on the TOOL name.
|
| 57 |
+
"cosineannealinglr": "https://raw.githubusercontent.com/pytorch/pytorch/main/torch/optim/lr_scheduler.py",
|
| 58 |
+
"reducelronplateau": "https://raw.githubusercontent.com/pytorch/pytorch/main/torch/optim/lr_scheduler.py",
|
| 59 |
+
"steplr": "https://raw.githubusercontent.com/pytorch/pytorch/main/torch/optim/lr_scheduler.py",
|
| 60 |
+
# techniques
|
| 61 |
+
"dora": "https://huggingface.co/docs/peft/main/en/developer_guides/lora#weight-decomposed-low-rank-adaptation-dora",
|
| 62 |
+
"flashattention": "https://github.com/Dao-AILab/flash-attention/blob/main/README.md",
|
| 63 |
+
"peft": "https://huggingface.co/docs/peft/quicktour",
|
| 64 |
+
"relora": "https://github.com/Guitaricet/relora/blob/main/README.md",
|
| 65 |
+
"tensorparallelism": "https://pytorch.org/tutorials/intermediate/TP_tutorial.html",
|
| 66 |
+
"mixedprecision": "https://raw.githubusercontent.com/pytorch/tutorials/main/recipes_source/recipes/amp_recipe.py",
|
| 67 |
+
"pruning": "https://raw.githubusercontent.com/pytorch/tutorials/main/intermediate_source/pruning_tutorial.py",
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
_PROMPT = """You are reading developer documentation for an ML training tool.
|
| 71 |
+
Return JSON with two keys:
|
| 72 |
+
"snippet": a MINIMAL, COMPLETE, RUNNABLE PyTorch trainer integration snippet
|
| 73 |
+
(5-15 lines). Show ONLY the lines that change to add this tool to
|
| 74 |
+
a plain PyTorch training loop. Use imports, the construct/wrap
|
| 75 |
+
call, and the line that drops into the loop. No prose. No ``` fences.
|
| 76 |
+
"summary": one short sentence (under 20 words) describing how a practitioner
|
| 77 |
+
integrates this into an existing trainer.
|
| 78 |
+
If the page has no usable code, return {"snippet":"", "summary":""}.
|
| 79 |
+
Output strictly JSON. No markdown fences. No commentary."""
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _ask_llm(markdown: str, tool: str, timeout: int = 120) -> dict:
|
| 83 |
+
if not scraper._llm_up():
|
| 84 |
+
return {}
|
| 85 |
+
md = scraper._clean_markdown(markdown)[:12000]
|
| 86 |
+
user = f"TOOL: {tool}\n\nDOCUMENTATION:\n{md}"
|
| 87 |
+
payload = {
|
| 88 |
+
"messages": [{"role": "system", "content": _PROMPT},
|
| 89 |
+
{"role": "user", "content": user}],
|
| 90 |
+
"temperature": 0,
|
| 91 |
+
"max_tokens": 600,
|
| 92 |
+
"response_format": {"type": "json_object"},
|
| 93 |
+
}
|
| 94 |
+
req = urllib.request.Request(
|
| 95 |
+
scraper.LLM_URL.rstrip("/") + "/v1/chat/completions",
|
| 96 |
+
data=json.dumps(payload).encode(),
|
| 97 |
+
headers={"Content-Type": "application/json", "Authorization": "Bearer no-key"},
|
| 98 |
+
)
|
| 99 |
+
try:
|
| 100 |
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
| 101 |
+
resp = json.loads(r.read())
|
| 102 |
+
text = resp["choices"][0]["message"]["content"]
|
| 103 |
+
m = re.search(r"\{[\s\S]*\}", text)
|
| 104 |
+
return json.loads(m.group(0)) if m else {}
|
| 105 |
+
except Exception as e: # noqa: BLE001
|
| 106 |
+
print(f" ! llm error for {tool}: {e}", file=sys.stderr)
|
| 107 |
+
return {}
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _clean_snippet(snippet: str) -> str:
|
| 111 |
+
"""Strip stray triple-backticks and un-escape over-escaped whitespace."""
|
| 112 |
+
s = snippet.strip()
|
| 113 |
+
s = re.sub(r"^```(?:python|py)?\s*\n?", "", s)
|
| 114 |
+
s = re.sub(r"\n?```\s*$", "", s)
|
| 115 |
+
# LLMs sometimes emit literal "\n"/"\t" inside the JSON string instead of real
|
| 116 |
+
# whitespace. Convert any such occurrences back to real characters.
|
| 117 |
+
if "\\n" in s:
|
| 118 |
+
s = s.replace("\\n", "\n")
|
| 119 |
+
if "\\t" in s:
|
| 120 |
+
s = s.replace("\\t", " ")
|
| 121 |
+
return s.strip()
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _append_block(existing: str, url: str, summary: str, snippet: str) -> str:
|
| 125 |
+
base = (existing or "").split("\n\n**Integration", 1)[0].rstrip()
|
| 126 |
+
summary = (summary or "").strip()
|
| 127 |
+
snippet = _clean_snippet(snippet)
|
| 128 |
+
if not snippet:
|
| 129 |
+
return existing
|
| 130 |
+
block = f"\n\n**Integration (source: {url}):**"
|
| 131 |
+
if summary:
|
| 132 |
+
block += f"\n_{summary}_"
|
| 133 |
+
block += f"\n```python\n{snippet}\n```"
|
| 134 |
+
return base + block
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def run(only: list[str] | None = None, dry_run: bool = False, sleep: float = 0.5):
|
| 138 |
+
if not scraper._llm_up():
|
| 139 |
+
print("local LLM not reachable at", scraper.LLM_URL)
|
| 140 |
+
return {"enriched": [], "skipped": [], "failed": []}
|
| 141 |
+
|
| 142 |
+
conn = db.connect()
|
| 143 |
+
rows = conn.execute(
|
| 144 |
+
"SELECT id, canonical, name, description FROM nodes WHERE tags_json LIKE '%discovered%'"
|
| 145 |
+
).fetchall()
|
| 146 |
+
|
| 147 |
+
stats = {"enriched": [], "skipped": [], "failed": []}
|
| 148 |
+
for r in rows:
|
| 149 |
+
canon = r["canonical"]
|
| 150 |
+
if only and canon not in only:
|
| 151 |
+
continue
|
| 152 |
+
url = SOURCES.get(canon)
|
| 153 |
+
if not url:
|
| 154 |
+
stats["skipped"].append((canon, "no curated source url"))
|
| 155 |
+
print(f" ~ {canon}: no curated source url")
|
| 156 |
+
continue
|
| 157 |
+
if r["description"] and "**Integration (source:" in (r["description"] or ""):
|
| 158 |
+
stats["skipped"].append((canon, "already enriched"))
|
| 159 |
+
print(f" = {canon}: already enriched")
|
| 160 |
+
continue
|
| 161 |
+
|
| 162 |
+
print(f" > {canon}: fetching {url}")
|
| 163 |
+
md = scraper.fetch(url) # BD Web Unlocker
|
| 164 |
+
if not md or len(md) < 200:
|
| 165 |
+
stats["failed"].append((canon, f"fetch empty ({len(md or '')} chars)"))
|
| 166 |
+
print(f" ! {canon}: fetch empty")
|
| 167 |
+
continue
|
| 168 |
+
|
| 169 |
+
result = _ask_llm(md, canon)
|
| 170 |
+
snippet = _clean_snippet(result.get("snippet", ""))
|
| 171 |
+
summary = result.get("summary", "")
|
| 172 |
+
if not snippet:
|
| 173 |
+
stats["failed"].append((canon, "no snippet extracted"))
|
| 174 |
+
print(f" ! {canon}: no snippet")
|
| 175 |
+
continue
|
| 176 |
+
|
| 177 |
+
new_desc = _append_block(r["description"], url, summary, snippet)
|
| 178 |
+
if dry_run:
|
| 179 |
+
print(f" [dry] {canon}: would append {len(snippet)}-char snippet")
|
| 180 |
+
else:
|
| 181 |
+
conn.execute("UPDATE nodes SET description=? WHERE id=?", (new_desc, r["id"]))
|
| 182 |
+
conn.commit()
|
| 183 |
+
stats["enriched"].append(canon)
|
| 184 |
+
print(f" + {canon}: +{len(snippet)} chars ({summary[:60]})")
|
| 185 |
+
time.sleep(sleep)
|
| 186 |
+
return stats
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def main():
|
| 190 |
+
p = argparse.ArgumentParser()
|
| 191 |
+
p.add_argument("--only", default="", help="comma-separated canonicals to limit to")
|
| 192 |
+
p.add_argument("--dry-run", action="store_true")
|
| 193 |
+
p.add_argument("--sleep", type=float, default=0.5)
|
| 194 |
+
a = p.parse_args()
|
| 195 |
+
only = [s.strip() for s in a.only.split(",") if s.strip()] or None
|
| 196 |
+
s = run(only=only, dry_run=a.dry_run, sleep=a.sleep)
|
| 197 |
+
print()
|
| 198 |
+
print(f"enriched: {len(s['enriched'])}")
|
| 199 |
+
print(f"skipped : {len(s['skipped'])}")
|
| 200 |
+
print(f"failed : {len(s['failed'])}")
|
| 201 |
+
if s["failed"]:
|
| 202 |
+
for c, why in s["failed"]:
|
| 203 |
+
print(f" - {c}: {why}")
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
if __name__ == "__main__":
|
| 207 |
+
main()
|
forge/forge.db
CHANGED
|
Binary files a/forge/forge.db and b/forge/forge.db differ
|
|
|
forge/multilingual.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Multilingual evidence pass — actually fetch CN/JP/IN/FR practitioner pages
|
| 2 |
+
via Bright Data geo-targeted SERP + Web Unlocker, so the bilingual-reach claim
|
| 3 |
+
is backed by real evidence rows.
|
| 4 |
+
|
| 5 |
+
Why this exists: previous campaign runs were English-host-dominated. The
|
| 6 |
+
writeup mentions Chinese ecosystem reach, but the DB only held huggingface.co
|
| 7 |
+
/ github.com / arxiv.org hosts. This run hits CSDN, Zhihu (CN), Qiita, Zenn
|
| 8 |
+
(JP), Medium-EN India authors / dev.to (IN), and dev.to FR / GitHub-FR forks
|
| 9 |
+
(FR) and pushes whatever the extractor finds into the corroboration gate.
|
| 10 |
+
|
| 11 |
+
The relation classifier is English-cue-tuned, so most foreign-language pages
|
| 12 |
+
fall back to co-mention COMPATIBLE → review_queue. That's the point: the
|
| 13 |
+
evidence_url column ends up holding real CN/JP/IN/FR hosts, demonstrating
|
| 14 |
+
actual multilingual scraping coverage even when promotion is gated out.
|
| 15 |
+
|
| 16 |
+
Run: python -m forge.multilingual
|
| 17 |
+
python -m forge.multilingual --countries CN,JP
|
| 18 |
+
python -m forge.multilingual --max-pages 20
|
| 19 |
+
"""
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import argparse
|
| 23 |
+
import json
|
| 24 |
+
import time
|
| 25 |
+
from datetime import datetime
|
| 26 |
+
from pathlib import Path
|
| 27 |
+
|
| 28 |
+
from . import collect, db, scraper
|
| 29 |
+
|
| 30 |
+
# Geo-targeted query packs. Mix native script + Roman tool names so SERP
|
| 31 |
+
# returns native-language practitioner pages that still mention tools by name.
|
| 32 |
+
QUERY_PACKS: dict[str, list[str]] = {
|
| 33 |
+
"CN": [
|
| 34 |
+
"QLoRA 微调 教程", # QLoRA fine-tuning tutorial
|
| 35 |
+
"Llama 训练 PEFT LoRA", # Llama training
|
| 36 |
+
"FlashAttention 安装 PyTorch", # FlashAttention install
|
| 37 |
+
"bitsandbytes 量化 4bit", # quantization 4bit
|
| 38 |
+
"DeepSpeed ZeRO 训练", # ZeRO training
|
| 39 |
+
"AdamW 学习率 cosine scheduler", # learning rate cosine
|
| 40 |
+
],
|
| 41 |
+
"JP": [
|
| 42 |
+
"QLoRA ファインチューニング 解説", # QLoRA fine-tuning explanation
|
| 43 |
+
"LoRA 学習 Llama PyTorch", # LoRA training Llama PyTorch
|
| 44 |
+
"FlashAttention インストール 方法", # FlashAttention installation
|
| 45 |
+
"bitsandbytes 量子化 8bit", # quantization 8bit
|
| 46 |
+
"DeepSpeed 分散学習", # DeepSpeed distributed training
|
| 47 |
+
"AdamW スケジューラ cosine", # scheduler cosine
|
| 48 |
+
],
|
| 49 |
+
"IN": [
|
| 50 |
+
"QLoRA fine tuning Llama tutorial India",
|
| 51 |
+
"PEFT LoRA training PyTorch India",
|
| 52 |
+
"FlashAttention installation guide",
|
| 53 |
+
"bitsandbytes 4bit quantization India",
|
| 54 |
+
"DeepSpeed ZeRO training Hindi",
|
| 55 |
+
"AdamW optimizer cosine scheduler",
|
| 56 |
+
],
|
| 57 |
+
"FR": [
|
| 58 |
+
"QLoRA fine-tuning tutoriel français",
|
| 59 |
+
"LoRA entraînement Llama PyTorch",
|
| 60 |
+
"FlashAttention installation PyTorch français",
|
| 61 |
+
"bitsandbytes quantification 4bit",
|
| 62 |
+
"DeepSpeed apprentissage distribué",
|
| 63 |
+
"AdamW optimiseur scheduler cosinus",
|
| 64 |
+
],
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _gather(country: str, queries: list[str], per_query: int, sleep: float) -> list[dict]:
|
| 69 |
+
"""Geo-SERP via Bright Data, deduped by URL."""
|
| 70 |
+
seen: set[str] = set()
|
| 71 |
+
hits: list[dict] = []
|
| 72 |
+
for q in queries:
|
| 73 |
+
try:
|
| 74 |
+
results = scraper.search(q, n=per_query, country=country)
|
| 75 |
+
except Exception as e: # noqa: BLE001
|
| 76 |
+
print(f" ! [{country}] SERP error on {q!r}: {e}")
|
| 77 |
+
continue
|
| 78 |
+
for r in results:
|
| 79 |
+
url = r.get("url")
|
| 80 |
+
if not url or url in seen:
|
| 81 |
+
continue
|
| 82 |
+
seen.add(url)
|
| 83 |
+
r["query"] = q
|
| 84 |
+
r["country"] = country
|
| 85 |
+
hits.append(r)
|
| 86 |
+
time.sleep(sleep)
|
| 87 |
+
return hits
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _ingest_page(conn, ai, resolver, hit: dict) -> dict:
|
| 91 |
+
"""Fetch one page via Web Unlocker; extract; ingest through corroboration gate."""
|
| 92 |
+
url = hit["url"]
|
| 93 |
+
try:
|
| 94 |
+
md = scraper.fetch(url)
|
| 95 |
+
except Exception as e: # noqa: BLE001
|
| 96 |
+
return {"ok": False, "reason": f"fetch error: {e}", "promoted": [], "queued": []}
|
| 97 |
+
if not md or len(md) < 200:
|
| 98 |
+
return {"ok": False, "reason": f"thin ({len(md or '')} chars)", "promoted": [], "queued": []}
|
| 99 |
+
triples = scraper.extract(md, url, ai)
|
| 100 |
+
res = scraper.ingest(conn, triples, url, resolver)
|
| 101 |
+
return {"ok": True, "triples": len(triples), "chars": len(md), **res}
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def run(countries: list[str] | None = None,
|
| 105 |
+
per_query: int = 4,
|
| 106 |
+
max_pages_per_country: int = 12,
|
| 107 |
+
sleep: float = 0.4) -> dict:
|
| 108 |
+
countries = countries or list(QUERY_PACKS.keys())
|
| 109 |
+
conn = db.connect()
|
| 110 |
+
ai = scraper.build_alias_index(conn)
|
| 111 |
+
resolver = scraper.build_resolver(conn)
|
| 112 |
+
|
| 113 |
+
summary = {"ts": datetime.utcnow().isoformat(), "by_country": {}}
|
| 114 |
+
for cc in countries:
|
| 115 |
+
queries = QUERY_PACKS.get(cc)
|
| 116 |
+
if not queries:
|
| 117 |
+
print(f" ~ no query pack for {cc}, skipping")
|
| 118 |
+
continue
|
| 119 |
+
print(f"\n=== {cc} ===")
|
| 120 |
+
hits = _gather(cc, queries, per_query=per_query, sleep=sleep)
|
| 121 |
+
print(f" SERP: {len(hits)} unique URLs across {len(queries)} queries")
|
| 122 |
+
|
| 123 |
+
cc_stats = {"hits": len(hits), "scraped": 0, "promoted": [], "queued": [], "hosts": {}, "errors": []}
|
| 124 |
+
for hit in hits[:max_pages_per_country]:
|
| 125 |
+
from urllib.parse import urlparse
|
| 126 |
+
host = urlparse(hit["url"]).netloc
|
| 127 |
+
print(f" > [{cc}] {host} ({hit['query'][:40]})")
|
| 128 |
+
res = _ingest_page(conn, ai, resolver, hit)
|
| 129 |
+
if not res["ok"]:
|
| 130 |
+
cc_stats["errors"].append((hit["url"], res["reason"]))
|
| 131 |
+
print(f" ! {res['reason']}")
|
| 132 |
+
continue
|
| 133 |
+
cc_stats["scraped"] += 1
|
| 134 |
+
cc_stats["hosts"][host] = cc_stats["hosts"].get(host, 0) + 1
|
| 135 |
+
cc_stats["promoted"] += res["promoted"]
|
| 136 |
+
cc_stats["queued"] += res["queued"]
|
| 137 |
+
print(f" + {res['triples']} triples, +{len(res['promoted'])} promoted, +{len(res['queued'])} queued")
|
| 138 |
+
time.sleep(sleep)
|
| 139 |
+
summary["by_country"][cc] = cc_stats
|
| 140 |
+
|
| 141 |
+
# Persist the raw run summary alongside other campaign artifacts.
|
| 142 |
+
collect.RAW_DIR.mkdir(parents=True, exist_ok=True)
|
| 143 |
+
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 144 |
+
out = collect.RAW_DIR / f"forge_multilingual_{stamp}.json"
|
| 145 |
+
out.write_text(json.dumps(summary, ensure_ascii=False, indent=2))
|
| 146 |
+
print(f"\nsaved summary -> {out}")
|
| 147 |
+
return summary
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def main():
|
| 151 |
+
p = argparse.ArgumentParser()
|
| 152 |
+
p.add_argument("--countries", default=",".join(QUERY_PACKS.keys()),
|
| 153 |
+
help="comma-separated country codes (CN,JP,IN,FR)")
|
| 154 |
+
p.add_argument("--per-query", type=int, default=4)
|
| 155 |
+
p.add_argument("--max-pages", type=int, default=12, help="cap pages scraped per country")
|
| 156 |
+
p.add_argument("--sleep", type=float, default=0.4)
|
| 157 |
+
a = p.parse_args()
|
| 158 |
+
countries = [c.strip().upper() for c in a.countries.split(",") if c.strip()]
|
| 159 |
+
s = run(countries=countries, per_query=a.per_query,
|
| 160 |
+
max_pages_per_country=a.max_pages, sleep=a.sleep)
|
| 161 |
+
|
| 162 |
+
print("\n=== multilingual run report ===")
|
| 163 |
+
for cc, st in s["by_country"].items():
|
| 164 |
+
print(f"\n[{cc}]")
|
| 165 |
+
print(f" SERP hits : {st['hits']}")
|
| 166 |
+
print(f" scraped OK : {st['scraped']}")
|
| 167 |
+
print(f" promoted : {len(st['promoted'])}")
|
| 168 |
+
print(f" queued : {len(st['queued'])}")
|
| 169 |
+
print(f" hosts seen : {sorted(st['hosts'].items(), key=lambda x: -x[1])[:8]}")
|
| 170 |
+
if st["errors"]:
|
| 171 |
+
print(f" errors : {len(st['errors'])}")
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
if __name__ == "__main__":
|
| 175 |
+
main()
|