Skip to content

Commit 3b600ce

Browse files
committed
LCORE-2664: add Confluence import PoC code and validation evidence
Moderate-ambition PoC validating the spike's core mechanism against real Confluence Cloud (redhat.atlassian.net, space RHAT, 20 pages, read-only API token): - confluence_fetch.py: full + incremental crawl via v1 REST body.export_view; writes per-page HTML, manifest.json (id, title, canonical URL, version) and state.json (watermark + versions); incremental mode combines a CQL lastmodified delta with page-version comparison and detects deletions by full ID enumeration diff. - confluence_processor.py: custom_processor-pattern build driver with ConfluenceMetadataProcessor resolving citation URL/title from the manifest (hermetic_build=True for auth-gated URLs); wires the docling HTMLReader via file_extractor and scopes the corpus with required_exts. - confluence_test_page.py: helper for an editable test page; unused in the end (space creation is 403-forbidden for regular users), kept as evidence of the read-only-token constraint. - poc-results/: sanitized evidence only (crawl stats, retrieval output with titles/URLs/scores, conversion-structure sample); raw crawled content is Red Hat internal and deliberately excluded. Validated: end-to-end build (3.8 MB llamastack-faiss store), correct top-3 retrieval with per-page URL citations, incremental re-run with zero body fetches and zero re-embeds (2 API calls instead of 21). Both directories are review-time artifacts and will be removed from the branch before merge per docs/contributing/howto-organize-poc-output.md.
1 parent b597f1a commit 3b600ce

9 files changed

Lines changed: 625 additions & 0 deletions

