File size: 6,825 Bytes
8fdff5c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
"""Audit every URL surfaced by EmpathRAG and report which are live, redirected,
or broken. Goal: catch 404s before students see them in the demo.

Checks:
* All `source_url` values in data/curated/service_graph.jsonl
* All `url` values in the curated corpus (data/curated/indexes/metadata_curated.db)
  if the index has been built locally.

Writes a Markdown report to eval/audit_resource_urls_<timestamp>.md with one
row per URL: HTTP status, final URL after redirects, error if any.

Usage:
    .\\venv\\Scripts\\python.exe eval\\audit_resource_urls.py
"""

from __future__ import annotations

import json
import sqlite3
import sys
import time
from datetime import datetime
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
SERVICE_GRAPH = ROOT / "data" / "curated" / "service_graph.jsonl"
METADATA_DB = ROOT / "data" / "curated" / "indexes" / "metadata_curated.db"

USER_AGENT = (
    "EmpathRAG-URL-Auditor/0.1 (academic prototype; "
    "+https://github.com/MukulRay1603/Empath-RAG)"
)


def head_or_get(url: str, timeout_s: float = 8.0) -> dict:
    """Return {status, final_url, error} for a URL. Tries HEAD then falls back
    to a tiny GET so servers that don't implement HEAD still report cleanly."""
    import urllib.request
    import urllib.error

    if not url or url in {"N/A", "unknown"}:
        return {"status": 0, "final_url": "", "error": "missing_url"}

    def _request(method: str) -> dict:
        req = urllib.request.Request(
            url,
            method=method,
            headers={"User-Agent": USER_AGENT, "Accept": "*/*"},
        )
        try:
            with urllib.request.urlopen(req, timeout=timeout_s) as resp:
                final = resp.geturl()
                code = getattr(resp, "status", 200)
                return {"status": code, "final_url": final, "error": ""}
        except urllib.error.HTTPError as e:
            return {"status": e.code, "final_url": url, "error": f"http_{e.code}"}
        except (urllib.error.URLError, TimeoutError, OSError) as e:
            return {"status": 0, "final_url": url, "error": f"{type(e).__name__}"}

    r = _request("HEAD")
    # Some servers return 405 / 403 for HEAD or block UAs they don't recognize.
    # Try a GET as fallback before declaring the URL broken.
    if r["status"] in (0, 403, 405, 501) or r["status"] >= 500:
        r2 = _request("GET")
        if r2["status"] and 200 <= r2["status"] < 400:
            return r2
        if r["status"] == 0 and r2["status"] != 0:
            return r2
    return r


def load_service_graph_urls() -> list[dict]:
    if not SERVICE_GRAPH.exists():
        return []
    rows: list[dict] = []
    with SERVICE_GRAPH.open("r", encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            obj = json.loads(line)
            rows.append({
                "kind": "service_graph",
                "service_id": obj.get("service_id", ""),
                "resource_name": obj.get("resource_name", ""),
                "url": obj.get("source_url", ""),
                "last_verified": obj.get("last_verified", ""),
            })
    return rows


def load_corpus_urls() -> list[dict]:
    if not METADATA_DB.exists():
        return []
    conn = sqlite3.connect(METADATA_DB)
    conn.row_factory = sqlite3.Row
    try:
        rows = conn.execute(
            "SELECT DISTINCT source_name, url, last_checked FROM chunks WHERE url IS NOT NULL AND url != ''"
        ).fetchall()
    except sqlite3.OperationalError as exc:
        conn.close()
        return [{
            "kind": "corpus",
            "service_id": "",
            "resource_name": "(corpus DB schema mismatch)",
            "url": "",
            "last_verified": str(exc),
        }]
    conn.close()
    return [
        {
            "kind": "corpus",
            "service_id": "",
            "resource_name": r["source_name"] or "",
            "url": r["url"] or "",
            "last_verified": r["last_checked"] or "",
        }
        for r in rows
    ]


def dedupe(rows: list[dict]) -> list[dict]:
    seen: set[str] = set()
    out: list[dict] = []
    for r in rows:
        u = r.get("url", "")
        if not u or u in seen:
            continue
        seen.add(u)
        out.append(r)
    return out


def main() -> int:
    rows = dedupe(load_service_graph_urls() + load_corpus_urls())
    print(f"[audit] {len(rows)} unique URLs to check\n")

    results: list[dict] = []
    for i, row in enumerate(rows, 1):
        url = row["url"]
        r = head_or_get(url)
        result = {**row, **r}
        results.append(result)
        status = r["status"] or "?"
        flag = "ok " if (isinstance(status, int) and 200 <= status < 400) else "FAIL"
        # Mark obvious redirects (final_url differs from start)
        redir = " (redirect)" if r["final_url"] and r["final_url"] != url and 200 <= (status if isinstance(status, int) else 0) < 400 else ""
        print(f"  [{flag}] {status:>3} {url[:90]}{redir}")
        if r["error"]:
            print(f"        error: {r['error']}")
        time.sleep(0.15)  # be polite to servers

    broken = [r for r in results if not (isinstance(r["status"], int) and 200 <= r["status"] < 400)]
    print(f"\n[summary] {len(results) - len(broken)}/{len(results)} live · {len(broken)} broken or unreachable\n")
    if broken:
        print("Broken URLs:")
        for b in broken:
            print(f"  - [{b['kind']}] {b['resource_name'] or b['service_id']} -> {b['url']} ({b['error'] or b['status']})")

    ts = datetime.now().strftime("%Y%m%d_%H%M%S")
    report_path = ROOT / "eval" / f"audit_resource_urls_{ts}.md"
    with report_path.open("w", encoding="utf-8") as f:
        f.write(f"# Resource URL audit — {ts}\n\n")
        f.write(f"Total unique URLs: {len(results)}. Live: {len(results) - len(broken)}. Broken: {len(broken)}.\n\n")
        if broken:
            f.write("## Broken / unreachable\n\n")
            f.write("| Source | Service ID | URL | Status | Error |\n")
            f.write("|---|---|---|---|---|\n")
            for b in broken:
                f.write(f"| {b['resource_name']} | `{b['service_id']}` | <{b['url']}> | {b['status']} | {b['error']} |\n")
            f.write("\n")
        f.write("## All URLs\n\n")
        f.write("| Status | Source | URL | Last verified |\n")
        f.write("|---|---|---|---|\n")
        for r in sorted(results, key=lambda x: (isinstance(x["status"], int) and not (200 <= x["status"] < 400), x["resource_name"] or "")):
            f.write(f"| {r['status']} | {r['resource_name'] or r['service_id']} | <{r['url']}> | {r['last_verified']} |\n")
    print(f"\n[report] {report_path}")
    return 0 if not broken else 1


if __name__ == "__main__":
    sys.exit(main())