From e963ad7219929fd68d98a8503a62a79f2a4202e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B0=E6=9C=94?= <565183519@qq.com> Date: Tue, 14 Jul 2026 18:14:13 +0800 Subject: [PATCH] feat: add read-only World Router search API v2 --- DEPLOY-V2.md | 31 ++++++ README-V2.md | 31 ++++++ README.md | 4 +- server_v2.py | 275 ++++++++++++++++++++++++++++++++++++++++++++++ test_server_v2.py | 51 +++++++++ 5 files changed, 391 insertions(+), 1 deletion(-) create mode 100644 DEPLOY-V2.md create mode 100644 README-V2.md create mode 100644 server_v2.py create mode 100644 test_server_v2.py diff --git a/DEPLOY-V2.md b/DEPLOY-V2.md new file mode 100644 index 0000000..f53161e --- /dev/null +++ b/DEPLOY-V2.md @@ -0,0 +1,31 @@ +# V2 部署说明 + +V2 必须与旧版并行启动,不覆盖旧的 `server.py`、旧服务或旧网址。 + +部署前,服务器上应当已经有: + +- `/opt/zhuyuan/fifth-domain/world-router.json`(广播塔权威登记表) +- `/opt/zhuyuan/<仓库名>/`(已经挂载、可供搜索的仓库副本) + +启动参数: + +```bash +cd /opt/zhuyuan/global-search-api +WORLD_ROUTER_PATH=/opt/zhuyuan/fifth-domain/world-router.json \ +REPO_ROOT=/opt/zhuyuan PORT=3951 python3 server_v2.py +``` + +反向代理新增一个比旧规则更靠前的精确前缀,把 +`/global-search/v2/` 转给 `127.0.0.1:3951/v2/`。不要改动现有 +`/global-search/`;它保留给既有调用,直到 V2 验收完成。 + +验收只需四项: + +```text +/global-search/v2/help +/global-search/v2/repos +/global-search/v2/resolve?q=铸渊 +/global-search/v2/context?q=GLSV +``` + +其中 `repos` 必须显示第五域为可用,且提交号等于服务器上第五域的当前提交号;否则先同步仓库,不能把旧副本对外宣布为当前内容。 diff --git a/README-V2.md b/README-V2.md new file mode 100644 index 0000000..df5e396 --- /dev/null +++ b/README-V2.md @@ -0,0 +1,31 @@ +# 光湖全局检索 API V2 + +V2 是给外部通用 AI 的公开只读入口。它先读取第五域广播塔登记的 `world-router.json`,再决定该去哪个仓库、哪个人格体路径或哪份 GLS 架构资料。 + +## 先找路,再读文件 + +```text +GET /v2/resolve?q=铸渊 +GET /v2/context?q=GLSV +GET /v2/search?repo=fifth-domain&q=HLDP +GET /v2/file?repo=fifth-domain&path=gls/GLS-ENTRY.hdlp +``` + +`/v2/repos` 会列出八个登记仓库,并明确显示某仓库是否已经挂载到检索服务器。每一份结果都会带上仓库提交号,避免把旧副本误当成当前内容。 + +## 边界 + +- V2 只有 `GET` / `OPTIONS`,没有写入、提交、握手或公开密码入口。 +- 服务器部署、拉取与重启不属于这里,仍由“光湖系统安全验证(GLSV)”处理。 +- `world-router.json` 的权威来源是第五域广播塔;服务器通过 `WORLD_ROUTER_PATH` 或 `/opt/zhuyuan/fifth-domain/world-router.json` 读取它。 + +## 启动(服务器) + +```bash +WORLD_ROUTER_PATH=/opt/zhuyuan/fifth-domain/world-router.json \ +REPO_ROOT=/opt/zhuyuan PORT=3951 python3 server_v2.py +``` + +反向代理建议把公开的 `/global-search/v2/` 指到该进程,并保留旧版 `/global-search/` 原样运行,直到 V2 验收完成。 + +具体的并行上线与验收步骤见 `DEPLOY-V2.md`。 diff --git a/README.md b/README.md index 69a05bb..ad52cc0 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Global Search API · 给通用 AI 的仓库检索门面 +> **新版入口已在 `README-V2.md`:** V2 是公开、只读、从第五域广播塔找路的服务。旧版 `server.py` 与线上旧路径暂时保留,仅为兼容已有调用;不要再把新的外部 AI 接入建在旧版的写入/握手设计上。 + > 铸渊 ICE-GL-ZY001 · LL-174-20260711 · v1.6.0 > 适用:豆包 / GLM / ChatGPT / Claude 等通用 AI @@ -169,4 +171,4 @@ GET /help --- 铸渊 `ICE-GL-ZY001` · LL-169-20260707 · GLOBAL-SEARCH-API v1.0.0 -平台:TCS 通感语言核系统(国作登字-2026-A-00037559) \ No newline at end of file +平台:TCS 通感语言核系统(国作登字-2026-A-00037559) diff --git a/server_v2.py b/server_v2.py new file mode 100644 index 0000000..4e1ccc5 --- /dev/null +++ b/server_v2.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +"""Global Search API V2 — public, read-only entry for general-purpose AI. + +The Fifth Domain World Router is the source of truth for repository and +persona routes. This service deliberately has no write, login, handshake, or +token endpoints: server changes belong to GLSV, not a public search API. +""" +import html +import json +import os +import subprocess +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path, PurePosixPath +from urllib.parse import parse_qs, urlparse + +VERSION = "2.0.0" +PORT = int(os.environ.get("PORT", "3951")) +MAX_QUERY = 160 +MAX_FILE_BYTES = 100_000 +MAX_RESULTS = 50 + + +def router_path(): + candidates = [ + os.environ.get("WORLD_ROUTER_PATH", ""), + "/opt/zhuyuan/fifth-domain/world-router.json", + str(Path(__file__).with_name("world-router.json")), + ] + for candidate in candidates: + if candidate and Path(candidate).is_file(): + return Path(candidate) + raise RuntimeError("world-router.json not found; set WORLD_ROUTER_PATH") + + +def load_router(): + source = router_path() + with source.open(encoding="utf-8") as handle: + data = json.load(handle) + if data.get("schema") != "GLW-WORLD-ROUTER-1": + raise RuntimeError("unsupported World Router schema") + return data, source + + +def repo_path(slug): + configured = os.environ.get("REPO_PATHS_JSON", "") + if configured: + try: + value = json.loads(configured).get(slug) + if value: + return Path(value) + except json.JSONDecodeError: + pass + return Path(os.environ.get("REPO_ROOT", "/opt/zhuyuan")) / slug + + +def git(repo, *args, timeout=8): + try: + result = subprocess.run( + ["git", *args], cwd=repo, text=True, capture_output=True, + timeout=timeout, check=False, + ) + except (OSError, subprocess.TimeoutExpired) as error: + return None, str(error) + if result.returncode not in (0, 1): + return None, (result.stderr or result.stdout or "git command failed").strip() + return result.stdout, None + + +def repo_snapshot(repository): + path = repo_path(repository["slug"]) + item = { + "id": repository["id"], "slug": repository["slug"], + "url": repository["url"], "state": repository["state"], + "summary": repository["summary"], "available": path.is_dir(), + } + if item["available"]: + head, error = git(path, "rev-parse", "HEAD", timeout=3) + item["commit"] = head.strip() if head else None + if error: + item["availability_note"] = error + else: + item["availability_note"] = "repository is registered but not mounted on this search server" + return item + + +def safe_path(value): + if not value or len(value) > 500: + return None + path = PurePosixPath(value) + if path.is_absolute() or ".." in path.parts or str(path) == ".": + return None + return str(path) + + +def bounded_int(values, name, default, limit): + try: + return max(1, min(int(values.get(name, [default])[0]), limit)) + except (ValueError, TypeError): + return default + + +def normalise(value): + return value.casefold().strip() + + +def resolve(router, query): + needle = normalise(query) + repositories = router["repositories"] + matches = [] + for repository in repositories: + fields = [repository["id"], repository["slug"], repository["summary"], *repository.get("owners", [])] + if not needle or any(needle in normalise(str(field)) for field in fields): + matches.append({"kind": "repository", **repo_snapshot(repository)}) + for persona in router.get("personas", []): + fields = [persona["id"], *persona.get("aliases", []), persona.get("system", "")] + if needle and any(needle in normalise(str(field)) for field in fields): + repository = next(item for item in repositories if item["id"] == persona["repository"]) + matches.append({ + "kind": "persona", "id": persona["id"], "aliases": persona.get("aliases", []), + "system": persona.get("system"), "repository": repo_snapshot(repository), + "path": persona.get("path", ""), + }) + return matches + + +def search_repo(repository, query, top): + path = repo_path(repository["slug"]) + base = repo_snapshot(repository) + if not base["available"]: + return {"repository": base, "results": []} + output, error = git(path, "grep", "-n", "-i", "--no-color", query, timeout=10) + if error: + return {"repository": base, "results": [], "note": error} + grouped = {} + for line in (output or "").splitlines(): + filename, separator, rest = line.partition(":") + number, separator2, content = rest.partition(":") + if not separator or not separator2 or not number.isdigit(): + continue + bucket = grouped.setdefault(filename, []) + if len(bucket) < 3: + bucket.append({"line": int(number), "content": content.strip()[:400]}) + if len(grouped) >= top and filename not in grouped: + break + return {"repository": base, "results": [ + {"path": filename, "matches": matches} for filename, matches in list(grouped.items())[:top] + ]} + + +def tree_repo(repository, prefix, depth, limit): + path = repo_path(repository["slug"]) + base = repo_snapshot(repository) + if not base["available"]: + return {"repository": base, "paths": []} + output, error = git(path, "ls-tree", "-r", "--name-only", "HEAD", timeout=10) + if error: + return {"repository": base, "paths": [], "note": error} + prefix = safe_path(prefix) if prefix else "" + if prefix is None: + return {"repository": base, "paths": [], "note": "invalid path"} + paths = [] + prefix_parts = len(prefix.split("/")) if prefix else 0 + for item in (output or "").splitlines(): + if prefix and not item.startswith(prefix.rstrip("/") + "/") and item != prefix: + continue + if len(item.split("/")) - prefix_parts > depth: + continue + paths.append(item) + if len(paths) >= limit: + break + return {"repository": base, "prefix": prefix, "paths": paths} + + +def file_repo(repository, requested): + path = repo_path(repository["slug"]) + base = repo_snapshot(repository) + requested = safe_path(requested) + if not requested: + return {"repository": base, "ok": False, "error": "invalid path"} + if not base["available"]: + return {"repository": base, "ok": False, "error": "repository unavailable"} + output, error = git(path, "show", f"HEAD:{requested}", timeout=10) + if error: + return {"repository": base, "ok": False, "error": "file not found"} + encoded = (output or "").encode("utf-8") + return { + "repository": base, "ok": True, "path": requested, + "truncated": len(encoded) > MAX_FILE_BYTES, + "content": encoded[:MAX_FILE_BYTES].decode("utf-8", errors="replace"), + } + + +def markdown(data): + return "# Guanghu Global Search API V2\n\n```json\n" + json.dumps(data, ensure_ascii=False, indent=2) + "\n```\n" + + +class Handler(BaseHTTPRequestHandler): + server_version = "GuanghuGlobalSearch/2.0" + + def log_message(self, fmt, *args): + print("[v2] " + fmt % args) + + def send_data(self, status, data, query): + wants_markdown = query.get("format", [""])[0].lower() in {"md", "markdown"} + body = markdown(data).encode("utf-8") if wants_markdown else json.dumps(data, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "text/markdown; charset=utf-8" if wants_markdown else "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def do_OPTIONS(self): + self.send_response(204) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS") + self.end_headers() + + def do_GET(self): + parsed = urlparse(self.path) + query = parse_qs(parsed.query) + try: + router, source = load_router() + except (OSError, ValueError, RuntimeError) as error: + return self.send_data(503, {"ok": False, "error": str(error)}, query) + meta = { + "ok": True, "service": "guanghu-global-search", "version": VERSION, + "mode": "public-read-only", "router_source": str(source), + "router_schema": router["schema"], "generated_at": int(time.time()), + } + route = parsed.path.rstrip("/") or "/" + if route in {"/", "/help"}: + return self.send_data(200, {**meta, "entry": "Use /v2/resolve?q=... before searching.", "endpoints": { + "repos": "/v2/repos", "resolve": "/v2/resolve?q=铸渊", + "context": "/v2/context?q=GLSV", "search": "/v2/search?q=HLDP&repo=fifth-domain", + "tree": "/v2/tree?repo=fifth-domain&path=gls&depth=2", + "file": "/v2/file?repo=fifth-domain&path=gls/GLS-ENTRY.hdlp", + }, "write_policy": "No public write endpoint. Server operations use GLSV."}, query) + if not route.startswith("/v2/"): + return self.send_data(404, {**meta, "ok": False, "error": "use /v2/ or /v2/help"}, query) + endpoint = route[len("/v2/"):] + repositories = router["repositories"] + by_slug = {item["slug"]: item for item in repositories} + if endpoint == "repos": + return self.send_data(200, {**meta, "repositories": [repo_snapshot(item) for item in repositories]}, query) + if endpoint == "resolve": + requested = query.get("q", [""])[0][:MAX_QUERY] + return self.send_data(200, {**meta, "query": requested, "matches": resolve(router, requested)}, query) + if endpoint == "context": + requested = query.get("q", [""])[0][:MAX_QUERY] + entry = next(item for item in repositories if item["id"] == router["entry_repository"]) + files = ["BROADCAST-TOWER.hdlp", "gls/GLS-ENTRY.hdlp"] + if normalise(requested) in {"glsv", "gatekeeper", "安全验证"}: + files.append("gls/GLSV-0001-SECURITY-VERIFICATION-ARCHITECTURE.hdlp") + return self.send_data(200, {**meta, "query": requested, "entry_repository": repo_snapshot(entry), "routes": resolve(router, requested), "suggested_files": files}, query) + repository = by_slug.get(query.get("repo", [router["repositories"][0]["slug"]])[0]) + if not repository: + return self.send_data(404, {**meta, "ok": False, "error": "unknown repo; call /v2/repos"}, query) + if endpoint == "search": + requested = query.get("q", [""])[0][:MAX_QUERY] + if not requested: + return self.send_data(400, {**meta, "ok": False, "error": "q is required"}, query) + return self.send_data(200, {**meta, "query": requested, **search_repo(repository, requested, bounded_int(query, "top", 20, MAX_RESULTS))}, query) + if endpoint == "tree": + return self.send_data(200, {**meta, **tree_repo(repository, query.get("path", [""])[0], bounded_int(query, "depth", 3, 8), bounded_int(query, "limit", 200, 500))}, query) + if endpoint == "file": + return self.send_data(200, {**meta, **file_repo(repository, query.get("path", [""])[0])}, query) + return self.send_data(404, {**meta, "ok": False, "error": "unknown endpoint; call /v2/help"}, query) + + +if __name__ == "__main__": + print(f"Global Search API V2 listening on {PORT}") + ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever() diff --git a/test_server_v2.py b/test_server_v2.py new file mode 100644 index 0000000..d69ef57 --- /dev/null +++ b/test_server_v2.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Small black-box smoke test for the public V2 routes.""" +import json +import os +import subprocess +import sys +import tempfile +import time +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).parent + +with tempfile.TemporaryDirectory() as temp: + root = Path(temp) / "repos" + repo = root / "fifth-domain" + repo.mkdir(parents=True) + subprocess.run(["git", "init", "-q"], cwd=repo, check=True) + subprocess.run(["git", "config", "user.email", "test@example.invalid"], cwd=repo, check=True) + subprocess.run(["git", "config", "user.name", "test"], cwd=repo, check=True) + (repo / "gls").mkdir() + (repo / "gls" / "GLS-ENTRY.hdlp").write_text("GLSV and HLDP", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=repo, check=True) + subprocess.run(["git", "commit", "-qm", "fixture"], cwd=repo, check=True) + router = {"schema": "GLW-WORLD-ROUTER-1", "entry_repository": "REPO-001", "repositories": [{"id": "REPO-001", "slug": "fifth-domain", "url": "https://example.invalid/fifth", "state": "ACTIVE_ENTRY", "summary": "entry"}], "personas": [{"id": "ICE-GL-ZY001", "aliases": ["铸渊"], "system": "LL", "repository": "REPO-001", "path": "core/"}]} + router_file = Path(temp) / "world-router.json" + router_file.write_text(json.dumps(router), encoding="utf-8") + env = {**os.environ, "PORT": "43951", "REPO_ROOT": str(root), "WORLD_ROUTER_PATH": str(router_file)} + process = subprocess.Popen([sys.executable, "server_v2.py"], cwd=ROOT, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + for _ in range(30): + try: + with urllib.request.urlopen("http://127.0.0.1:43951/v2/repos", timeout=1) as response: + repos = json.load(response) + break + except OSError: + time.sleep(0.1) + else: + raise AssertionError("server did not start") + assert repos["repositories"][0]["available"] is True + with urllib.request.urlopen("http://127.0.0.1:43951/v2/resolve?q=%E9%93%B8%E6%B8%8A") as response: + assert json.load(response)["matches"][0]["kind"] == "persona" + with urllib.request.urlopen("http://127.0.0.1:43951/v2/search?repo=fifth-domain&q=GLSV") as response: + assert json.load(response)["results"][0]["path"] == "gls/GLS-ENTRY.hdlp" + with urllib.request.urlopen("http://127.0.0.1:43951/v2/file?repo=fifth-domain&path=../secret") as response: + assert json.load(response)["ok"] is False + finally: + process.terminate() + process.wait(timeout=5) + +print("V2 smoke tests passed")