File tree

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# PoC results: BYOK auto-import from Confluence (LCORE-2664)
2+
3+
Evidence for the spike doc's PoC findings. Raw crawled content is **not**
4+
included: the PoC ran against the RHAT space on redhat.atlassian.net
5+
(Red Hat-internal content); this directory carries sanitized stats and
6+
excerpts only.
7+
8+
## Design
9+
10+
- **Target**: Confluence Cloud (redhat.atlassian.net), space `RHAT`
11+
(20 pages), read-only API token of a regular user.
12+
- **Fetch**: v1 REST `body.export_view` (rendered HTML), one HTML file per
13+
page + `manifest.json` (id → title, canonical URL, version) +
14+
`state.json` (sync watermark, per-page versions).
15+
- **Build**: existing rag-content pipeline — docling `HTMLReader`,
16+
`MarkdownNodeParser`, chunk 380/0, `all-mpnet-base-v2` (768-dim),
17+
`llamastack-faiss` output.
18+
- **Verify**: `vector_io.query` via llama-stack library client, top-3.
19+
- **Incremental**: run 2 with `--incremental` — CQL
20+
`lastmodified >= watermark − 10min` + full ID enumeration (deletion diff)
21+
+ version comparison to skip unchanged pages.
22+
23+
## Results
24+
25+
| Step | Outcome |
26+
|---|---|
27+
| Full crawl (run 1) | 20/20 pages fetched, ~1 API call/page + 1 listing call |
28+
| DB build | `faiss_store.db` 3.8 MB, no page failed conversion |
29+
| Retrieval | Top-3 chunks on a policy question: correct pages, scores 2.25/1.65/1.60 |
30+
| Citations | `title` + canonical page URL present on every chunk (see `retrieval-evidence.txt`) |
31+
| Incremental (run 2) | `fetched=0 unchanged=20 deleted=0` — 2 API calls total instead of 21 |
32+
| Conversion quality | Headings/bold/nested lists/expand-macros clean (see `conversion-sample.md` excerpt) |
33+
34+
## Findings (carried into the spike doc)
35+
36+
1. **The mechanism works end-to-end** with zero changes to rag-content
37+
library code — only a fetch stage and a `MetadataProcessor` subclass.
38+
2. **Citations flow**: per-page Confluence URLs land in chunk metadata via
39+
`url_function`; `hermetic_build=True` is required (page URLs are
40+
auth-gated, reachability pings would mark all unreachable).
41+
3. **Read-only is all you get**: space creation 403-forbidden for regular
42+
users on redhat.atlassian.net; the importer must not assume any write
43+
permission. Update/delete sync paths are therefore implemented but
44+
validated logically, not live (no writable page available).
45+
4. **Governance**: Red Hat's own Atlassian policy (retrieved by the PoC
46+
itself) requires team integrations to use registered bot/service
47+
accounts, not personal tokens — auth docs must say so.
48+
5. **Incidental bug**: the generated `llama-stack.yaml` +
49+
`scripts/query_rag.py` fail out-of-the-box — re-registering the
50+
vector store conflicts with the registration persisted inside
51+
`faiss_store.db` (`provider_resource_id`/`vector_store_name`: None vs
52+
set). Worked around by dropping `registered_resources.vector_stores`
53+
and querying the persisted registration.
54+
6. **Portability gotcha confirmed live**: the built DB bakes the absolute
55+
embedding-model path into `llama-stack.yaml` and the kv registry.
56+
7. **Incomplete-LFS gotcha**: an embeddings-model directory without
57+
`model.safetensors` fails only at build time with an obscure error.
58+
59+
## Implications
60+
61+
- Recommended design (fetch stage + existing pipeline) is validated;
62+
no design change needed.
63+
- Incremental sync as "skip unchanged re-embeds, full rebuild output"
64+
is cheap and works; live update/delete validation moves to the e2e
65+
test ticket (needs a writable test instance/mock).
66+
- Finding 5 becomes a proposed incidental JIRA.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Conversion quality evidence (sanitized)
2+
3+
Largest crawled page ("Atlassian Cloud FAQ", export_view HTML → docling →
4+
Markdown, 0.14 s). Body text redacted (internal); structure shown verbatim:
5+
6+
```
7+
# Atlassian Cloud FAQ
8+
9+
## Review the answers to common questions you may have about our Cloud environment.
10+
11+
##### **What is Atlassian Cloud?**
12+
13+
[paragraph — clean prose, no markup artifacts]
14+
15+
##### **What is the timeline and when is the migration?**
16+
17+
**[bold lead sentence]**
18+
19+
- [nested list, 2 levels, rendered correctly]
20+
- [timezone conversion sub-items]
21+
```
22+
23+
Observations:
24+
25+
- Headings, bold, links, and 2-level nested lists all convert cleanly.
26+
- Confluence expand-macros arrive already expanded in `export_view`
27+
(their content is present as regular headings/paragraphs).
28+
- No letter-spaced-font artifacts (the known docling weakness with
29+
Confluence "Export to PDF" does not apply to the HTML path).
30+
- Minor: one stray list item rendered as an empty `-` bullet
31+
(trailing empty list node in the source HTML) — harmless for chunking.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Run 1 (full crawl) summary line:
2+
[full] spaces=['RHAT'] fetched=20 unchanged=0 deleted=0 total_on_disk=20
3+
4+
# Run 2 (incremental, zero changes):
5+
[incremental] spaces=['RHAT'] fetched=0 unchanged=20 deleted=0 total_on_disk=20
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
Query: "What is the policy for using API scripts and bots in the Atlassian Cloud environment?"
2+
Store: llamastack-faiss, top_k=3, mode=vector
3+
4+
chunk 1 | score=2.2482
5+
title: APIs, Scripts, and Bots: Policy for Cloud Environment
6+
docs_url: https://redhat.atlassian.net/wiki/spaces/RHAT/pages/303600805/APIs+Scripts+and+Bots+Policy+for+Cloud+Environment
7+
text: "## Policy Statement\n\n- Personal experimentation using Atlassian REST APIs may use API tokens in a user's own account.\n- Team-supporting integrations must use a registered bot user or approved service account, not a personal token. [...]"
8+
9+
chunk 2 | score=1.6468
10+
title: APIs, Scripts, and Bots: Policy for Cloud Environment
11+
docs_url: (same page as chunk 1)
12+
text: [Purpose section — redacted, internal]
13+
14+
chunk 3 | score=1.5955
15+
title: API, Script, and Bot Policy - public
16+
docs_url: https://redhat.atlassian.net/wiki/spaces/RHAT/pages/297468192/API+Script+and+Bot+Policy+-+public
17+
text: "## Policy Statement\n\n- Users using Atlassian API, bots, or scripts against Jira or Confluence must have a bot user. All bots need to be registered with the PME team. [...]"
18+
19+
All three chunks: correct pages for the query; every chunk carries title +
20+
canonical per-page Confluence URL in metadata (citation path proven).
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# PoC: BYOK auto-import from Confluence (LCORE-2664 spike)
2+
3+
Validates the core mechanism recommended by the spike doc: fetch Confluence
4+
pages as rendered HTML via the REST API, feed them to the existing
5+
`lightspeed-rag-content` pipeline (docling `HTMLReader` → Markdown →
6+
`MarkdownNodeParser` → embeddings), produce a `llamastack-faiss` vector DB,
7+
and query it with correct per-page citations. Also validates incremental
8+
change detection (CQL `lastmodified` + page-version comparison).
9+
10+
Not production code: credentials come from the developer's Jira CLI
11+
credentials file, there is no retry/backoff, no attachments, no config
12+
surface.
13+
14+
## Files
15+
16+
| File | Purpose |
17+
|---|---|
18+
| `confluence_fetch.py` | Fetch stage: full + incremental crawl, manifest, state |
19+
| `confluence_processor.py` | `custom_processor.py`-pattern build driver with `ConfluenceMetadataProcessor` |
20+
| `confluence_test_page.py` | Test-space/page helper (unused in the end: space creation is 403-forbidden on redhat.atlassian.net; kept as evidence of the read-only constraint) |
21+
22+
## Repro
23+
24+
Prereqs: `~/.config/jira/credentials.json` (email + API token for
25+
redhat.atlassian.net), a checkout of `lightspeed-rag-content` with its uv
26+
venv, and a *complete* local copy of the embedding model
27+
(`model.safetensors` present — an LFS-less clone will fail).
28+
29+
```bash
30+
RUN_DIR=/tmp/poc-run # fetched content is Red Hat internal; keep out of the repo
31+
POC_DIR=docs/design/byok-confluence-import/poc
32+
33+
# 1. Full crawl (run 1)
34+
python3 $POC_DIR/confluence_fetch.py --spaces RHAT --out $RUN_DIR/pages
35+
36+
# 2. Build the vector DB (from the rag-content repo root)
37+
cd ../rag-content
38+
uv run python $POC_DIR/confluence_processor.py \
39+
-f $RUN_DIR/pages -o $RUN_DIR/vector_db -i byok-confluence-poc \
40+
-md embeddings_model -mn sentence-transformers/all-mpnet-base-v2 \
41+
--vector-store-type llamastack-faiss
42+
43+
# 3. Query (workaround script; scripts/query_rag.py hits a
44+
# registered_resources conflict — see incidental findings in the spike doc)
45+
uv run python query_workaround.py "your question here" 3
46+
47+
# 4. Incremental crawl (run 2) — re-uses state.json, fetches only changes
48+
python3 $POC_DIR/confluence_fetch.py --spaces RHAT --out $RUN_DIR/pages --incremental
49+
```
50+
51+
Results and evidence: see `../poc-results/`.
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
#!/usr/bin/env python3
2+
"""PoC: fetch Confluence pages as rendered HTML for the BYOK RAG pipeline.
3+
4+
Fetches all pages from the given Confluence spaces via the v1 REST API
5+
(`body.export_view` = rendered HTML), writes one HTML file per page plus a
6+
`manifest.json` (page id -> title/url/version) consumed by the metadata
7+
processor, and a `state.json` sync watermark.
8+
9+
Modes:
10+
- Full crawl (default, or no state file): enumerate every page in each space.
11+
- Incremental (`--incremental`, requires state file): CQL `lastmodified >=`
12+
watermark to find changed pages, full ID enumeration to detect deletions.
13+
14+
PoC shortcuts (not production shape): credentials read from the Jira CLI
15+
credentials file; no retry/backoff beyond honoring nothing; no attachments.
16+
"""
17+
18+
import argparse
19+
import base64
20+
import datetime
21+
import json
22+
import pathlib
23+
import sys
24+
import urllib.parse
25+
import urllib.request
26+
27+
CRED_FILE = pathlib.Path.home() / ".config/jira/credentials.json"
28+
BASE = "https://redhat.atlassian.net"
29+
EXPAND = "body.export_view,version,space"
30+
# Overlap buffer so CQL minute granularity / timezone skew can't miss edits;
31+
# version comparison deduplicates re-fetches.
32+
WATERMARK_OVERLAP_MIN = 10
33+
34+
35+
def _auth_header() -> str:
36+
creds = json.loads(CRED_FILE.read_text())
37+
token = base64.b64encode(f"{creds['email']}:{creds['token']}".encode()).decode()
38+
return f"Basic {token}"
39+
40+
41+
def api_get(path: str, params: dict | None = None) -> dict:
42+
url = f"{BASE}{path}"
43+
if params:
44+
url += "?" + urllib.parse.urlencode(params)
45+
req = urllib.request.Request(
46+
url, headers={"Authorization": _auth_header(), "Accept": "application/json"}
47+
)
48+
with urllib.request.urlopen(req, timeout=60) as resp:
49+
return json.load(resp)
50+
51+
52+
def list_space_page_ids(space: str) -> dict[str, int]:
53+
"""Enumerate all page ids (id -> version) in a space, no bodies."""
54+
ids: dict[str, int] = {}
55+
start = 0
56+
while True:
57+
res = api_get(
58+
"/wiki/rest/api/content",
59+
{"spaceKey": space, "type": "page", "limit": 100, "start": start,
60+
"expand": "version"},
61+
)
62+
for page in res.get("results", []):
63+
ids[page["id"]] = page["version"]["number"]
64+
if len(res.get("results", [])) < 100:
65+
return ids
66+
start += 100
67+
68+
69+
def fetch_page(page_id: str) -> dict:
70+
return api_get(f"/wiki/rest/api/content/{page_id}", {"expand": EXPAND})
71+
72+
73+
def changed_page_ids(spaces: list[str], since_utc: datetime.datetime) -> set[str]:
74+
"""CQL delta: page ids modified since the watermark (minus overlap)."""
75+
since = since_utc - datetime.timedelta(minutes=WATERMARK_OVERLAP_MIN)
76+
space_list = ",".join(f'"{s}"' for s in spaces)
77+
cql = (
78+
f"space in ({space_list}) and type=page"
79+
f' and lastmodified >= "{since.strftime("%Y/%m/%d %H:%M")}"'
80+
)
81+
ids: set[str] = set()
82+
start = 0
83+
while True:
84+
res = api_get(
85+
"/wiki/rest/api/content/search",
86+
{"cql": cql, "limit": 100, "start": start},
87+
)
88+
ids.update(r["id"] for r in res.get("results", []))
89+
if len(res.get("results", [])) < 100:
90+
return ids
91+
start += 100
92+
93+
94+
def write_page(page: dict, out_dir: pathlib.Path) -> dict:
95+
"""Write one page as HTML; return its manifest entry."""
96+
title = page["title"]
97+
html_body = page["body"]["export_view"]["value"]
98+
doc = (
99+
"<!DOCTYPE html>\n<html>\n<head>\n<meta charset=\"utf-8\">\n"
100+
f"<title>{title}</title>\n</head>\n<body>\n"
101+
f"<h1>{title}</h1>\n{html_body}\n</body>\n</html>\n"
102+
)
103+
filename = f"{page['id']}.html"
104+
(out_dir / filename).write_text(doc, encoding="utf-8")
105+
return {
106+
"id": page["id"],
107+
"title": title,
108+
"url": BASE + "/wiki" + page["_links"]["webui"],
109+
"version": page["version"]["number"],
110+
"when": page["version"]["when"],
111+
"space": page["space"]["key"],
112+
"file": filename,
113+
}
114+
115+
116+
def main() -> int:
117+
parser = argparse.ArgumentParser(description=__doc__)
118+
parser.add_argument("--spaces", nargs="+", required=True, help="Space keys to crawl")
119+
parser.add_argument("--out", required=True, help="Output directory for HTML files")
120+
parser.add_argument("--incremental", action="store_true",
121+
help="Use CQL delta + deletion diff against state file")
122+
args = parser.parse_args()
123+
124+
out_dir = pathlib.Path(args.out)
125+
out_dir.mkdir(parents=True, exist_ok=True)
126+
manifest_path = out_dir / "manifest.json"
127+
state_path = out_dir / "state.json"
128+
129+
manifest: dict[str, dict] = (
130+
json.loads(manifest_path.read_text()) if manifest_path.exists() else {}
131+
)
132+
state: dict = json.loads(state_path.read_text()) if state_path.exists() else {}
133+
run_started = datetime.datetime.now(datetime.timezone.utc)
134+
135+
known_versions: dict[str, int] = state.get("pages", {})
136+
stats = {"fetched": 0, "unchanged": 0, "deleted": 0, "api_calls_saved": 0}
137+
138+
# Current inventory (id -> version) across all spaces: needed for deletion
139+
# detection in both modes, and as the fetch list in full mode.
140+
inventory: dict[str, int] = {}
141+
for space in args.spaces:
142+
inventory.update(list_space_page_ids(space))
143+
144+
if args.incremental and state.get("last_sync"):
145+
watermark = datetime.datetime.fromisoformat(state["last_sync"])
146+
candidates = changed_page_ids(args.spaces, watermark)
147+
# Also anything present in inventory but unknown to state (new pages
148+
# CQL may have missed due to timezone skew).
149+
candidates.update(pid for pid in inventory if pid not in known_versions)
150+
to_fetch = {
151+
pid for pid in candidates
152+
if pid in inventory and inventory[pid] != known_versions.get(pid)
153+
}
154+
stats["unchanged"] = len(inventory) - len(to_fetch)
155+
stats["api_calls_saved"] = stats["unchanged"]
156+
else:
157+
to_fetch = set(inventory)
158+
159+
for pid in sorted(to_fetch):
160+
entry = write_page(fetch_page(pid), out_dir)
161+
manifest[entry["file"]] = entry
162+
stats["fetched"] += 1
163+
print(f" fetched [{entry['space']}] v{entry['version']} {entry['title']}")
164+
165+
# Deletion diff: anything on disk whose page id vanished from inventory.
166+
for filename in list(manifest):
167+
pid = manifest[filename]["id"]
168+
if pid not in inventory:
169+
(out_dir / filename).unlink(missing_ok=True)
170+
del manifest[filename]
171+
stats["deleted"] += 1
172+
print(f" deleted {filename}")
173+
174+
manifest_path.write_text(json.dumps(manifest, indent=2))
175+
state_path.write_text(json.dumps({
176+
"last_sync": run_started.isoformat(),
177+
"spaces": args.spaces,
178+
"pages": inventory,
179+
}, indent=2))
180+
181+
mode = "incremental" if args.incremental and state.get("last_sync") else "full"
182+
print(f"[{mode}] spaces={args.spaces} fetched={stats['fetched']} "
183+
f"unchanged={stats['unchanged']} deleted={stats['deleted']} "
184+
f"total_on_disk={len(manifest)}")
185+
return 0
186+
187+
188+
if __name__ == "__main__":
189+
sys.exit(main())

0 commit comments

Comments
 (0)