Spaces:
Sleeping
Index all 5 publications; add self-updating refresh pipeline
Browse filesThe knowledge base held 1 of 5 papers. download_papers.py only queried arXiv,
so anything published solely to ACM DL or PMLR was invisible to the RAG,
including both Canadian AI 2026 papers.
download_publications.py (replaces download_papers.py)
Merges four sources on a normalised title:
openalex widest coverage, picks up new work on its own, but lags months
crossref authoritative published author order for DOI-bearing papers
pmlr scrapes configured volume indexes; PMLR reaches OpenAlex late
arxiv preprints
Plus a manual list in sources.json for anything all four miss.
Author matching requires family name AND a known given name on the same
entry. Crossref returns several unrelated Serbanescus, and a surname-only
match pulled them all in.
Crossref order is treated as canonical: it corrected two papers where the
author order was previously wrong.
refresh_index.py
Rewritten around the failure mode that mattered. It used to run every step,
ignore failures, and rebuild regardless, so a rate-limited GitHub run
silently rebuilt the index from a half-empty docs/ tree.
Now aborts before rebuilding when a source fails, or when the corpus shrinks
more than 25% against the last run (an API returning [] rather than an
error). On abort nothing is rebuilt and nothing is pushed, so the deployed
index keeps serving the last good build. State in .refresh_state.json.
sync_resume.py
Copies the current resume into docs/pdfs so answers track the live document.
Removed the stale April copy, which would have contradicted it.
.gitignore
Stopped ignoring docs/faiss/ and docs/pdfs/. The Space serves both directly
out of the repo; the rules only appeared to work because those files were
committed before the rules existed, which meant any newly added PDF never
reached the deployment.
Scheduling
scheduled_refresh.sh for cron (flock, venv, rotating logs) and a GitHub
Actions workflow. AUTOMATION.md covers both, what updates automatically,
and why LinkedIn cannot.
All 5 papers now retrieve as top hits against the rebuilt index.
- .github/workflows/refresh-knowledge-base.yml +84 -0
- .gitignore +17 -7
- .refresh_state.json +9 -0
- AUTOMATION.md +174 -0
- build_index.py +136 -32
- docs/faiss/document_lookup.txt +0 -0
- docs/faiss/index.faiss +2 -2
- docs/faiss/index.pkl +2 -2
- docs/faiss/metadata.pkl +2 -2
- docs/papers/2024_UnAnswGen_A_Systematic_Approach_for_Generating_Unanswerable_Questions_in_Machine_Reading_Comprehension.md +14 -0
- docs/papers/2025_FalseCoTQA_Adversarial_Multi-Hop_QA_via_Knowledge-Grounded_False_Chains_of_Thought.md +14 -0
- docs/papers/2025_Uncovering_the_Persuasive_Fingerprint_of_LLMs_in_Jailbreaking_Attacks.md +16 -0
- docs/papers/2026_Cause-Conditioned_Multi-Task_Learning_for_Answerable_Question_Suggestion_in_MRC.md +13 -0
- docs/papers/2026_From_Hints_to_Answers_Uncertainty-Aware_LLM-Guided_Retrieval_for_Multi-Hop_Question_Answering.md +13 -0
- docs/papers/arxiv_2510.21983v1_Uncovering_the_Persuasive_Fingerprint_of_LLMs_in_Jailbreaking_Attacks.md +0 -15
- docs/pdfs/resume_julien_current.pdf +3 -0
- download_publications.py +525 -0
- refresh_index.py +272 -0
- scheduled_refresh.sh +66 -0
- sources.json +43 -0
- sync_resume.py +68 -0
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Requires this repo to be mirrored to GitHub. See AUTOMATION.md for setup, or
|
| 2 |
+
# use the julienlab cron option instead if you would rather not mirror.
|
| 3 |
+
|
| 4 |
+
name: Refresh RAG knowledge base
|
| 5 |
+
|
| 6 |
+
on:
|
| 7 |
+
schedule:
|
| 8 |
+
# 07:00 UTC every Monday (03:00 Toronto in EDT, 02:00 in EST).
|
| 9 |
+
- cron: "0 7 * * 1"
|
| 10 |
+
workflow_dispatch:
|
| 11 |
+
inputs:
|
| 12 |
+
allow_partial:
|
| 13 |
+
description: "Rebuild even if a source fails"
|
| 14 |
+
type: boolean
|
| 15 |
+
default: false
|
| 16 |
+
|
| 17 |
+
concurrency:
|
| 18 |
+
group: refresh-knowledge-base
|
| 19 |
+
cancel-in-progress: false
|
| 20 |
+
|
| 21 |
+
jobs:
|
| 22 |
+
refresh:
|
| 23 |
+
runs-on: ubuntu-latest
|
| 24 |
+
timeout-minutes: 45
|
| 25 |
+
|
| 26 |
+
steps:
|
| 27 |
+
- name: Check out repository
|
| 28 |
+
uses: actions/checkout@v4
|
| 29 |
+
with:
|
| 30 |
+
fetch-depth: 0
|
| 31 |
+
|
| 32 |
+
- name: Set up Python
|
| 33 |
+
uses: actions/setup-python@v5
|
| 34 |
+
with:
|
| 35 |
+
python-version: "3.11"
|
| 36 |
+
cache: pip
|
| 37 |
+
|
| 38 |
+
- name: Install dependencies
|
| 39 |
+
run: |
|
| 40 |
+
python -m pip install --upgrade pip
|
| 41 |
+
pip install -r requirements_hf.txt
|
| 42 |
+
|
| 43 |
+
- name: Refresh knowledge base and rebuild index
|
| 44 |
+
env:
|
| 45 |
+
COHEREAPIKEY: ${{ secrets.COHEREAPIKEY }}
|
| 46 |
+
COHERE_API_KEY: ${{ secrets.COHEREAPIKEY }}
|
| 47 |
+
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
| 48 |
+
# Lets the GitHub sources use the 5000 req/hr authenticated limit
|
| 49 |
+
# instead of the 60 req/hr anonymous one.
|
| 50 |
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
| 51 |
+
run: |
|
| 52 |
+
python refresh_index.py ${{ inputs.allow_partial && '--allow-partial' || '' }}
|
| 53 |
+
|
| 54 |
+
- name: Push updated index to Hugging Face Space
|
| 55 |
+
if: success()
|
| 56 |
+
env:
|
| 57 |
+
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
| 58 |
+
run: |
|
| 59 |
+
set -euo pipefail
|
| 60 |
+
if [ -z "${HF_TOKEN:-}" ]; then
|
| 61 |
+
echo "HF_TOKEN not set; skipping deploy."
|
| 62 |
+
exit 0
|
| 63 |
+
fi
|
| 64 |
+
|
| 65 |
+
git config user.name "rag-refresh-bot"
|
| 66 |
+
git config user.email "actions@github.com"
|
| 67 |
+
git add docs/ .refresh_state.json
|
| 68 |
+
|
| 69 |
+
if git diff --staged --quiet; then
|
| 70 |
+
echo "Index unchanged; nothing to deploy."
|
| 71 |
+
exit 0
|
| 72 |
+
fi
|
| 73 |
+
|
| 74 |
+
git commit -m "chore: refresh knowledge base ($(date -u +%Y-%m-%d))"
|
| 75 |
+
git push "https://oauth:${HF_TOKEN}@huggingface.co/spaces/DaJulster/julienserbanescu-rag" HEAD:main
|
| 76 |
+
echo "Deployed. The Space will rebuild automatically."
|
| 77 |
+
|
| 78 |
+
- name: Upload run log
|
| 79 |
+
if: always()
|
| 80 |
+
uses: actions/upload-artifact@v4
|
| 81 |
+
with:
|
| 82 |
+
name: refresh-state
|
| 83 |
+
path: .refresh_state.json
|
| 84 |
+
if-no-files-found: ignore
|
|
@@ -1,9 +1,19 @@
|
|
| 1 |
-
|
| 2 |
-
docs/pdfs/*
|
| 3 |
-
docs/youtube/*
|
| 4 |
-
.git_backup
|
| 5 |
-
__pycache__
|
| 6 |
-
*.pyc
|
| 7 |
.env
|
| 8 |
pat.txt
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Secrets
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
.env
|
| 3 |
pat.txt
|
| 4 |
+
|
| 5 |
+
# Local environment
|
| 6 |
+
/venv
|
| 7 |
+
__pycache__
|
| 8 |
+
*.pyc
|
| 9 |
+
.git_backup
|
| 10 |
+
|
| 11 |
+
# Transcripts are large and not currently part of the pipeline
|
| 12 |
+
docs/youtube/*
|
| 13 |
+
|
| 14 |
+
# NOTE: docs/faiss/ and docs/pdfs/ are deliberately NOT ignored.
|
| 15 |
+
# The Hugging Face Space serves the prebuilt index and source PDFs directly out
|
| 16 |
+
# of this repo, so ignoring them silently strips content from the deployment.
|
| 17 |
+
# They were previously ignored and only kept working because the files had been
|
| 18 |
+
# committed before the rule was added, which meant any newly added PDF (such as
|
| 19 |
+
# a refreshed resume) never reached the Space.
|
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"corpus": {
|
| 3 |
+
"publications": 5,
|
| 4 |
+
"readmes": 70,
|
| 5 |
+
"github_activity": 18,
|
| 6 |
+
"pdfs": 4
|
| 7 |
+
},
|
| 8 |
+
"last_run": "2026-08-08T00:30:21.863716+00:00"
|
| 9 |
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Keeping the RAG current
|
| 2 |
+
|
| 3 |
+
The knowledge base is a set of files under `docs/` plus a prebuilt FAISS index.
|
| 4 |
+
The Hugging Face Space serves that index directly out of this repo, so "updating
|
| 5 |
+
the RAG" means: re-download sources, rebuild the index, commit, push.
|
| 6 |
+
|
| 7 |
+
`refresh_index.py` does all four steps.
|
| 8 |
+
|
| 9 |
+
```bash
|
| 10 |
+
python refresh_index.py # download + rebuild, no deploy
|
| 11 |
+
python refresh_index.py --push # download + rebuild + deploy to the Space
|
| 12 |
+
python refresh_index.py --dry-run # print the plan, touch nothing
|
| 13 |
+
```
|
| 14 |
+
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
## What updates automatically, and what does not
|
| 18 |
+
|
| 19 |
+
| Source | Path | Automatic? | How |
|
| 20 |
+
|---|---|---|---|
|
| 21 |
+
| Publications | `docs/papers/` | **Yes** | `download_publications.py` merges OpenAlex, Crossref, PMLR, arXiv |
|
| 22 |
+
| GitHub repo READMEs | `docs/readmes/` | **Yes** | `download_readmes.py` |
|
| 23 |
+
| GitHub issues + PRs | `docs/github_activity/` | **Yes** | `download_github_activity.py` |
|
| 24 |
+
| Resume | `docs/pdfs/` | **Yes** | `sync_resume.py` copies from the path in `sources.json` |
|
| 25 |
+
| Portfolio site text | scraped at build | **Yes** | `build_index.py` scrapes the live site |
|
| 26 |
+
| LinkedIn | `docs/linkedin/` | **No** | Manual export, see below |
|
| 27 |
+
|
| 28 |
+
### Publications
|
| 29 |
+
|
| 30 |
+
`download_publications.py` queries four sources and merges them on a normalised
|
| 31 |
+
title, so one paper appearing in three places becomes one document.
|
| 32 |
+
|
| 33 |
+
Each source exists because the others have a gap:
|
| 34 |
+
|
| 35 |
+
- **OpenAlex** is the widest net and picks up new work on its own, but lags
|
| 36 |
+
months behind publication.
|
| 37 |
+
- **Crossref** has authoritative author order for anything with a DOI. This
|
| 38 |
+
matters: the portfolio previously listed the wrong first author on two papers.
|
| 39 |
+
- **PMLR** proceedings (Canadian AI, ICML, AISTATS) are not in OpenAlex for a
|
| 40 |
+
long time, so configured volumes are scraped directly.
|
| 41 |
+
- **arXiv** catches preprints.
|
| 42 |
+
|
| 43 |
+
**When you publish somewhere new**, one of two things happens:
|
| 44 |
+
|
| 45 |
+
- Venue has a DOI or is on arXiv: nothing to do, it appears within a few weeks.
|
| 46 |
+
- Venue is a new PMLR volume: add the volume to `sources.json`:
|
| 47 |
+
|
| 48 |
+
```json
|
| 49 |
+
"pmlr": { "volumes": ["v318", "v3XX"] }
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
- Neither: add it to `manual_publications` in `sources.json`. Entries there are
|
| 53 |
+
always included.
|
| 54 |
+
|
| 55 |
+
Verify with `python download_publications.py --dry-run`, which lists everything
|
| 56 |
+
found and flags anything missing an abstract.
|
| 57 |
+
|
| 58 |
+
### LinkedIn
|
| 59 |
+
|
| 60 |
+
There is no API for reading your own profile. LinkedIn removed public profile
|
| 61 |
+
API access, and scraping it violates their terms and will get the account
|
| 62 |
+
flagged. The supported path is the periodic data export:
|
| 63 |
+
|
| 64 |
+
1. <https://www.linkedin.com/mypreferences/d/download-my-data>
|
| 65 |
+
2. Request the archive, wait for the email (usually under 24 hours)
|
| 66 |
+
3. Unzip into `docs/linkedin/`
|
| 67 |
+
4. `python refresh_index.py --skip-download` to rebuild
|
| 68 |
+
|
| 69 |
+
`build_index.py` already reads `Profile.csv`, `Positions.csv`, `Education.csv`,
|
| 70 |
+
`Skills.csv`, `Projects.csv`, and any `.txt` / `.md` in that directory.
|
| 71 |
+
|
| 72 |
+
Realistically the resume PDF already covers most of what LinkedIn holds, and it
|
| 73 |
+
updates automatically. Treat the export as an occasional top-up, not a routine.
|
| 74 |
+
|
| 75 |
+
---
|
| 76 |
+
|
| 77 |
+
## Safety guards
|
| 78 |
+
|
| 79 |
+
A scheduled job that quietly degrades the index is worse than no job. The
|
| 80 |
+
refresh aborts rather than rebuilding when:
|
| 81 |
+
|
| 82 |
+
- **any source fails** (`--allow-partial` overrides), so a rate-limited GitHub
|
| 83 |
+
run cannot bake missing repos into the index;
|
| 84 |
+
- **the corpus shrinks more than 25%** against the last recorded run
|
| 85 |
+
(`--max-shrink N` to adjust), which catches an API returning an empty list
|
| 86 |
+
instead of an error.
|
| 87 |
+
|
| 88 |
+
On abort, nothing is rebuilt and nothing is pushed. The deployed index keeps
|
| 89 |
+
serving the last good build. State lives in `.refresh_state.json`.
|
| 90 |
+
|
| 91 |
+
---
|
| 92 |
+
|
| 93 |
+
## Scheduling
|
| 94 |
+
|
| 95 |
+
Two options. Pick one.
|
| 96 |
+
|
| 97 |
+
### Option A: cron on julienlab (recommended)
|
| 98 |
+
|
| 99 |
+
Best fit given the box is already on and already holds backups. No secrets leave
|
| 100 |
+
the network.
|
| 101 |
+
|
| 102 |
+
```bash
|
| 103 |
+
# on julienlab, one time
|
| 104 |
+
git clone https://huggingface.co/spaces/DaJulster/julienserbanescu-rag
|
| 105 |
+
cd julienserbanescu-rag
|
| 106 |
+
python3 -m venv venv
|
| 107 |
+
./venv/bin/pip install -r requirements_hf.txt
|
| 108 |
+
|
| 109 |
+
# secrets
|
| 110 |
+
cat > .env <<'EOF'
|
| 111 |
+
COHEREAPIKEY=...
|
| 112 |
+
COHERE_API_KEY=...
|
| 113 |
+
OPENROUTER_API_KEY=...
|
| 114 |
+
GITHUB_TOKEN=...
|
| 115 |
+
EOF
|
| 116 |
+
chmod 600 .env
|
| 117 |
+
|
| 118 |
+
# let git push to HF without prompting
|
| 119 |
+
git remote set-url origin https://oauth:<HF_TOKEN>@huggingface.co/spaces/DaJulster/julienserbanescu-rag
|
| 120 |
+
|
| 121 |
+
# point sources.json at wherever the resume lives on this box,
|
| 122 |
+
# or drop the resume path entirely and let the PDF already in docs/pdfs stand
|
| 123 |
+
|
| 124 |
+
crontab -e
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
Add:
|
| 128 |
+
|
| 129 |
+
```
|
| 130 |
+
0 3 * * 1 /home/julien/julienserbanescu-rag/scheduled_refresh.sh
|
| 131 |
+
```
|
| 132 |
+
|
| 133 |
+
`scheduled_refresh.sh` handles the working directory, the venv, a `flock` so
|
| 134 |
+
overlapping runs cannot corrupt the index, and rotating logs in `logs/`.
|
| 135 |
+
|
| 136 |
+
Check a run with `tail -n 60 logs/refresh-*.log | less`.
|
| 137 |
+
|
| 138 |
+
### Option B: GitHub Actions
|
| 139 |
+
|
| 140 |
+
Use this if you would rather not depend on julienlab being up. It needs the repo
|
| 141 |
+
mirrored to GitHub, because Hugging Face does not run Actions.
|
| 142 |
+
|
| 143 |
+
```bash
|
| 144 |
+
git remote add github https://github.com/Julien-ser/julienserbanescu-rag.git
|
| 145 |
+
git push github main
|
| 146 |
+
```
|
| 147 |
+
|
| 148 |
+
Then add repo secrets under Settings → Secrets and variables → Actions:
|
| 149 |
+
|
| 150 |
+
| Secret | Purpose |
|
| 151 |
+
|---|---|
|
| 152 |
+
| `COHEREAPIKEY` | Embeddings for the index build |
|
| 153 |
+
| `OPENROUTER_API_KEY` | Response generation |
|
| 154 |
+
| `HF_TOKEN` | Write token so the workflow can push to the Space |
|
| 155 |
+
|
| 156 |
+
`.github/workflows/refresh-knowledge-base.yml` then runs every Monday 07:00 UTC,
|
| 157 |
+
and on demand via workflow_dispatch. `GITHUB_TOKEN` is provided by Actions
|
| 158 |
+
automatically, which also lifts the GitHub API limit from 60 to 5000 req/hour.
|
| 159 |
+
|
| 160 |
+
Note that this pushes from GitHub to Hugging Face, so GitHub becomes the source
|
| 161 |
+
of truth. Do not also commit directly to the HF remote or the two will diverge.
|
| 162 |
+
|
| 163 |
+
---
|
| 164 |
+
|
| 165 |
+
## Tokens
|
| 166 |
+
|
| 167 |
+
`pat.txt` holds an expired GitHub PAT (it returns 401). Nothing reads it by
|
| 168 |
+
preference; the scripts take `--token` or `GITHUB_TOKEN`. Put a fresh
|
| 169 |
+
fine-grained PAT with **public repo read** scope in `.env` as `GITHUB_TOKEN` and
|
| 170 |
+
delete `pat.txt`.
|
| 171 |
+
|
| 172 |
+
Without a working token the GitHub sources fall back to 60 requests/hour, which
|
| 173 |
+
is not enough for 70 repos, so those steps will fail and the refresh will
|
| 174 |
+
correctly abort instead of shrinking the index.
|
|
@@ -24,7 +24,7 @@ import cohere
|
|
| 24 |
from dotenv import load_dotenv
|
| 25 |
from langchain_community.docstore.document import Document
|
| 26 |
from langchain_community.docstore.in_memory import InMemoryDocstore
|
| 27 |
-
from
|
| 28 |
import requests
|
| 29 |
from bs4 import BeautifulSoup
|
| 30 |
import traceback
|
|
@@ -357,6 +357,112 @@ def load_readme_documents(readme_dir):
|
|
| 357 |
|
| 358 |
return documents
|
| 359 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 360 |
def main():
|
| 361 |
"""Main function to build the FAISS index"""
|
| 362 |
print("=== Building FAISS Index for Julien Serbanescu RAG System ===")
|
|
@@ -389,25 +495,11 @@ def main():
|
|
| 389 |
else:
|
| 390 |
print(" Warning: Could not scrape portfolio website content")
|
| 391 |
|
| 392 |
-
# 1b.
|
| 393 |
-
print("\n1b.
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
if linkedin_content:
|
| 398 |
-
doc = Document(
|
| 399 |
-
page_content=linkedin_content,
|
| 400 |
-
metadata={
|
| 401 |
-
'source': linkedin_url,
|
| 402 |
-
'type': 'linkedin_profile'
|
| 403 |
-
}
|
| 404 |
-
)
|
| 405 |
-
all_documents.append(doc)
|
| 406 |
-
print(f" Scraped {len(linkedin_content)} characters from LinkedIn profile")
|
| 407 |
-
else:
|
| 408 |
-
print(" Warning: Could not scrape LinkedIn profile (may require authentication)")
|
| 409 |
-
else:
|
| 410 |
-
print(" Skipping LinkedIn (set LINKEDIN_PROFILE_URL in .env to enable)")
|
| 411 |
|
| 412 |
# 2. Load PDF documents
|
| 413 |
print("\n2. Loading PDF documents...")
|
|
@@ -429,6 +521,18 @@ def main():
|
|
| 429 |
readme_docs = load_readme_documents(readme_dir)
|
| 430 |
all_documents.extend(readme_docs)
|
| 431 |
print(f" Loaded {len(readme_docs)} README documents")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 432 |
|
| 433 |
print(f"\nTotal documents collected: {len(all_documents)}")
|
| 434 |
|
|
@@ -436,8 +540,8 @@ def main():
|
|
| 436 |
print("No documents found! Please check your document directories.")
|
| 437 |
return
|
| 438 |
|
| 439 |
-
#
|
| 440 |
-
print("\
|
| 441 |
text_splitter = RecursiveCharacterTextSplitter(
|
| 442 |
chunk_size=1000,
|
| 443 |
chunk_overlap=200,
|
|
@@ -447,8 +551,8 @@ def main():
|
|
| 447 |
split_documents = text_splitter.split_documents(all_documents)
|
| 448 |
print(f" Created {len(split_documents)} document chunks")
|
| 449 |
|
| 450 |
-
#
|
| 451 |
-
print("\
|
| 452 |
texts = [doc.page_content for doc in split_documents]
|
| 453 |
embeddings = embedding_function.embed_documents(texts)
|
| 454 |
|
|
@@ -456,8 +560,8 @@ def main():
|
|
| 456 |
embedding_matrix = np.array(embeddings).astype('float32')
|
| 457 |
print(f" Created embedding matrix with shape: {embedding_matrix.shape}")
|
| 458 |
|
| 459 |
-
#
|
| 460 |
-
print("\
|
| 461 |
dimension = embedding_matrix.shape[1]
|
| 462 |
index = faiss.IndexFlatIP(dimension) # Inner product for cosine similarity
|
| 463 |
|
|
@@ -467,8 +571,8 @@ def main():
|
|
| 467 |
|
| 468 |
print(f" FAISS index built with {index.ntotal} vectors")
|
| 469 |
|
| 470 |
-
#
|
| 471 |
-
print("\
|
| 472 |
docstore = InMemoryDocstore()
|
| 473 |
index_to_docstore_id = {}
|
| 474 |
|
|
@@ -477,8 +581,8 @@ def main():
|
|
| 477 |
docstore.add({doc_id: doc})
|
| 478 |
index_to_docstore_id[i] = doc_id
|
| 479 |
|
| 480 |
-
#
|
| 481 |
-
print("\
|
| 482 |
|
| 483 |
# Save FAISS index
|
| 484 |
faiss_index_path = os.path.join(output_dir, "index.faiss")
|
|
@@ -498,8 +602,8 @@ def main():
|
|
| 498 |
pickle.dump(metadata_list, f)
|
| 499 |
print(f" Saved metadata to: {metadata_path}")
|
| 500 |
|
| 501 |
-
#
|
| 502 |
-
print("\
|
| 503 |
lookup_path = os.path.join(output_dir, "document_lookup.txt")
|
| 504 |
with open(lookup_path, 'w', encoding='utf-8') as f:
|
| 505 |
for i, doc in enumerate(split_documents):
|
|
|
|
| 24 |
from dotenv import load_dotenv
|
| 25 |
from langchain_community.docstore.document import Document
|
| 26 |
from langchain_community.docstore.in_memory import InMemoryDocstore
|
| 27 |
+
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
| 28 |
import requests
|
| 29 |
from bs4 import BeautifulSoup
|
| 30 |
import traceback
|
|
|
|
| 357 |
|
| 358 |
return documents
|
| 359 |
|
| 360 |
+
def load_github_activity(activity_dir):
|
| 361 |
+
"""Load GitHub issues and PRs downloaded by download_github_activity.py"""
|
| 362 |
+
documents = []
|
| 363 |
+
if not os.path.exists(activity_dir):
|
| 364 |
+
print(f" GitHub activity directory {activity_dir} not found, skipping...")
|
| 365 |
+
return documents
|
| 366 |
+
|
| 367 |
+
md_files = [f for f in os.listdir(activity_dir) if f.endswith('.md')]
|
| 368 |
+
for md_file in md_files:
|
| 369 |
+
try:
|
| 370 |
+
filepath = os.path.join(activity_dir, md_file)
|
| 371 |
+
with open(filepath, 'r', encoding='utf-8') as f:
|
| 372 |
+
content = f.read()
|
| 373 |
+
|
| 374 |
+
doc_type = 'github_pr' if '_PR_' in md_file else 'github_issue'
|
| 375 |
+
doc = Document(
|
| 376 |
+
page_content=content,
|
| 377 |
+
metadata={
|
| 378 |
+
'source': f"docs/github_activity/{md_file}",
|
| 379 |
+
'type': doc_type,
|
| 380 |
+
}
|
| 381 |
+
)
|
| 382 |
+
documents.append(doc)
|
| 383 |
+
except Exception as e:
|
| 384 |
+
print(f" Error loading {md_file}: {e}")
|
| 385 |
+
return documents
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
def load_publications(papers_dir):
|
| 389 |
+
"""Load publication documents written by download_publications.py.
|
| 390 |
+
|
| 391 |
+
Covers every source that script merges (OpenAlex, Crossref, PMLR, arXiv,
|
| 392 |
+
manual), not just arXiv.
|
| 393 |
+
"""
|
| 394 |
+
documents = []
|
| 395 |
+
if not os.path.exists(papers_dir):
|
| 396 |
+
print(f" Papers directory {papers_dir} not found, skipping...")
|
| 397 |
+
return documents
|
| 398 |
+
|
| 399 |
+
md_files = [f for f in os.listdir(papers_dir) if f.endswith('.md')]
|
| 400 |
+
for md_file in md_files:
|
| 401 |
+
try:
|
| 402 |
+
filepath = os.path.join(papers_dir, md_file)
|
| 403 |
+
with open(filepath, 'r', encoding='utf-8') as f:
|
| 404 |
+
content = f.read()
|
| 405 |
+
|
| 406 |
+
doc = Document(
|
| 407 |
+
page_content=content,
|
| 408 |
+
metadata={
|
| 409 |
+
'source': f"docs/papers/{md_file}",
|
| 410 |
+
'type': 'publication',
|
| 411 |
+
}
|
| 412 |
+
)
|
| 413 |
+
documents.append(doc)
|
| 414 |
+
except Exception as e:
|
| 415 |
+
print(f" Error loading {md_file}: {e}")
|
| 416 |
+
return documents
|
| 417 |
+
|
| 418 |
+
|
| 419 |
+
def load_linkedin_export(linkedin_dir):
|
| 420 |
+
"""Load LinkedIn data export files.
|
| 421 |
+
|
| 422 |
+
LinkedIn lets you download your data at: https://www.linkedin.com/mypreferences/d/download-my-data
|
| 423 |
+
Place the exported CSV/JSON files in docs/linkedin/.
|
| 424 |
+
Supports: Profile.csv, Positions.csv, Education.csv, Skills.csv, Projects.csv, and any .txt/.md files.
|
| 425 |
+
"""
|
| 426 |
+
documents = []
|
| 427 |
+
if not os.path.exists(linkedin_dir):
|
| 428 |
+
print(f" LinkedIn directory {linkedin_dir} not found, skipping...")
|
| 429 |
+
print(f" To add LinkedIn data: export from https://www.linkedin.com/mypreferences/d/download-my-data")
|
| 430 |
+
print(f" Then place files in {linkedin_dir}/")
|
| 431 |
+
return documents
|
| 432 |
+
|
| 433 |
+
import csv
|
| 434 |
+
for filename in os.listdir(linkedin_dir):
|
| 435 |
+
filepath = os.path.join(linkedin_dir, filename)
|
| 436 |
+
try:
|
| 437 |
+
if filename.endswith('.csv'):
|
| 438 |
+
with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
|
| 439 |
+
reader = csv.DictReader(f)
|
| 440 |
+
rows = list(reader)
|
| 441 |
+
if not rows:
|
| 442 |
+
continue
|
| 443 |
+
content = f"# LinkedIn Data: {filename}\n\n"
|
| 444 |
+
for row in rows:
|
| 445 |
+
content += "\n".join(f"**{k}:** {v}" for k, v in row.items() if v) + "\n---\n"
|
| 446 |
+
doc = Document(
|
| 447 |
+
page_content=content,
|
| 448 |
+
metadata={'source': f"docs/linkedin/{filename}", 'type': 'linkedin_export'}
|
| 449 |
+
)
|
| 450 |
+
documents.append(doc)
|
| 451 |
+
elif filename.endswith(('.txt', '.md')):
|
| 452 |
+
with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
|
| 453 |
+
content = f.read()
|
| 454 |
+
if content.strip():
|
| 455 |
+
doc = Document(
|
| 456 |
+
page_content=content,
|
| 457 |
+
metadata={'source': f"docs/linkedin/{filename}", 'type': 'linkedin_export'}
|
| 458 |
+
)
|
| 459 |
+
documents.append(doc)
|
| 460 |
+
except Exception as e:
|
| 461 |
+
print(f" Error loading {filename}: {e}")
|
| 462 |
+
|
| 463 |
+
return documents
|
| 464 |
+
|
| 465 |
+
|
| 466 |
def main():
|
| 467 |
"""Main function to build the FAISS index"""
|
| 468 |
print("=== Building FAISS Index for Julien Serbanescu RAG System ===")
|
|
|
|
| 495 |
else:
|
| 496 |
print(" Warning: Could not scrape portfolio website content")
|
| 497 |
|
| 498 |
+
# 1b. Load LinkedIn export data
|
| 499 |
+
print("\n1b. Loading LinkedIn export data...")
|
| 500 |
+
linkedin_docs = load_linkedin_export("docs/linkedin")
|
| 501 |
+
all_documents.extend(linkedin_docs)
|
| 502 |
+
print(f" Loaded {len(linkedin_docs)} LinkedIn documents")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 503 |
|
| 504 |
# 2. Load PDF documents
|
| 505 |
print("\n2. Loading PDF documents...")
|
|
|
|
| 521 |
readme_docs = load_readme_documents(readme_dir)
|
| 522 |
all_documents.extend(readme_docs)
|
| 523 |
print(f" Loaded {len(readme_docs)} README documents")
|
| 524 |
+
|
| 525 |
+
# 5. Load GitHub issues & PRs
|
| 526 |
+
print("\n5. Loading GitHub issues & PRs...")
|
| 527 |
+
activity_docs = load_github_activity("docs/github_activity")
|
| 528 |
+
all_documents.extend(activity_docs)
|
| 529 |
+
print(f" Loaded {len(activity_docs)} GitHub issues/PRs")
|
| 530 |
+
|
| 531 |
+
# 6. Load publications (all sources, not just arXiv)
|
| 532 |
+
print("\n6. Loading publications...")
|
| 533 |
+
paper_docs = load_publications("docs/papers")
|
| 534 |
+
all_documents.extend(paper_docs)
|
| 535 |
+
print(f" Loaded {len(paper_docs)} publications")
|
| 536 |
|
| 537 |
print(f"\nTotal documents collected: {len(all_documents)}")
|
| 538 |
|
|
|
|
| 540 |
print("No documents found! Please check your document directories.")
|
| 541 |
return
|
| 542 |
|
| 543 |
+
# 7. Split documents into chunks
|
| 544 |
+
print("\n7. Splitting documents into chunks...")
|
| 545 |
text_splitter = RecursiveCharacterTextSplitter(
|
| 546 |
chunk_size=1000,
|
| 547 |
chunk_overlap=200,
|
|
|
|
| 551 |
split_documents = text_splitter.split_documents(all_documents)
|
| 552 |
print(f" Created {len(split_documents)} document chunks")
|
| 553 |
|
| 554 |
+
# 8. Create embeddings
|
| 555 |
+
print("\n8. Creating embeddings...")
|
| 556 |
texts = [doc.page_content for doc in split_documents]
|
| 557 |
embeddings = embedding_function.embed_documents(texts)
|
| 558 |
|
|
|
|
| 560 |
embedding_matrix = np.array(embeddings).astype('float32')
|
| 561 |
print(f" Created embedding matrix with shape: {embedding_matrix.shape}")
|
| 562 |
|
| 563 |
+
# 9. Build FAISS index
|
| 564 |
+
print("\n9. Building FAISS index...")
|
| 565 |
dimension = embedding_matrix.shape[1]
|
| 566 |
index = faiss.IndexFlatIP(dimension) # Inner product for cosine similarity
|
| 567 |
|
|
|
|
| 571 |
|
| 572 |
print(f" FAISS index built with {index.ntotal} vectors")
|
| 573 |
|
| 574 |
+
# 10. Create docstore and mapping
|
| 575 |
+
print("\n10. Creating docstore and mapping...")
|
| 576 |
docstore = InMemoryDocstore()
|
| 577 |
index_to_docstore_id = {}
|
| 578 |
|
|
|
|
| 581 |
docstore.add({doc_id: doc})
|
| 582 |
index_to_docstore_id[i] = doc_id
|
| 583 |
|
| 584 |
+
# 11. Save everything
|
| 585 |
+
print("\n11. Saving index files...")
|
| 586 |
|
| 587 |
# Save FAISS index
|
| 588 |
faiss_index_path = os.path.join(output_dir, "index.faiss")
|
|
|
|
| 602 |
pickle.dump(metadata_list, f)
|
| 603 |
print(f" Saved metadata to: {metadata_path}")
|
| 604 |
|
| 605 |
+
# 12. Create human-readable document lookup
|
| 606 |
+
print("\n12. Creating document lookup file...")
|
| 607 |
lookup_path = os.path.join(output_dir, "document_lookup.txt")
|
| 608 |
with open(lookup_path, 'w', encoding='utf-8') as f:
|
| 609 |
for i, doc in enumerate(split_documents):
|
|
The diff for this file is too large to render.
See raw diff
|
|
|
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:c939addb445c9cb242b77a382f005eb0bcaf042dd36ea251e20752ea421ef36d
|
| 3 |
+
size 3477549
|
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d0a6ff76940741bcfc061f6e2a926603fe29b7c680ad371a6f90d91535aa6eb9
|
| 3 |
+
size 776917
|
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:e37da3ecfb523e1d4f7e6f5b247a927e6fa671e4fe14b1455bf1d5f251b58080
|
| 3 |
+
size 17099
|
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# UnAnswGen: A Systematic Approach for Generating Unanswerable Questions in Machine Reading Comprehension
|
| 2 |
+
|
| 3 |
+
**Authors:** Hadiseh Moradisani, Fattane Zarrinkalam, Julien Serbanescu, Zeinab Noorian
|
| 4 |
+
**Venue:** Proceedings of the 2024 Annual International ACM SIGIR Conference on Research and Development in Information Retrieval in the Asia Pacific Region
|
| 5 |
+
**Year:** 2024
|
| 6 |
+
**DOI:** 10.1145/3673791.3698413
|
| 7 |
+
**URL:** https://doi.org/10.1145/3673791.3698413
|
| 8 |
+
**Indexed from:** openalex, crossref
|
| 9 |
+
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
## Abstract
|
| 13 |
+
|
| 14 |
+
This paper introduces a configurable software workflow to automatically generate and publicly share a dataset of multi-labeled unanswerable questions for Machine Reading Comprehension (MRC). Unlike existing datasets like SQuAD2.0, which do not account for the reasons behind question unanswerability, our method fills a critical gap by systematically transforming answerable questions into their unanswerable counterparts across various linguistic dimensions including entity swap, number swap, negation, antonym, mutual exclusion, and no information. These candidate unanswerable questions are evaluated using advanced MRC models to ensure their context-based unanswerability, with the final selection based on a majority consensus mechanism. Our approach addresses the scarcity of multi-labeled datasets like SQuAD2-CR, enabling comprehensive evaluation of MRC systems' ability to handle unanswerable queries and facilitating the exploration of solutions such as query reformulation. The resulting UnAnswGen dataset and associated software workflow are made publicly available to advance research in machine reading comprehension, offering researchers a standardized toolset for evaluating and enhancing MRC systems' robustness and performance.
|
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# FalseCoTQA: Adversarial Multi-Hop QA via Knowledge-Grounded False Chains of Thought
|
| 2 |
+
|
| 3 |
+
**Authors:** Julien Serbanescu, Mahdiyar Ali Akbar Alavi, Faezeh Ensan, Fattane Zarrinkalam
|
| 4 |
+
**Venue:** Proceedings of the 2025 Annual International ACM SIGIR Conference on Research and Development in Information Retrieval in the Asia Pacific Region
|
| 5 |
+
**Year:** 2025
|
| 6 |
+
**DOI:** 10.1145/3767695.3769494
|
| 7 |
+
**URL:** https://doi.org/10.1145/3767695.3769494
|
| 8 |
+
**Indexed from:** openalex, crossref
|
| 9 |
+
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
## Abstract
|
| 13 |
+
|
| 14 |
+
Multi-hop question answering (QA) models excel at decomposing complex queries into sequential reasoning steps, yet they remain vulnerable to subtly flawed inference chains that appear reasonable but are factually incorrect. To quantify and address this weakness, we present FalseCoTQA, an adversarial benchmark that injects knowledge-grounded false reasoning into retrieval-augmented contexts. Unlike prior methods that merely tweak surface text, FalseCoTQA leverages a domain-agnostic knowledge graph to systematically replace entities to construct semantically coherent yet incorrect chains of thought on top of standard multi-hop datasets (HotpotQA and MuSiQue). By evaluating state-of-the-art language models on this benchmark, we observe dramatic drops in answer accuracy, highlighting their tendency to follow deceptive reasoning without verifying factual consistency. We expect the proposed benchmark to contribute to the evaluation and improvement of the robustness and reliability of language models in multi-hop question answering.
|
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Uncovering the Persuasive Fingerprint of LLMs in Jailbreaking Attacks
|
| 2 |
+
|
| 3 |
+
**Authors:** Havva Alizadeh Noughabi, Julien Serbanescu, Fattane Zarrinkalam, Ali Dehghantanha
|
| 4 |
+
**Venue:** Proceedings of the 34th ACM International Conference on Information and Knowledge Management
|
| 5 |
+
**Year:** 2025
|
| 6 |
+
**DOI:** 10.1145/3746252.3760929
|
| 7 |
+
**arXiv ID:** 2510.21983v1
|
| 8 |
+
**URL:** https://doi.org/10.1145/3746252.3760929
|
| 9 |
+
**PDF:** https://arxiv.org/pdf/2510.21983v1
|
| 10 |
+
**Indexed from:** openalex, crossref, arxiv
|
| 11 |
+
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
## Abstract
|
| 15 |
+
|
| 16 |
+
Despite recent advances, Large Language Models (LLMs) remain vulnerable to jailbreak attacks that bypass alignment safeguards and elicit harmful outputs. While prior research has proposed various attack strategies differing in human readability and transferability, little attention has been paid to the linguistic and psychological mechanisms that may influence a model's susceptibility to such attacks. In this paper, we examine an interdisciplinary line of research that leverages foundational theories of persuasion from the social sciences to craft adversarial prompts capable of circumventing alignment constraints in LLMs. Drawing on well-established persuasive strategies, we hypothesize that LLMs, having been trained on large-scale human-generated text, may respond more compliantly to prompts with persuasive structures. Furthermore, we investigate whether LLMs themselves exhibit distinct persuasive fingerprints that emerge in their jailbreak responses. Empirical evaluations across multiple aligned LLMs reveal that persuasion-aware prompts significantly bypass safeguards, demonstrating their potential to induce jailbreak behaviors. This work underscores the importance of cross-disciplinary insight in addressing the evolving challenges of LLM safety. The code and data are available. https://github.com/CyberScienceLab/Our-Papers/tree/main/PersuasiveJailbreaking/.
|
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Cause-Conditioned Multi-Task Learning for Answerable Question Suggestion in MRC
|
| 2 |
+
|
| 3 |
+
**Authors:** Hadiseh Moradisani, Fattane Zarrinkalam, Julien Serbanescu, Zeinab Noorian
|
| 4 |
+
**Venue:** Proceedings of the The 39th Canadian Conference on Artificial Intelligence
|
| 5 |
+
**Year:** 2026
|
| 6 |
+
**URL:** https://proceedings.mlr.press/v318/moradisani26a.html
|
| 7 |
+
**Indexed from:** pmlr:v318
|
| 8 |
+
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
## Abstract
|
| 12 |
+
|
| 13 |
+
Machine Reading Comprehension (MRC) systems struggle when user questions are unanswerable given the passage: most simply output “no answer”, leaving users without guidance on how to recover useful information. We introduce a \textit{cause-conditioned multi-task learning (MTL)} framework that turns failure into follow-up by jointly (1) classifying an input as answerable or as one of six fine-grained unanswerability causes (Entity Swap, Number Swap, Antonym, Negation, Mutual Exclusion, No Information), and (2) generating a revised, context-grounded answerable question conditioned on the predicted cause label and an extracted guidance sentence. Using an ensemble of strong readers plus LLMs-as-judges, we apply majority voting to test whether rewrites become answerable. A human study further assesses fluency, relevance, and usefulness. Our cause-conditioning MTL framework yields better recovery from unanswerable inputs and earns strong human ratings, advancing user-supportive, failure-aware MRC.
|
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# From Hints to Answers: Uncertainty-Aware LLM-Guided Retrieval for Multi-Hop Question Answering
|
| 2 |
+
|
| 3 |
+
**Authors:** Mahdiyar Ali Akbar Alavi, Bita Azad, Julien Serbanescu, Fattane Zarrinkalam, Faezeh Ensan
|
| 4 |
+
**Venue:** Proceedings of the The 39th Canadian Conference on Artificial Intelligence
|
| 5 |
+
**Year:** 2026
|
| 6 |
+
**URL:** https://proceedings.mlr.press/v318/alavi26a.html
|
| 7 |
+
**Indexed from:** pmlr:v318
|
| 8 |
+
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
## Abstract
|
| 12 |
+
|
| 13 |
+
We propose Generate-Retrieve-Generate (GReG), a training-free pipeline for multi-hop open-domain question answering. GReG uses a strong LLM to generate multiple long-form “hints” that expose implicit intermediate facts in the question, and uses the selected hint as a retrieval query for gathering supporting evidence. To choose among candidate hints, we introduce an uncertainty-aware selection method, which favors lower-entropy generations. By improving retrieval quality, GReG enables a smaller, cost-efficient answer generator to answer complex multi-hop questions more accurately. Experiments on HotpotQA and 2WikiMultihopQA show that GReG achieves state-of-the-art performance under identical retrieval and generation settings.
|
|
@@ -1,15 +0,0 @@
|
|
| 1 |
-
# Uncovering the Persuasive Fingerprint of LLMs in Jailbreaking Attacks
|
| 2 |
-
|
| 3 |
-
**Authors:** Havva Alizadeh Noughabi, Julien Serbanescu, Fattane Zarrinkalam, Ali Dehghantanha
|
| 4 |
-
**arXiv ID:** 2510.21983v1
|
| 5 |
-
**Published:** 2025-10-24
|
| 6 |
-
**Updated:** 2025-10-24
|
| 7 |
-
**Categories:** cs.CL, cs.AI
|
| 8 |
-
**PDF:** https://arxiv.org/pdf/2510.21983v1
|
| 9 |
-
**Abstract URL:** https://arxiv.org/abs/2510.21983v1
|
| 10 |
-
|
| 11 |
-
---
|
| 12 |
-
|
| 13 |
-
## Abstract
|
| 14 |
-
|
| 15 |
-
Despite recent advances, Large Language Models remain vulnerable to jailbreak attacks that bypass alignment safeguards and elicit harmful outputs. While prior research has proposed various attack strategies differing in human readability and transferability, little attention has been paid to the linguistic and psychological mechanisms that may influence a model's susceptibility to such attacks. In this paper, we examine an interdisciplinary line of research that leverages foundational theories of persuasion from the social sciences to craft adversarial prompts capable of circumventing alignment constraints in LLMs. Drawing on well-established persuasive strategies, we hypothesize that LLMs, having been trained on large-scale human-generated text, may respond more compliantly to prompts with persuasive structures. Furthermore, we investigate whether LLMs themselves exhibit distinct persuasive fingerprints that emerge in their jailbreak responses. Empirical evaluations across multiple aligned LLMs reveal that persuasion-aware prompts significantly bypass safeguards, demonstrating their potential to induce jailbreak behaviors. This work underscores the importance of cross-disciplinary insight in addressing the evolving challenges of LLM safety. The code and data are available.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:7796f0cfee53567c2e5ea51072794c58cb36a6eec1e2fd82aa84f4aa997e9362
|
| 3 |
+
size 234131
|
|
@@ -0,0 +1,525 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Multi-source publication downloader for the Julien Serbanescu RAG system.
|
| 4 |
+
|
| 5 |
+
Supersedes download_papers.py, which only knew about arXiv and therefore missed
|
| 6 |
+
every paper published in a venue that does not mirror to arXiv (ACM DL, PMLR).
|
| 7 |
+
|
| 8 |
+
Sources, in the order they are merged:
|
| 9 |
+
|
| 10 |
+
openalex Broad cross-venue coverage with abstracts. Picks up new papers
|
| 11 |
+
automatically a few weeks after publication. Primary source.
|
| 12 |
+
crossref Authoritative author order and venue for anything with a DOI
|
| 13 |
+
(ACM, IEEE, Springer). Crossref has no abstracts for most ACM
|
| 14 |
+
records, so it mainly corrects and enriches.
|
| 15 |
+
pmlr Proceedings volumes (Canadian AI, ICML, AISTATS...). PMLR papers
|
| 16 |
+
are not indexed by OpenAlex for months, so they are scraped from
|
| 17 |
+
the volume index pages listed in sources.json.
|
| 18 |
+
arxiv Preprints.
|
| 19 |
+
manual Escape hatch in sources.json for anything all of the above miss.
|
| 20 |
+
|
| 21 |
+
Records are keyed on a normalised title so the same paper arriving from several
|
| 22 |
+
sources merges into one document instead of duplicating. Later sources fill in
|
| 23 |
+
blank fields but never overwrite a field an earlier source already populated,
|
| 24 |
+
except where a source is explicitly more authoritative (Crossref for authors).
|
| 25 |
+
|
| 26 |
+
Usage:
|
| 27 |
+
python download_publications.py
|
| 28 |
+
python download_publications.py --source openalex --source pmlr
|
| 29 |
+
python download_publications.py --dry-run
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
import argparse
|
| 33 |
+
import html
|
| 34 |
+
import json
|
| 35 |
+
import os
|
| 36 |
+
import re
|
| 37 |
+
import sys
|
| 38 |
+
import time
|
| 39 |
+
import urllib.parse
|
| 40 |
+
import urllib.request
|
| 41 |
+
import xml.etree.ElementTree as ET
|
| 42 |
+
from pathlib import Path
|
| 43 |
+
|
| 44 |
+
DEFAULT_CONFIG = "sources.json"
|
| 45 |
+
DEFAULT_OUTPUT = "docs/papers"
|
| 46 |
+
USER_AGENT = "julienserbanescu-rag/1.0 (publication indexer)"
|
| 47 |
+
ALL_SOURCES = ["openalex", "crossref", "pmlr", "arxiv", "manual"]
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# ----------------------------------------------------------------------------
|
| 51 |
+
# helpers
|
| 52 |
+
# ----------------------------------------------------------------------------
|
| 53 |
+
|
| 54 |
+
def load_config(path):
|
| 55 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 56 |
+
return json.load(f)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def http_get(url, timeout=45, retries=3, backoff=2.0):
|
| 60 |
+
"""GET with retries. Returns bytes, or None if every attempt failed."""
|
| 61 |
+
for attempt in range(1, retries + 1):
|
| 62 |
+
try:
|
| 63 |
+
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
| 64 |
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
| 65 |
+
return resp.read()
|
| 66 |
+
except Exception as e:
|
| 67 |
+
if attempt == retries:
|
| 68 |
+
print(f" request failed after {retries} attempts: {e}")
|
| 69 |
+
return None
|
| 70 |
+
wait = backoff ** attempt
|
| 71 |
+
print(f" attempt {attempt} failed ({e}); retrying in {wait:.0f}s")
|
| 72 |
+
time.sleep(wait)
|
| 73 |
+
return None
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def norm_title(title):
|
| 77 |
+
"""Normalise a title into a dedupe key: lowercase alphanumerics only."""
|
| 78 |
+
return re.sub(r"[^a-z0-9]", "", (title or "").lower())
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def clean_filename(text):
|
| 82 |
+
cleaned = re.sub(r"[^\w\-_.]", "_", text)
|
| 83 |
+
cleaned = re.sub(r"_+", "_", cleaned)
|
| 84 |
+
return cleaned.strip("_")[:120]
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def strip_tags(s):
|
| 88 |
+
return html.unescape(re.sub(r"<[^>]+>", "", s or "")).replace("\xa0", " ").strip()
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def is_our_author(names, cfg):
|
| 92 |
+
"""True if this author list contains our author.
|
| 93 |
+
|
| 94 |
+
Both the family name AND a known given name must appear on the same entry.
|
| 95 |
+
Serbanescu is a common Romanian surname, and a family-name-only match pulls
|
| 96 |
+
in unrelated researchers (Crossref returns several).
|
| 97 |
+
"""
|
| 98 |
+
family = cfg["author"]["family_name"].lower()
|
| 99 |
+
givens = [g.lower() for g in cfg["author"]["given_names"]]
|
| 100 |
+
for n in names:
|
| 101 |
+
low = (n or "").lower()
|
| 102 |
+
if family in low and any(g in low for g in givens):
|
| 103 |
+
return True
|
| 104 |
+
return False
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def merge_record(store, rec):
|
| 108 |
+
"""Merge a record into the store, keyed on normalised title."""
|
| 109 |
+
key = norm_title(rec.get("title"))
|
| 110 |
+
if not key:
|
| 111 |
+
return
|
| 112 |
+
if key not in store:
|
| 113 |
+
store[key] = rec
|
| 114 |
+
return
|
| 115 |
+
existing = store[key]
|
| 116 |
+
for field, value in rec.items():
|
| 117 |
+
if field == "sources":
|
| 118 |
+
continue
|
| 119 |
+
if value and not existing.get(field):
|
| 120 |
+
existing[field] = value
|
| 121 |
+
existing.setdefault("sources", [])
|
| 122 |
+
for s in rec.get("sources", []):
|
| 123 |
+
if s not in existing["sources"]:
|
| 124 |
+
existing["sources"].append(s)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
# ----------------------------------------------------------------------------
|
| 128 |
+
# sources
|
| 129 |
+
# ----------------------------------------------------------------------------
|
| 130 |
+
|
| 131 |
+
def fetch_openalex(cfg):
|
| 132 |
+
"""OpenAlex: broad coverage, includes abstracts as an inverted index."""
|
| 133 |
+
print("\n [openalex] querying...")
|
| 134 |
+
name = cfg["author"]["full_name"]
|
| 135 |
+
email = cfg["author"].get("contact_email") or ""
|
| 136 |
+
params = urllib.parse.urlencode({
|
| 137 |
+
"filter": f"raw_author_name.search:{name}",
|
| 138 |
+
"per-page": 100,
|
| 139 |
+
"mailto": email,
|
| 140 |
+
})
|
| 141 |
+
raw = http_get(f"https://api.openalex.org/works?{params}")
|
| 142 |
+
if not raw:
|
| 143 |
+
return []
|
| 144 |
+
|
| 145 |
+
try:
|
| 146 |
+
data = json.loads(raw)
|
| 147 |
+
except Exception as e:
|
| 148 |
+
print(f" could not parse OpenAlex response: {e}")
|
| 149 |
+
return []
|
| 150 |
+
|
| 151 |
+
out = []
|
| 152 |
+
for w in data.get("results", []):
|
| 153 |
+
authors = [
|
| 154 |
+
(a.get("author") or {}).get("display_name", "")
|
| 155 |
+
for a in w.get("authorships", [])
|
| 156 |
+
]
|
| 157 |
+
if not is_our_author(authors, cfg):
|
| 158 |
+
continue
|
| 159 |
+
|
| 160 |
+
# OpenAlex ships abstracts as {word: [positions]}; rebuild the text.
|
| 161 |
+
abstract = ""
|
| 162 |
+
inv = w.get("abstract_inverted_index")
|
| 163 |
+
if inv:
|
| 164 |
+
positions = {}
|
| 165 |
+
for word, idxs in inv.items():
|
| 166 |
+
for i in idxs:
|
| 167 |
+
positions[i] = word
|
| 168 |
+
abstract = " ".join(positions[i] for i in sorted(positions))
|
| 169 |
+
|
| 170 |
+
loc = (w.get("primary_location") or {})
|
| 171 |
+
venue = (loc.get("source") or {}).get("display_name")
|
| 172 |
+
|
| 173 |
+
out.append({
|
| 174 |
+
"title": strip_tags(w.get("title")),
|
| 175 |
+
"authors": [a for a in authors if a],
|
| 176 |
+
"abstract": abstract,
|
| 177 |
+
"venue": venue,
|
| 178 |
+
"year": w.get("publication_year"),
|
| 179 |
+
"doi": (w.get("doi") or "").replace("https://doi.org/", "") or None,
|
| 180 |
+
"url": loc.get("landing_page_url") or w.get("doi"),
|
| 181 |
+
"sources": ["openalex"],
|
| 182 |
+
})
|
| 183 |
+
|
| 184 |
+
print(f" matched {len(out)} publication(s)")
|
| 185 |
+
return out
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def fetch_crossref(cfg):
|
| 189 |
+
"""Crossref: authoritative author order and venue for DOI-bearing papers."""
|
| 190 |
+
print("\n [crossref] querying...")
|
| 191 |
+
name = cfg["author"]["full_name"]
|
| 192 |
+
email = cfg["author"].get("contact_email") or ""
|
| 193 |
+
params = urllib.parse.urlencode({
|
| 194 |
+
"query.author": name,
|
| 195 |
+
"rows": 100,
|
| 196 |
+
"mailto": email,
|
| 197 |
+
})
|
| 198 |
+
raw = http_get(f"https://api.crossref.org/works?{params}")
|
| 199 |
+
if not raw:
|
| 200 |
+
return []
|
| 201 |
+
|
| 202 |
+
try:
|
| 203 |
+
items = json.loads(raw)["message"]["items"]
|
| 204 |
+
except Exception as e:
|
| 205 |
+
print(f" could not parse Crossref response: {e}")
|
| 206 |
+
return []
|
| 207 |
+
|
| 208 |
+
out = []
|
| 209 |
+
for w in items:
|
| 210 |
+
authors = [
|
| 211 |
+
f"{a.get('given','')} {a.get('family','')}".strip()
|
| 212 |
+
for a in (w.get("author") or [])
|
| 213 |
+
]
|
| 214 |
+
if not is_our_author(authors, cfg):
|
| 215 |
+
continue
|
| 216 |
+
|
| 217 |
+
container = w.get("container-title") or []
|
| 218 |
+
issued = (w.get("issued") or {}).get("date-parts") or [[None]]
|
| 219 |
+
|
| 220 |
+
out.append({
|
| 221 |
+
"title": strip_tags((w.get("title") or [""])[0]),
|
| 222 |
+
# Crossref preserves published author order; treat it as canonical.
|
| 223 |
+
"authors_authoritative": authors,
|
| 224 |
+
"authors": authors,
|
| 225 |
+
"abstract": strip_tags(w.get("abstract")) or "",
|
| 226 |
+
"venue": container[0] if container else None,
|
| 227 |
+
"year": issued[0][0],
|
| 228 |
+
"doi": w.get("DOI"),
|
| 229 |
+
"url": f"https://doi.org/{w['DOI']}" if w.get("DOI") else None,
|
| 230 |
+
"sources": ["crossref"],
|
| 231 |
+
})
|
| 232 |
+
|
| 233 |
+
print(f" matched {len(out)} publication(s)")
|
| 234 |
+
return out
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def fetch_pmlr(cfg):
|
| 238 |
+
"""Scrape configured PMLR volume index pages, then each paper's abs page.
|
| 239 |
+
|
| 240 |
+
PMLR proceedings take months to reach OpenAlex, so without this the most
|
| 241 |
+
recent conference papers are invisible to the RAG.
|
| 242 |
+
"""
|
| 243 |
+
volumes = (cfg.get("pmlr") or {}).get("volumes") or []
|
| 244 |
+
if not volumes:
|
| 245 |
+
print("\n [pmlr] no volumes configured, skipping")
|
| 246 |
+
return []
|
| 247 |
+
|
| 248 |
+
out = []
|
| 249 |
+
for vol in volumes:
|
| 250 |
+
print(f"\n [pmlr] scanning volume {vol}...")
|
| 251 |
+
raw = http_get(f"https://proceedings.mlr.press/{vol}/")
|
| 252 |
+
if not raw:
|
| 253 |
+
continue
|
| 254 |
+
page = raw.decode("utf-8", errors="replace")
|
| 255 |
+
|
| 256 |
+
# Each entry is a <div class="paper"> block; split rather than regex the
|
| 257 |
+
# whole block, since the markup nests inconsistently.
|
| 258 |
+
blocks = page.split('<div class="paper">')[1:]
|
| 259 |
+
matched = 0
|
| 260 |
+
for block in blocks:
|
| 261 |
+
authors_m = re.search(r'<span class="authors">(.*?)</span>', block, re.S)
|
| 262 |
+
if not authors_m:
|
| 263 |
+
continue
|
| 264 |
+
authors = [strip_tags(a) for a in authors_m.group(1).split(" ,")]
|
| 265 |
+
if len(authors) == 1:
|
| 266 |
+
authors = [strip_tags(a) for a in re.split(r",\s*", authors_m.group(1))]
|
| 267 |
+
authors = [a for a in (strip_tags(x) for x in authors) if a]
|
| 268 |
+
if not is_our_author(authors, cfg):
|
| 269 |
+
continue
|
| 270 |
+
|
| 271 |
+
title_m = re.search(r'<p class="title">(.*?)</p>', block, re.S)
|
| 272 |
+
info_m = re.search(r'<span class="info">(.*?)</span>', block, re.S)
|
| 273 |
+
abs_m = re.search(r'href="(https://proceedings\.mlr\.press/[^"]+\.html)"', block)
|
| 274 |
+
|
| 275 |
+
title = strip_tags(title_m.group(1)) if title_m else ""
|
| 276 |
+
info = strip_tags(info_m.group(1)) if info_m else ""
|
| 277 |
+
abs_url = abs_m.group(1) if abs_m else None
|
| 278 |
+
|
| 279 |
+
year = None
|
| 280 |
+
ym = re.search(r"(20\d{2})", info)
|
| 281 |
+
if ym:
|
| 282 |
+
year = int(ym.group(1))
|
| 283 |
+
if not year:
|
| 284 |
+
vm = re.search(r"(\d{2})[a-z]$", (abs_url or "").rsplit("/", 1)[-1].split(".")[0])
|
| 285 |
+
if vm:
|
| 286 |
+
year = 2000 + int(vm.group(1))
|
| 287 |
+
|
| 288 |
+
abstract = ""
|
| 289 |
+
if abs_url:
|
| 290 |
+
time.sleep(1) # be polite to PMLR
|
| 291 |
+
abs_raw = http_get(abs_url)
|
| 292 |
+
if abs_raw:
|
| 293 |
+
abs_page = abs_raw.decode("utf-8", errors="replace")
|
| 294 |
+
am = re.search(
|
| 295 |
+
r'<div[^>]*class="abstract"[^>]*>(.*?)</div>', abs_page, re.S
|
| 296 |
+
)
|
| 297 |
+
if am:
|
| 298 |
+
abstract = strip_tags(am.group(1))
|
| 299 |
+
|
| 300 |
+
venue = re.sub(r",\s*PMLR.*$", "", info).strip() or f"PMLR {vol}"
|
| 301 |
+
|
| 302 |
+
out.append({
|
| 303 |
+
"title": title,
|
| 304 |
+
"authors": authors,
|
| 305 |
+
"abstract": abstract,
|
| 306 |
+
"venue": venue,
|
| 307 |
+
"year": year,
|
| 308 |
+
"url": abs_url,
|
| 309 |
+
"sources": [f"pmlr:{vol}"],
|
| 310 |
+
})
|
| 311 |
+
matched += 1
|
| 312 |
+
|
| 313 |
+
print(f" matched {matched} publication(s) in {vol}")
|
| 314 |
+
|
| 315 |
+
return out
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
def fetch_arxiv(cfg):
|
| 319 |
+
"""arXiv preprints by author."""
|
| 320 |
+
print("\n [arxiv] querying...")
|
| 321 |
+
name = cfg["author"]["full_name"]
|
| 322 |
+
params = urllib.parse.urlencode({
|
| 323 |
+
"search_query": f'au:"{name}"',
|
| 324 |
+
"start": 0,
|
| 325 |
+
"max_results": 100,
|
| 326 |
+
"sortBy": "submittedDate",
|
| 327 |
+
"sortOrder": "descending",
|
| 328 |
+
})
|
| 329 |
+
raw = http_get(f"http://export.arxiv.org/api/query?{params}")
|
| 330 |
+
if not raw:
|
| 331 |
+
return []
|
| 332 |
+
|
| 333 |
+
ns = {"atom": "http://www.w3.org/2005/Atom"}
|
| 334 |
+
try:
|
| 335 |
+
root = ET.fromstring(raw)
|
| 336 |
+
except Exception as e:
|
| 337 |
+
print(f" could not parse arXiv response: {e}")
|
| 338 |
+
return []
|
| 339 |
+
|
| 340 |
+
out = []
|
| 341 |
+
for entry in root.findall("atom:entry", ns):
|
| 342 |
+
authors = [a.findtext("atom:name", "", ns) for a in entry.findall("atom:author", ns)]
|
| 343 |
+
if not is_our_author(authors, cfg):
|
| 344 |
+
continue
|
| 345 |
+
|
| 346 |
+
arxiv_id = ""
|
| 347 |
+
pdf_url = ""
|
| 348 |
+
for link in entry.findall("atom:link", ns):
|
| 349 |
+
href = link.get("href", "")
|
| 350 |
+
if link.get("title") == "pdf":
|
| 351 |
+
pdf_url = href
|
| 352 |
+
elif "/abs/" in href:
|
| 353 |
+
arxiv_id = href.split("/abs/")[-1]
|
| 354 |
+
|
| 355 |
+
out.append({
|
| 356 |
+
"title": " ".join((entry.findtext("atom:title", "", ns) or "").split()),
|
| 357 |
+
"authors": authors,
|
| 358 |
+
"abstract": " ".join((entry.findtext("atom:summary", "", ns) or "").split()),
|
| 359 |
+
"venue": "arXiv preprint",
|
| 360 |
+
"year": int((entry.findtext("atom:published", "", ns) or "0000")[:4] or 0) or None,
|
| 361 |
+
"arxiv_id": arxiv_id,
|
| 362 |
+
"pdf_url": pdf_url,
|
| 363 |
+
"url": f"https://arxiv.org/abs/{arxiv_id}" if arxiv_id else None,
|
| 364 |
+
"sources": ["arxiv"],
|
| 365 |
+
})
|
| 366 |
+
|
| 367 |
+
print(f" matched {len(out)} publication(s)")
|
| 368 |
+
return out
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
def fetch_manual(cfg):
|
| 372 |
+
"""Entries hand-written into sources.json."""
|
| 373 |
+
entries = [
|
| 374 |
+
e for e in (cfg.get("manual_publications") or [])
|
| 375 |
+
if e.get("title") and not e.get("_example")
|
| 376 |
+
]
|
| 377 |
+
print(f"\n [manual] {len(entries)} entry(ies) from config")
|
| 378 |
+
out = []
|
| 379 |
+
for e in entries:
|
| 380 |
+
rec = {k: v for k, v in e.items() if not k.startswith("_")}
|
| 381 |
+
rec["sources"] = ["manual"]
|
| 382 |
+
out.append(rec)
|
| 383 |
+
return out
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
FETCHERS = {
|
| 387 |
+
"openalex": fetch_openalex,
|
| 388 |
+
"crossref": fetch_crossref,
|
| 389 |
+
"pmlr": fetch_pmlr,
|
| 390 |
+
"arxiv": fetch_arxiv,
|
| 391 |
+
"manual": fetch_manual,
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
# ----------------------------------------------------------------------------
|
| 396 |
+
# output
|
| 397 |
+
# ----------------------------------------------------------------------------
|
| 398 |
+
|
| 399 |
+
def render_markdown(rec):
|
| 400 |
+
authors = rec.get("authors_authoritative") or rec.get("authors") or []
|
| 401 |
+
lines = [f"# {rec.get('title','Untitled')}", ""]
|
| 402 |
+
|
| 403 |
+
if authors:
|
| 404 |
+
lines.append(f"**Authors:** {', '.join(authors)}")
|
| 405 |
+
if rec.get("venue"):
|
| 406 |
+
lines.append(f"**Venue:** {rec['venue']}")
|
| 407 |
+
if rec.get("year"):
|
| 408 |
+
lines.append(f"**Year:** {rec['year']}")
|
| 409 |
+
if rec.get("doi"):
|
| 410 |
+
lines.append(f"**DOI:** {rec['doi']}")
|
| 411 |
+
if rec.get("arxiv_id"):
|
| 412 |
+
lines.append(f"**arXiv ID:** {rec['arxiv_id']}")
|
| 413 |
+
if rec.get("url"):
|
| 414 |
+
lines.append(f"**URL:** {rec['url']}")
|
| 415 |
+
if rec.get("pdf_url"):
|
| 416 |
+
lines.append(f"**PDF:** {rec['pdf_url']}")
|
| 417 |
+
if rec.get("sources"):
|
| 418 |
+
lines.append(f"**Indexed from:** {', '.join(rec['sources'])}")
|
| 419 |
+
|
| 420 |
+
lines += ["", "---", "", "## Abstract", ""]
|
| 421 |
+
lines.append(rec.get("abstract") or "(No abstract available from any source.)")
|
| 422 |
+
lines.append("")
|
| 423 |
+
return "\n".join(lines)
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
def write_publications(records, output_dir, dry_run=False):
|
| 427 |
+
out_path = Path(output_dir)
|
| 428 |
+
if not dry_run:
|
| 429 |
+
out_path.mkdir(parents=True, exist_ok=True)
|
| 430 |
+
|
| 431 |
+
# Rewrite the directory from scratch so renamed or retracted papers do not
|
| 432 |
+
# linger as stale documents in the index.
|
| 433 |
+
existing = set()
|
| 434 |
+
if out_path.exists():
|
| 435 |
+
existing = {p.name for p in out_path.glob("*.md")}
|
| 436 |
+
|
| 437 |
+
written = set()
|
| 438 |
+
for rec in records:
|
| 439 |
+
year = rec.get("year") or "n.d."
|
| 440 |
+
fname = clean_filename(f"{year}_{rec.get('title','untitled')}") + ".md"
|
| 441 |
+
written.add(fname)
|
| 442 |
+
if dry_run:
|
| 443 |
+
continue
|
| 444 |
+
(out_path / fname).write_text(render_markdown(rec), encoding="utf-8")
|
| 445 |
+
|
| 446 |
+
stale = existing - written
|
| 447 |
+
if not dry_run:
|
| 448 |
+
for name in stale:
|
| 449 |
+
(out_path / name).unlink()
|
| 450 |
+
|
| 451 |
+
return written, stale
|
| 452 |
+
|
| 453 |
+
|
| 454 |
+
def main():
|
| 455 |
+
parser = argparse.ArgumentParser(description="Download publications from all sources")
|
| 456 |
+
parser.add_argument("--config", default=DEFAULT_CONFIG)
|
| 457 |
+
parser.add_argument("--output-dir", default=DEFAULT_OUTPUT)
|
| 458 |
+
parser.add_argument("--source", action="append", choices=ALL_SOURCES,
|
| 459 |
+
help="Limit to specific sources (repeatable). Default: all.")
|
| 460 |
+
parser.add_argument("--dry-run", action="store_true",
|
| 461 |
+
help="Report what would be written without touching disk")
|
| 462 |
+
parser.add_argument("--min-expected", type=int, default=0,
|
| 463 |
+
help="Fail with exit 1 if fewer than N publications are found")
|
| 464 |
+
args = parser.parse_args()
|
| 465 |
+
|
| 466 |
+
if not os.path.exists(args.config):
|
| 467 |
+
print(f"ERROR: config not found: {args.config}")
|
| 468 |
+
return 1
|
| 469 |
+
cfg = load_config(args.config)
|
| 470 |
+
|
| 471 |
+
sources = args.source or ALL_SOURCES
|
| 472 |
+
print("=" * 64)
|
| 473 |
+
print(f" Publication download for {cfg['author']['full_name']}")
|
| 474 |
+
print(f" Sources: {', '.join(sources)}")
|
| 475 |
+
print("=" * 64)
|
| 476 |
+
|
| 477 |
+
store = {}
|
| 478 |
+
failed_sources = []
|
| 479 |
+
for name in sources:
|
| 480 |
+
try:
|
| 481 |
+
for rec in FETCHERS[name](cfg):
|
| 482 |
+
merge_record(store, rec)
|
| 483 |
+
except Exception as e:
|
| 484 |
+
print(f" [{name}] unexpected error: {e}")
|
| 485 |
+
failed_sources.append(name)
|
| 486 |
+
|
| 487 |
+
records = sorted(
|
| 488 |
+
store.values(),
|
| 489 |
+
key=lambda r: (r.get("year") or 0, r.get("title") or ""),
|
| 490 |
+
reverse=True,
|
| 491 |
+
)
|
| 492 |
+
|
| 493 |
+
print("\n" + "=" * 64)
|
| 494 |
+
print(f" {len(records)} unique publication(s) after dedupe")
|
| 495 |
+
print("=" * 64)
|
| 496 |
+
for r in records:
|
| 497 |
+
abstract_flag = "abstract" if r.get("abstract") else "NO ABSTRACT"
|
| 498 |
+
print(f" {r.get('year') or '????'} {(r.get('title') or '')[:66]}")
|
| 499 |
+
print(f" {r.get('venue') or 'unknown venue'} [{abstract_flag}]"
|
| 500 |
+
f" via {', '.join(r.get('sources', []))}")
|
| 501 |
+
|
| 502 |
+
written, stale = write_publications(records, args.output_dir, args.dry_run)
|
| 503 |
+
|
| 504 |
+
if args.dry_run:
|
| 505 |
+
print(f"\n DRY RUN: would write {len(written)} file(s) to {args.output_dir}/")
|
| 506 |
+
if stale:
|
| 507 |
+
print(f" DRY RUN: would remove {len(stale)} stale file(s): {', '.join(sorted(stale))}")
|
| 508 |
+
else:
|
| 509 |
+
print(f"\n Wrote {len(written)} file(s) to {args.output_dir}/")
|
| 510 |
+
if stale:
|
| 511 |
+
print(f" Removed {len(stale)} stale file(s): {', '.join(sorted(stale))}")
|
| 512 |
+
|
| 513 |
+
if failed_sources:
|
| 514 |
+
print(f"\n WARNING: these sources errored and contributed nothing: {', '.join(failed_sources)}")
|
| 515 |
+
|
| 516 |
+
if args.min_expected and len(records) < args.min_expected:
|
| 517 |
+
print(f"\n ERROR: found {len(records)} publication(s), expected at least {args.min_expected}.")
|
| 518 |
+
print(" Refusing to report success; the index would lose content.")
|
| 519 |
+
return 1
|
| 520 |
+
|
| 521 |
+
return 0
|
| 522 |
+
|
| 523 |
+
|
| 524 |
+
if __name__ == "__main__":
|
| 525 |
+
sys.exit(main())
|
|
@@ -0,0 +1,272 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Knowledge Base Refresh
|
| 4 |
+
|
| 5 |
+
Re-downloads every source, rebuilds the FAISS index, and optionally deploys to
|
| 6 |
+
the Hugging Face Space.
|
| 7 |
+
|
| 8 |
+
Safety model
|
| 9 |
+
------------
|
| 10 |
+
The previous version ran every step, ignored failures, and rebuilt the index
|
| 11 |
+
regardless. If GitHub rate-limited a run, the index was silently rebuilt from a
|
| 12 |
+
half-empty docs/ tree and the RAG lost content with no visible error.
|
| 13 |
+
|
| 14 |
+
This version:
|
| 15 |
+
* treats each source as independent and records its outcome,
|
| 16 |
+
* refuses to rebuild if a source failed, unless --allow-partial is given,
|
| 17 |
+
* refuses to rebuild if the corpus shrank more than --max-shrink percent
|
| 18 |
+
against the last recorded run,
|
| 19 |
+
* never pushes a build it would not have rebuilt.
|
| 20 |
+
|
| 21 |
+
Usage:
|
| 22 |
+
python refresh_index.py # download + rebuild
|
| 23 |
+
python refresh_index.py --dry-run # report only, touch nothing
|
| 24 |
+
python refresh_index.py --skip-download # rebuild from existing docs
|
| 25 |
+
python refresh_index.py --push # rebuild and deploy to HF
|
| 26 |
+
python refresh_index.py --only publications # run a single source
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
import argparse
|
| 30 |
+
import json
|
| 31 |
+
import os
|
| 32 |
+
import subprocess
|
| 33 |
+
import sys
|
| 34 |
+
import time
|
| 35 |
+
from datetime import datetime, timezone
|
| 36 |
+
from pathlib import Path
|
| 37 |
+
|
| 38 |
+
from dotenv import load_dotenv
|
| 39 |
+
|
| 40 |
+
load_dotenv()
|
| 41 |
+
|
| 42 |
+
CONFIG_PATH = "sources.json"
|
| 43 |
+
STATE_PATH = ".refresh_state.json"
|
| 44 |
+
|
| 45 |
+
# Directories whose file count is a proxy for corpus health.
|
| 46 |
+
CORPUS_DIRS = {
|
| 47 |
+
"publications": ("docs/papers", "*.md"),
|
| 48 |
+
"readmes": ("docs/readmes", "*.md"),
|
| 49 |
+
"github_activity": ("docs/github_activity", "*.md"),
|
| 50 |
+
"pdfs": ("docs/pdfs", "*.pdf"),
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def load_config():
|
| 55 |
+
if not os.path.exists(CONFIG_PATH):
|
| 56 |
+
print(f"ERROR: {CONFIG_PATH} not found.")
|
| 57 |
+
sys.exit(1)
|
| 58 |
+
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
| 59 |
+
return json.load(f)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def corpus_counts():
|
| 63 |
+
return {
|
| 64 |
+
name: len(list(Path(d).glob(pat))) if Path(d).exists() else 0
|
| 65 |
+
for name, (d, pat) in CORPUS_DIRS.items()
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def load_state():
|
| 70 |
+
if os.path.exists(STATE_PATH):
|
| 71 |
+
try:
|
| 72 |
+
with open(STATE_PATH, "r", encoding="utf-8") as f:
|
| 73 |
+
return json.load(f)
|
| 74 |
+
except Exception:
|
| 75 |
+
pass
|
| 76 |
+
return {}
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def save_state(state):
|
| 80 |
+
with open(STATE_PATH, "w", encoding="utf-8") as f:
|
| 81 |
+
json.dump(state, f, indent=2)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def run_step(cmd, label, dry_run=False):
|
| 85 |
+
"""Run one pipeline step. Returns True on success."""
|
| 86 |
+
print(f"\n{'='*64}\n {label}\n{'='*64}")
|
| 87 |
+
if dry_run:
|
| 88 |
+
print(f" DRY RUN: would execute {' '.join(str(c) for c in cmd)}")
|
| 89 |
+
return True
|
| 90 |
+
|
| 91 |
+
start = time.time()
|
| 92 |
+
result = subprocess.run(cmd)
|
| 93 |
+
elapsed = time.time() - start
|
| 94 |
+
|
| 95 |
+
if result.returncode != 0:
|
| 96 |
+
print(f"\n FAILED: {label} exited {result.returncode} after {elapsed:.0f}s")
|
| 97 |
+
return False
|
| 98 |
+
print(f"\n OK: {label} ({elapsed:.0f}s)")
|
| 99 |
+
return True
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def validate_github_token(token):
|
| 103 |
+
"""Return the token if it authenticates, else empty string."""
|
| 104 |
+
if not token:
|
| 105 |
+
return ""
|
| 106 |
+
try:
|
| 107 |
+
import requests
|
| 108 |
+
resp = requests.get(
|
| 109 |
+
"https://api.github.com/user",
|
| 110 |
+
headers={"Authorization": f"token {token}"},
|
| 111 |
+
timeout=10,
|
| 112 |
+
)
|
| 113 |
+
if resp.status_code == 401:
|
| 114 |
+
print(" WARNING: GITHUB_TOKEN is expired or invalid; continuing unauthenticated.")
|
| 115 |
+
print(" (Unauthenticated GitHub API is rate-limited to 60 requests/hour.)")
|
| 116 |
+
return ""
|
| 117 |
+
return token
|
| 118 |
+
except Exception as e:
|
| 119 |
+
print(f" WARNING: could not validate GITHUB_TOKEN ({e}); using it anyway.")
|
| 120 |
+
return token
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def push_to_hf(dry_run=False):
|
| 124 |
+
print(f"\n{'='*64}\n Deploying to Hugging Face Space\n{'='*64}")
|
| 125 |
+
if dry_run:
|
| 126 |
+
print(" DRY RUN: would git add docs/, commit, and push")
|
| 127 |
+
return True
|
| 128 |
+
|
| 129 |
+
subprocess.run(["git", "add", "docs/"])
|
| 130 |
+
if subprocess.run(["git", "diff", "--staged", "--quiet"]).returncode == 0:
|
| 131 |
+
print(" No changes to commit; the deployed index is already current.")
|
| 132 |
+
return True
|
| 133 |
+
|
| 134 |
+
stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
| 135 |
+
subprocess.run(["git", "commit", "-m", f"chore: refresh knowledge base ({stamp})"])
|
| 136 |
+
if subprocess.run(["git", "push"]).returncode != 0:
|
| 137 |
+
print(" ERROR: git push failed. Check your Hugging Face credentials.")
|
| 138 |
+
return False
|
| 139 |
+
|
| 140 |
+
print(" Pushed. The Space will rebuild automatically.")
|
| 141 |
+
return True
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def main():
|
| 145 |
+
parser = argparse.ArgumentParser(description="Refresh the RAG knowledge base")
|
| 146 |
+
parser.add_argument("--github-token", default=os.getenv("GITHUB_TOKEN", ""))
|
| 147 |
+
parser.add_argument("--skip-download", action="store_true",
|
| 148 |
+
help="Rebuild the index from whatever is already in docs/")
|
| 149 |
+
parser.add_argument("--only", action="append",
|
| 150 |
+
choices=["publications", "readmes", "activity", "resume"],
|
| 151 |
+
help="Run only these sources (repeatable)")
|
| 152 |
+
parser.add_argument("--push", action="store_true",
|
| 153 |
+
help="Commit and push to the Hugging Face Space after a clean rebuild")
|
| 154 |
+
parser.add_argument("--dry-run", action="store_true",
|
| 155 |
+
help="Report the plan without executing anything")
|
| 156 |
+
parser.add_argument("--allow-partial", action="store_true",
|
| 157 |
+
help="Rebuild even if a source failed (risks losing content)")
|
| 158 |
+
parser.add_argument("--max-shrink", type=float, default=25.0,
|
| 159 |
+
help="Abort if the corpus shrinks more than this percent (default 25)")
|
| 160 |
+
args = parser.parse_args()
|
| 161 |
+
|
| 162 |
+
cfg = load_config()
|
| 163 |
+
state = load_state()
|
| 164 |
+
before = corpus_counts()
|
| 165 |
+
|
| 166 |
+
print("=" * 64)
|
| 167 |
+
print(" RAG Knowledge Base Refresh")
|
| 168 |
+
print(f" Started {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
| 169 |
+
print("=" * 64)
|
| 170 |
+
print("\n Corpus before:")
|
| 171 |
+
for k, v in before.items():
|
| 172 |
+
print(f" {k:<16} {v}")
|
| 173 |
+
|
| 174 |
+
results = {}
|
| 175 |
+
py = sys.executable
|
| 176 |
+
|
| 177 |
+
if not args.skip_download:
|
| 178 |
+
wanted = args.only or ["publications", "readmes", "activity", "resume"]
|
| 179 |
+
token = validate_github_token(args.github_token)
|
| 180 |
+
token_args = ["--token", token] if token else []
|
| 181 |
+
username = cfg["github"]["username"]
|
| 182 |
+
|
| 183 |
+
if "publications" in wanted:
|
| 184 |
+
results["publications"] = run_step(
|
| 185 |
+
[py, "download_publications.py", "--min-expected", "1"],
|
| 186 |
+
"Downloading publications (OpenAlex, Crossref, PMLR, arXiv)",
|
| 187 |
+
args.dry_run,
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
if "readmes" in wanted:
|
| 191 |
+
results["readmes"] = run_step(
|
| 192 |
+
[py, "download_readmes.py", "--username", username,
|
| 193 |
+
"--exclude-tpoze"] + token_args,
|
| 194 |
+
"Downloading GitHub READMEs",
|
| 195 |
+
args.dry_run,
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
if "activity" in wanted:
|
| 199 |
+
results["activity"] = run_step(
|
| 200 |
+
[py, "download_github_activity.py", "--username", username] + token_args,
|
| 201 |
+
"Downloading GitHub issues and PRs",
|
| 202 |
+
args.dry_run,
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
if "resume" in wanted:
|
| 206 |
+
results["resume"] = run_step(
|
| 207 |
+
[py, "sync_resume.py"],
|
| 208 |
+
"Syncing current resume",
|
| 209 |
+
args.dry_run,
|
| 210 |
+
)
|
| 211 |
+
else:
|
| 212 |
+
print("\n Skipping downloads (--skip-download)")
|
| 213 |
+
|
| 214 |
+
failed = [k for k, ok in results.items() if not ok]
|
| 215 |
+
after = corpus_counts()
|
| 216 |
+
|
| 217 |
+
print(f"\n{'='*64}\n Source results\n{'='*64}")
|
| 218 |
+
for name in sorted(results):
|
| 219 |
+
delta = after.get(name, 0) - before.get(name, 0)
|
| 220 |
+
sign = f"{delta:+d}" if delta else "no change"
|
| 221 |
+
print(f" {name:<16} {'OK' if results[name] else 'FAILED':<8} ({sign})")
|
| 222 |
+
|
| 223 |
+
# Guard 1: a failed source means the corpus may be incomplete.
|
| 224 |
+
if failed and not args.allow_partial:
|
| 225 |
+
print(f"\n ABORTING: these sources failed: {', '.join(failed)}")
|
| 226 |
+
print(" Rebuilding now would bake the missing content into the index.")
|
| 227 |
+
print(" Fix the cause, or re-run with --allow-partial to override.")
|
| 228 |
+
return 1
|
| 229 |
+
|
| 230 |
+
# Guard 2: a large drop usually means an API returned an empty result set.
|
| 231 |
+
shrink_problems = []
|
| 232 |
+
for name, prev in (state.get("corpus") or {}).items():
|
| 233 |
+
now = after.get(name, 0)
|
| 234 |
+
if prev > 0 and now < prev:
|
| 235 |
+
pct = (prev - now) / prev * 100
|
| 236 |
+
if pct > args.max_shrink:
|
| 237 |
+
shrink_problems.append(f"{name}: {prev} -> {now} ({pct:.0f}% smaller)")
|
| 238 |
+
|
| 239 |
+
if shrink_problems and not args.allow_partial:
|
| 240 |
+
print(f"\n ABORTING: corpus shrank more than {args.max_shrink:.0f}%:")
|
| 241 |
+
for p in shrink_problems:
|
| 242 |
+
print(f" {p}")
|
| 243 |
+
print(" This usually means a source returned nothing rather than that content was removed.")
|
| 244 |
+
print(" Re-run with --allow-partial if the shrinkage is intentional.")
|
| 245 |
+
return 1
|
| 246 |
+
|
| 247 |
+
ok = run_step([py, "build_index.py"], "Rebuilding FAISS index", args.dry_run)
|
| 248 |
+
if not ok:
|
| 249 |
+
print("\n Index rebuild failed. The previously deployed index is untouched.")
|
| 250 |
+
return 1
|
| 251 |
+
|
| 252 |
+
if not args.dry_run:
|
| 253 |
+
state["corpus"] = after
|
| 254 |
+
state["last_run"] = datetime.now(timezone.utc).isoformat()
|
| 255 |
+
save_state(state)
|
| 256 |
+
|
| 257 |
+
if args.push:
|
| 258 |
+
if not push_to_hf(args.dry_run):
|
| 259 |
+
return 1
|
| 260 |
+
|
| 261 |
+
print(f"\n{'='*64}")
|
| 262 |
+
print(" Refresh complete.")
|
| 263 |
+
for k, v in after.items():
|
| 264 |
+
print(f" {k:<16} {v}")
|
| 265 |
+
if not args.push:
|
| 266 |
+
print("\n Run with --push to deploy to the Hugging Face Space.")
|
| 267 |
+
print("=" * 64)
|
| 268 |
+
return 0
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
if __name__ == "__main__":
|
| 272 |
+
sys.exit(main())
|
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
#
|
| 3 |
+
# Scheduled knowledge-base refresh, intended for cron on an always-on box.
|
| 4 |
+
#
|
| 5 |
+
# Wraps refresh_index.py with the things cron needs and a bare script does not:
|
| 6 |
+
# an absolute working directory, the venv on PATH, a lock so overlapping runs
|
| 7 |
+
# cannot corrupt the index, and a rotating log.
|
| 8 |
+
#
|
| 9 |
+
# Install (on julienlab):
|
| 10 |
+
# crontab -e
|
| 11 |
+
# 0 3 * * 1 /home/julien/julienserbanescu-rag/scheduled_refresh.sh
|
| 12 |
+
#
|
| 13 |
+
# See AUTOMATION.md for full setup.
|
| 14 |
+
|
| 15 |
+
set -euo pipefail
|
| 16 |
+
|
| 17 |
+
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
| 18 |
+
VENV="${REPO_DIR}/venv"
|
| 19 |
+
LOG_DIR="${REPO_DIR}/logs"
|
| 20 |
+
LOCK="${REPO_DIR}/.refresh.lock"
|
| 21 |
+
LOG="${LOG_DIR}/refresh-$(date -u +%Y%m%d-%H%M%S).log"
|
| 22 |
+
|
| 23 |
+
mkdir -p "${LOG_DIR}"
|
| 24 |
+
|
| 25 |
+
# Refuse to start if a previous run is still going. flock releases the lock
|
| 26 |
+
# automatically if the holding process dies, so a crashed run cannot wedge this.
|
| 27 |
+
exec 9>"${LOCK}"
|
| 28 |
+
if ! flock -n 9; then
|
| 29 |
+
echo "$(date -u +%FT%TZ) another refresh is already running; exiting." >> "${LOG}"
|
| 30 |
+
exit 0
|
| 31 |
+
fi
|
| 32 |
+
|
| 33 |
+
cd "${REPO_DIR}"
|
| 34 |
+
|
| 35 |
+
if [ -x "${VENV}/bin/python" ]; then
|
| 36 |
+
PYTHON="${VENV}/bin/python"
|
| 37 |
+
else
|
| 38 |
+
PYTHON="$(command -v python3)"
|
| 39 |
+
echo "WARNING: no venv at ${VENV}; falling back to ${PYTHON}" | tee -a "${LOG}"
|
| 40 |
+
fi
|
| 41 |
+
|
| 42 |
+
{
|
| 43 |
+
echo "=============================================================="
|
| 44 |
+
echo " Scheduled refresh started $(date -u +%FT%TZ)"
|
| 45 |
+
echo " Repo: ${REPO_DIR}"
|
| 46 |
+
echo " Python: ${PYTHON}"
|
| 47 |
+
echo "=============================================================="
|
| 48 |
+
} >> "${LOG}"
|
| 49 |
+
|
| 50 |
+
status=0
|
| 51 |
+
"${PYTHON}" refresh_index.py --push >> "${LOG}" 2>&1 || status=$?
|
| 52 |
+
|
| 53 |
+
{
|
| 54 |
+
echo "--------------------------------------------------------------"
|
| 55 |
+
if [ "${status}" -eq 0 ]; then
|
| 56 |
+
echo " Finished OK $(date -u +%FT%TZ)"
|
| 57 |
+
else
|
| 58 |
+
echo " FAILED (exit ${status}) $(date -u +%FT%TZ)"
|
| 59 |
+
echo " The previously deployed index was left untouched."
|
| 60 |
+
fi
|
| 61 |
+
} >> "${LOG}"
|
| 62 |
+
|
| 63 |
+
# Keep the last 12 runs.
|
| 64 |
+
ls -1t "${LOG_DIR}"/refresh-*.log 2>/dev/null | tail -n +13 | xargs -r rm -f
|
| 65 |
+
|
| 66 |
+
exit "${status}"
|
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_comment": "Central config for the knowledge-base refresh pipeline. Edit this rather than the scripts.",
|
| 3 |
+
|
| 4 |
+
"author": {
|
| 5 |
+
"full_name": "Julien Serbanescu",
|
| 6 |
+
"given_names": ["Julien"],
|
| 7 |
+
"family_name": "Serbanescu",
|
| 8 |
+
"openalex_author_id": null,
|
| 9 |
+
"contact_email": "julien.serbanescu@gmail.com"
|
| 10 |
+
},
|
| 11 |
+
|
| 12 |
+
"github": {
|
| 13 |
+
"username": "Julien-ser",
|
| 14 |
+
"exclude_forks": true
|
| 15 |
+
},
|
| 16 |
+
|
| 17 |
+
"pmlr": {
|
| 18 |
+
"_comment": "PMLR volumes to scan. New proceedings appear here before OpenAlex indexes them. Canadian AI 2026 = v318.",
|
| 19 |
+
"volumes": ["v318"]
|
| 20 |
+
},
|
| 21 |
+
|
| 22 |
+
"resume": {
|
| 23 |
+
"_comment": "Copied into docs/pdfs on each refresh so the RAG always reflects the current resume. First path that exists wins.",
|
| 24 |
+
"source_paths": [
|
| 25 |
+
"C:/Users/a1/Documents/Obsidian Vault/MyExp/resume.pdf",
|
| 26 |
+
"~/Documents/Obsidian Vault/MyExp/resume.pdf"
|
| 27 |
+
],
|
| 28 |
+
"dest_name": "resume_julien_current.pdf"
|
| 29 |
+
},
|
| 30 |
+
|
| 31 |
+
"manual_publications": [
|
| 32 |
+
{
|
| 33 |
+
"_comment": "Escape hatch for anything the APIs miss. Entries here are always included.",
|
| 34 |
+
"_example": true,
|
| 35 |
+
"title": null,
|
| 36 |
+
"authors": [],
|
| 37 |
+
"venue": null,
|
| 38 |
+
"year": null,
|
| 39 |
+
"abstract": null,
|
| 40 |
+
"url": null
|
| 41 |
+
}
|
| 42 |
+
]
|
| 43 |
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Copy the current resume PDF into docs/pdfs so the RAG always answers from the
|
| 4 |
+
latest version rather than whichever copy happened to be committed.
|
| 5 |
+
|
| 6 |
+
Paths are configured under "resume" in sources.json; the first path that exists
|
| 7 |
+
wins, so the same config works on the Windows workstation and on a Linux box.
|
| 8 |
+
|
| 9 |
+
Usage:
|
| 10 |
+
python sync_resume.py
|
| 11 |
+
python sync_resume.py --config sources.json
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import argparse
|
| 15 |
+
import json
|
| 16 |
+
import os
|
| 17 |
+
import shutil
|
| 18 |
+
import sys
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def main():
|
| 23 |
+
parser = argparse.ArgumentParser(description="Sync the current resume into docs/pdfs")
|
| 24 |
+
parser.add_argument("--config", default="sources.json")
|
| 25 |
+
parser.add_argument("--dest-dir", default="docs/pdfs")
|
| 26 |
+
args = parser.parse_args()
|
| 27 |
+
|
| 28 |
+
if not os.path.exists(args.config):
|
| 29 |
+
print(f" ERROR: config not found: {args.config}")
|
| 30 |
+
return 1
|
| 31 |
+
|
| 32 |
+
with open(args.config, "r", encoding="utf-8") as f:
|
| 33 |
+
cfg = json.load(f)
|
| 34 |
+
|
| 35 |
+
resume_cfg = cfg.get("resume") or {}
|
| 36 |
+
candidates = resume_cfg.get("source_paths") or []
|
| 37 |
+
dest_name = resume_cfg.get("dest_name") or "resume_current.pdf"
|
| 38 |
+
|
| 39 |
+
src = None
|
| 40 |
+
for raw in candidates:
|
| 41 |
+
p = Path(os.path.expanduser(raw))
|
| 42 |
+
if p.is_file():
|
| 43 |
+
src = p
|
| 44 |
+
break
|
| 45 |
+
|
| 46 |
+
if src is None:
|
| 47 |
+
# Not fatal: the pipeline should still rebuild from everything else.
|
| 48 |
+
print(" No resume found at any configured path; skipping.")
|
| 49 |
+
for raw in candidates:
|
| 50 |
+
print(f" tried: {raw}")
|
| 51 |
+
return 0
|
| 52 |
+
|
| 53 |
+
dest_dir = Path(args.dest_dir)
|
| 54 |
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
| 55 |
+
dest = dest_dir / dest_name
|
| 56 |
+
|
| 57 |
+
if dest.exists() and dest.stat().st_size == src.stat().st_size:
|
| 58 |
+
print(f" Resume already current ({dest_name}, {src.stat().st_size:,} bytes)")
|
| 59 |
+
return 0
|
| 60 |
+
|
| 61 |
+
shutil.copy2(src, dest)
|
| 62 |
+
print(f" Copied resume: {src}")
|
| 63 |
+
print(f" -> {dest} ({dest.stat().st_size:,} bytes)")
|
| 64 |
+
return 0
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
if __name__ == "__main__":
|
| 68 |
+
sys.exit(main())
|