Julien-ser commited on
Commit
ab705c9
·
1 Parent(s): a8918f1

Index all 5 publications; add self-updating refresh pipeline

Browse files

The 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 ADDED
@@ -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
.gitignore CHANGED
@@ -1,9 +1,19 @@
1
- docs/faiss/index.faiss
2
- docs/pdfs/*
3
- docs/youtube/*
4
- .git_backup
5
- __pycache__
6
- *.pyc
7
  .env
8
  pat.txt
9
- /venv
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.
.refresh_state.json ADDED
@@ -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
+ }
AUTOMATION.md ADDED
@@ -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.
build_index.py CHANGED
@@ -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 langchain.text_splitter import RecursiveCharacterTextSplitter
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. Scrape LinkedIn profile (optional - may require authentication)
393
- print("\n1b. Scraping LinkedIn profile (if URL provided)...")
394
- linkedin_url = os.getenv("LINKEDIN_PROFILE_URL") # Optional: set in .env
395
- if linkedin_url:
396
- linkedin_content = scrape_linkedin_profile(linkedin_url)
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
- # 5. Split documents into chunks
440
- print("\n5. Splitting documents into chunks...")
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
- # 6. Create embeddings
451
- print("\n6. Creating embeddings...")
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
- # 7. Build FAISS index
460
- print("\n7. Building FAISS index...")
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
- # 8. Create docstore and mapping
471
- print("\n8. Creating docstore and mapping...")
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
- # 9. Save everything
481
- print("\n9. Saving index files...")
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
- # 10. Create human-readable document lookup
502
- print("\n10. Creating document lookup file...")
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):
docs/faiss/document_lookup.txt CHANGED
The diff for this file is too large to render. See raw diff
 
docs/faiss/index.faiss CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:761c3e1c863aed9b8194abb693fb5cd07c237ebf4987efd4f58ab4349df6cbd7
3
- size 3424301
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c939addb445c9cb242b77a382f005eb0bcaf042dd36ea251e20752ea421ef36d
3
+ size 3477549
docs/faiss/index.pkl CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:8a2bb4a67ed657a46ea93c309f4f686b6c7115bd2b18c7605c5b28c13d686ff1
3
- size 767874
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d0a6ff76940741bcfc061f6e2a926603fe29b7c680ad371a6f90d91535aa6eb9
3
+ size 776917
docs/faiss/metadata.pkl CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:16da6051eb9ab58343238c7c7dd50c32229abdfab355d06871f7d817904ef93d
3
- size 16460
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e37da3ecfb523e1d4f7e6f5b247a927e6fa671e4fe14b1455bf1d5f251b58080
3
+ size 17099
docs/papers/2024_UnAnswGen_A_Systematic_Approach_for_Generating_Unanswerable_Questions_in_Machine_Reading_Comprehension.md ADDED
@@ -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.
docs/papers/2025_FalseCoTQA_Adversarial_Multi-Hop_QA_via_Knowledge-Grounded_False_Chains_of_Thought.md ADDED
@@ -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.
docs/papers/2025_Uncovering_the_Persuasive_Fingerprint_of_LLMs_in_Jailbreaking_Attacks.md ADDED
@@ -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/.
docs/papers/2026_Cause-Conditioned_Multi-Task_Learning_for_Answerable_Question_Suggestion_in_MRC.md ADDED
@@ -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.
docs/papers/2026_From_Hints_to_Answers_Uncertainty-Aware_LLM-Guided_Retrieval_for_Multi-Hop_Question_Answering.md ADDED
@@ -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.
docs/papers/arxiv_2510.21983v1_Uncovering_the_Persuasive_Fingerprint_of_LLMs_in_Jailbreaking_Attacks.md DELETED
@@ -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.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/pdfs/resume_julien_current.pdf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7796f0cfee53567c2e5ea51072794c58cb36a6eec1e2fd82aa84f4aa997e9362
3
+ size 234131
download_publications.py ADDED
@@ -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("&nbsp;,")]
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())
refresh_index.py ADDED
@@ -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())
scheduled_refresh.sh ADDED
@@ -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}"
sources.json ADDED
@@ -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
+ }
sync_resume.py ADDED
@@ -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())