Make the offline RAG chain observable without widening credential access
Some checks failed
verify / verify (push) Has been cancelled
Some checks failed
verify / verify (push) Has been cancelled
Expose the verified synthetic retrieval path through a typed React client and a non-root Nginx edge while keeping database credentials and data-network reachability out of the browser tier. A fixed-origin gateway preserves request boundaries and now closes upstream streams even when downstream disconnects before body iteration. The deployment ADR and runbooks record the four-network topology and its accepted Web edge-egress risk. Constraint: The previously exposed Bailian key must be revoked and no live provider credential may enter Git, images, logs, or the browser. Rejected: Connect Web directly to the data network | expands lateral reach to PostgreSQL. Rejected: Publish the database-aware API on the edge network | gives a credential-bearing process a public default route. Rejected: Buffer streaming responses in either proxy | prevents incremental chat delivery in the future. Confidence: high Scope-risk: moderate Reversibility: clean Directive: Do not mark Stage 1 or Stage 2 complete until the rotated-key live smoke and remaining stage gates pass. Tested: make verify; 65 backend tests; 14 frontend tests; Docker image build; container health and isolation probes; real HTTP demo/status/search/docs/OpenAPI checks. Not-tested: Live Bailian models, real document ingestion, business chat SSE generation, and final browser screenshot automation because the browser skill runtime was unavailable.
This commit is contained in:
@@ -13,7 +13,10 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: "0.11.3"
|
version: "0.11.3"
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
- name: Install locked backend dependencies
|
- uses: actions/setup-node@v4
|
||||||
run: make backend-sync
|
with:
|
||||||
- name: Verify design, secrets, backend, and schema contracts
|
node-version: "22"
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
- name: Verify design, secrets, backend, frontend, and schema contracts
|
||||||
run: make verify-ci
|
run: make verify-ci
|
||||||
|
|||||||
26
Makefile
26
Makefile
@@ -1,6 +1,7 @@
|
|||||||
.PHONY: setup-hooks check-secrets docs-check links-check verify-design backend-sync \
|
.PHONY: setup-hooks check-secrets docs-check links-check verify-design backend-sync \
|
||||||
backend-format backend-lint backend-type backend-test backend-check compose-check \
|
backend-format backend-lint backend-type backend-test backend-check compose-check \
|
||||||
verify-ci verify
|
frontend-sync frontend-format frontend-lint frontend-type frontend-test \
|
||||||
|
frontend-build frontend-check verify-ci verify
|
||||||
|
|
||||||
setup-hooks:
|
setup-hooks:
|
||||||
git config core.hooksPath .githooks
|
git config core.hooksPath .githooks
|
||||||
@@ -16,6 +17,7 @@ docs-check:
|
|||||||
test -f docs/03-implementation-plan.md
|
test -f docs/03-implementation-plan.md
|
||||||
test -f docs/04-project-todo.md
|
test -f docs/04-project-todo.md
|
||||||
test -f docs/05-stage1-runbook.md
|
test -f docs/05-stage1-runbook.md
|
||||||
|
test -f docs/06-frontend-demo-runbook.md
|
||||||
|
|
||||||
links-check:
|
links-check:
|
||||||
python3 scripts/check_markdown_links.py
|
python3 scripts/check_markdown_links.py
|
||||||
@@ -41,12 +43,32 @@ backend-test:
|
|||||||
|
|
||||||
backend-check: backend-sync backend-format backend-lint backend-type backend-test
|
backend-check: backend-sync backend-format backend-lint backend-type backend-test
|
||||||
|
|
||||||
|
frontend-sync:
|
||||||
|
cd frontend && npm ci
|
||||||
|
|
||||||
|
frontend-format:
|
||||||
|
cd frontend && npm run format:check
|
||||||
|
|
||||||
|
frontend-lint:
|
||||||
|
cd frontend && npm run lint
|
||||||
|
|
||||||
|
frontend-type:
|
||||||
|
cd frontend && npm run typecheck
|
||||||
|
|
||||||
|
frontend-test:
|
||||||
|
cd frontend && npm run test
|
||||||
|
|
||||||
|
frontend-build:
|
||||||
|
cd frontend && npm run build
|
||||||
|
|
||||||
|
frontend-check: frontend-sync frontend-format frontend-lint frontend-type frontend-test frontend-build
|
||||||
|
|
||||||
compose-check:
|
compose-check:
|
||||||
docker compose --env-file .env.example config --quiet
|
docker compose --env-file .env.example config --quiet
|
||||||
|
|
||||||
# CI does not assume its runner exposes a Docker daemon. Schema and Compose
|
# CI does not assume its runner exposes a Docker daemon. Schema and Compose
|
||||||
# security contracts still run in backend-test; local verification also asks the
|
# security contracts still run in backend-test; local verification also asks the
|
||||||
# Docker CLI to render the real Compose model.
|
# Docker CLI to render the real Compose model.
|
||||||
verify-ci: verify-design backend-check
|
verify-ci: verify-design backend-check frontend-check
|
||||||
|
|
||||||
verify: verify-ci compose-check
|
verify: verify-ci compose-check
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
本仓库用于“基于 RAG 的地质找矿知识问答系统构建与应用”毕业设计。系统计划采用 Python/FastAPI 后端、React/TypeScript 前端、PostgreSQL + pgvector 向量存储,以及阿里云百炼的 `text-embedding-v4`、`qwen3-rerank`、`deepseek-v4-flash`。
|
本仓库用于“基于 RAG 的地质找矿知识问答系统构建与应用”毕业设计。系统计划采用 Python/FastAPI 后端、React/TypeScript 前端、PostgreSQL + pgvector 向量存储,以及阿里云百炼的 `text-embedding-v4`、`qwen3-rerank`、`deepseek-v4-flash`。
|
||||||
|
|
||||||
当前处于 Stage 1“模型与数据库 PoC”:设计基线与离线 pgvector/适配器/seed 子闭环可运行,真实百炼 smoke 仍待轮换后的新 Key,React 产品功能尚未开始。完整方案、状态和评测方法见 [docs/README.md](docs/README.md),仓库不会把 PoC 伪装成已经完成的产品。
|
当前处于 Stage 1“模型与数据库 PoC”:设计基线、离线 pgvector/适配器/seed、FastAPI 和 React/Nginx 可视化检索演示均可运行;真实百炼 smoke 仍待轮换后的新 Key,正式知识库、文档入库和生成问答尚未实现。完整方案、状态和评测方法见 [docs/README.md](docs/README.md),仓库不会把离线演示伪装成已经完成的产品。
|
||||||
|
|
||||||
## 目录
|
## 目录
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
- 百炼 API Key 只通过 Docker Secret、环境变量或云密钥管理服务注入。
|
- 百炼 API Key 只通过 Docker Secret、环境变量或云密钥管理服务注入。
|
||||||
- 真实 `.env`、`secrets/`、原始受限资料和私有评测数据一律禁止提交。
|
- 真实 `.env`、`secrets/`、原始受限资料和私有评测数据一律禁止提交。
|
||||||
- 前端不得接触模型密钥;所有模型请求只能由后端发起。
|
- 前端不得接触模型密钥;所有模型请求只能由后端发起。
|
||||||
- 每次提交前运行 `make verify`。当前设计阶段它执行文档、链接、diff 和密钥检查;代码落地后将扩展为后端/前端 lint、类型、测试和镜像检查。首次克隆后运行 `make setup-hooks` 启用提交前密钥检查。
|
- 每次提交前运行 `make verify`。它统一执行文档、链接、diff、密钥、后端与前端格式、类型、测试、构建和 Compose 配置检查。首次克隆后运行 `make setup-hooks` 启用提交前密钥检查。
|
||||||
- 若密钥曾出现在聊天、日志、终端历史或提交中,立即在百炼控制台重置;仅从 Git 历史删除文件并不能让密钥恢复安全。
|
- 若密钥曾出现在聊天、日志、终端历史或提交中,立即在百炼控制台重置;仅从 Git 历史删除文件并不能让密钥恢复安全。
|
||||||
|
|
||||||
## 阶段交付规则
|
## 阶段交付规则
|
||||||
@@ -44,19 +44,19 @@
|
|||||||
- [实施计划与验收](docs/03-implementation-plan.md)
|
- [实施计划与验收](docs/03-implementation-plan.md)
|
||||||
- [全生命周期 TODO 与进度](docs/04-project-todo.md)
|
- [全生命周期 TODO 与进度](docs/04-project-todo.md)
|
||||||
- [Stage 1 PoC 运行手册](docs/05-stage1-runbook.md)
|
- [Stage 1 PoC 运行手册](docs/05-stage1-runbook.md)
|
||||||
|
- [React 离线检索演示运行手册](docs/06-frontend-demo-runbook.md)
|
||||||
- [架构决策记录](docs/adr/README.md)
|
- [架构决策记录](docs/adr/README.md)
|
||||||
|
|
||||||
## 当前可运行入口
|
## 当前可运行入口
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make setup-hooks
|
make setup-hooks
|
||||||
make backend-sync
|
|
||||||
bash scripts/init-local-secrets.sh
|
bash scripts/init-local-secrets.sh
|
||||||
docker compose up -d --build gateway
|
docker compose up -d --build web
|
||||||
docker compose --profile tools run --rm seed-demo-offline
|
docker compose --profile tools run --rm seed-demo-offline
|
||||||
curl http://127.0.0.1:8000/health/ready
|
curl http://127.0.0.1:8000/health/ready
|
||||||
curl http://127.0.0.1:8000/api/v1/demo/status
|
curl http://127.0.0.1:8000/api/v1/demo/status
|
||||||
make verify
|
make verify
|
||||||
```
|
```
|
||||||
|
|
||||||
这组命令不需要百炼 Key,只使用 20 条虚构数据验证 pgvector 写入、检索、重排和幂等。`gateway` 会依次启动数据库、迁移和内部 API;启动后可在 <http://127.0.0.1:8000/docs> 查看 FastAPI Swagger,并通过 `POST /api/v1/demo/search` 体验只读离线检索。真实模型运行必须先轮换聊天中已暴露的旧 Key,再按 [Stage 1 运行手册](docs/05-stage1-runbook.md) 操作。
|
这组命令不需要百炼 Key,只使用 20 条虚构数据验证 pgvector 写入、检索、重排、引用编号和同源 Web 交互。`web` 会依次启动数据库、迁移、内部 API 和 gateway;启动后访问 <http://127.0.0.1:8000/> 使用 React 演示,或访问 <http://127.0.0.1:8000/docs> 查看 FastAPI Swagger。真实模型运行必须先轮换聊天中已暴露的旧 Key,再按 [Stage 1 运行手册](docs/05-stage1-runbook.md) 操作。
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ from contextlib import asynccontextmanager
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import FastAPI, Request, Response, status
|
from fastapi import FastAPI, Request, Response, status
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
|
from starlette.types import Receive, Scope, Send
|
||||||
|
|
||||||
UPSTREAM_ORIGIN = httpx.URL("http://api:8000")
|
UPSTREAM_ORIGIN = httpx.URL("http://api:8000")
|
||||||
MAX_REQUEST_BODY_BYTES = 1024 * 1024
|
MAX_REQUEST_BODY_BYTES = 1024 * 1024
|
||||||
@@ -53,6 +54,27 @@ RESPONSE_HEADER_ALLOWLIST = frozenset(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _UpstreamStreamingResponse(StreamingResponse):
|
||||||
|
"""Close the upstream response even if downstream headers cannot be sent."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
upstream_response: httpx.Response,
|
||||||
|
content: AsyncIterator[bytes],
|
||||||
|
status_code: int,
|
||||||
|
headers: Mapping[str, str],
|
||||||
|
) -> None:
|
||||||
|
self._upstream_response = upstream_response
|
||||||
|
super().__init__(content=content, status_code=status_code, headers=headers)
|
||||||
|
|
||||||
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
|
try:
|
||||||
|
await super().__call__(scope, receive, send)
|
||||||
|
finally:
|
||||||
|
await self._upstream_response.aclose()
|
||||||
|
|
||||||
|
|
||||||
def _allowlisted_headers(headers: Mapping[str, str], allowlist: frozenset[str]) -> dict[str, str]:
|
def _allowlisted_headers(headers: Mapping[str, str], allowlist: frozenset[str]) -> dict[str, str]:
|
||||||
return {name: value for name, value in headers.items() if name.lower() in allowlist}
|
return {name: value for name, value in headers.items() if name.lower() in allowlist}
|
||||||
|
|
||||||
@@ -139,15 +161,23 @@ def create_gateway_app(transport: httpx.AsyncBaseTransport | None = None) -> Fas
|
|||||||
content=body,
|
content=body,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
upstream_response = await upstream_client.send(upstream_request)
|
upstream_response = await upstream_client.send(upstream_request, stream=True)
|
||||||
except httpx.RequestError:
|
except httpx.RequestError:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
content={"detail": "upstream unavailable"},
|
content={"detail": "upstream unavailable"},
|
||||||
)
|
)
|
||||||
|
|
||||||
return Response(
|
async def response_body() -> AsyncIterator[bytes]:
|
||||||
content=upstream_response.content,
|
if upstream_response.is_stream_consumed:
|
||||||
|
yield upstream_response.content
|
||||||
|
else:
|
||||||
|
async for chunk in upstream_response.aiter_raw():
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
return _UpstreamStreamingResponse(
|
||||||
|
upstream_response=upstream_response,
|
||||||
|
content=response_body(),
|
||||||
status_code=upstream_response.status_code,
|
status_code=upstream_response.status_code,
|
||||||
headers=_allowlisted_headers(
|
headers=_allowlisted_headers(
|
||||||
upstream_response.headers,
|
upstream_response.headers,
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ from app.persistence.job_queue_sql import ( # noqa: E402
|
|||||||
|
|
||||||
COMPOSE = (ROOT / "compose.yaml").read_text(encoding="utf-8")
|
COMPOSE = (ROOT / "compose.yaml").read_text(encoding="utf-8")
|
||||||
DOCKERFILE = (BACKEND / "Dockerfile").read_text(encoding="utf-8")
|
DOCKERFILE = (BACKEND / "Dockerfile").read_text(encoding="utf-8")
|
||||||
|
FRONTEND_DOCKERFILE = (ROOT / "frontend/Dockerfile").read_text(encoding="utf-8")
|
||||||
|
NGINX = (ROOT / "frontend/nginx.conf").read_text(encoding="utf-8")
|
||||||
DEMO_API = (BACKEND / "app/api/v1/demo.py").read_text(encoding="utf-8")
|
DEMO_API = (BACKEND / "app/api/v1/demo.py").read_text(encoding="utf-8")
|
||||||
BOOTSTRAP = (ROOT / "ops/postgres/init/10-bootstrap-rag.sh").read_text(encoding="utf-8")
|
BOOTSTRAP = (ROOT / "ops/postgres/init/10-bootstrap-rag.sh").read_text(encoding="utf-8")
|
||||||
MIGRATION = (BACKEND / "migrations/versions/0001_initial_schema.py").read_text(encoding="utf-8")
|
MIGRATION = (BACKEND / "migrations/versions/0001_initial_schema.py").read_text(encoding="utf-8")
|
||||||
@@ -35,6 +37,7 @@ def test_compose_isolates_database_credentials_and_networks() -> None:
|
|||||||
migrate = _service_block("migrate")
|
migrate = _service_block("migrate")
|
||||||
api = _service_block("api")
|
api = _service_block("api")
|
||||||
gateway = _service_block("gateway")
|
gateway = _service_block("gateway")
|
||||||
|
web = _service_block("web")
|
||||||
provider_smoke = _service_block("provider-smoke")
|
provider_smoke = _service_block("provider-smoke")
|
||||||
seed_demo = _service_block("seed-demo")
|
seed_demo = _service_block("seed-demo")
|
||||||
seed_demo_offline = _service_block("seed-demo-offline")
|
seed_demo_offline = _service_block("seed-demo-offline")
|
||||||
@@ -59,15 +62,29 @@ def test_compose_isolates_database_credentials_and_networks() -> None:
|
|||||||
assert "no-new-privileges:true" in api
|
assert "no-new-privileges:true" in api
|
||||||
assert "cap_drop:" in api and " - ALL" in api
|
assert "cap_drop:" in api and " - ALL" in api
|
||||||
|
|
||||||
assert '"127.0.0.1:8000:8000"' in gateway
|
assert '"127.0.0.1:8000:8000"' not in gateway
|
||||||
assert " - edge" in gateway
|
assert " - ingress" in gateway
|
||||||
assert " - data" in gateway
|
assert " - data" in gateway
|
||||||
|
assert " - edge" not in gateway
|
||||||
|
assert " - egress" not in gateway
|
||||||
assert "secrets:" not in gateway
|
assert "secrets:" not in gateway
|
||||||
assert "POSTGRES_" not in gateway
|
assert "POSTGRES_" not in gateway
|
||||||
assert "BAILIAN_" not in gateway
|
assert "BAILIAN_" not in gateway
|
||||||
assert "read_only: true" in gateway
|
assert "read_only: true" in gateway
|
||||||
assert "no-new-privileges:true" in gateway
|
assert "no-new-privileges:true" in gateway
|
||||||
|
|
||||||
|
assert '"127.0.0.1:8000:8080"' in web
|
||||||
|
assert " - edge" in web
|
||||||
|
assert " - ingress" in web
|
||||||
|
assert " - data" not in web
|
||||||
|
assert " - egress" not in web
|
||||||
|
assert "secrets:" not in web
|
||||||
|
assert "POSTGRES_" not in web
|
||||||
|
assert "BAILIAN_" not in web
|
||||||
|
assert "read_only: true" in web
|
||||||
|
assert "no-new-privileges:true" in web
|
||||||
|
assert len(re.findall(r"(?m)^ ports:$", COMPOSE)) == 1
|
||||||
|
|
||||||
assert "bailian_api_key" in provider_smoke
|
assert "bailian_api_key" in provider_smoke
|
||||||
assert "postgres_" not in provider_smoke
|
assert "postgres_" not in provider_smoke
|
||||||
|
|
||||||
@@ -83,11 +100,15 @@ def test_compose_isolates_database_credentials_and_networks() -> None:
|
|||||||
assert "./data/samples/public:/demo:ro" in seed_demo_offline
|
assert "./data/samples/public:/demo:ro" in seed_demo_offline
|
||||||
|
|
||||||
assert re.search(r"(?ms)^ data:\n.*?^ internal: true$", COMPOSE)
|
assert re.search(r"(?ms)^ data:\n.*?^ internal: true$", COMPOSE)
|
||||||
|
assert re.search(r"(?ms)^ ingress:\n.*?^ internal: true$", COMPOSE)
|
||||||
assert re.search(r"(?m)^ edge:$", COMPOSE)
|
assert re.search(r"(?m)^ edge:$", COMPOSE)
|
||||||
assert re.search(r"(?m)^ egress:$", COMPOSE)
|
assert re.search(r"(?m)^ egress:$", COMPOSE)
|
||||||
|
|
||||||
assert "USER 10001:10001" in DOCKERFILE
|
assert "USER 10001:10001" in DOCKERFILE
|
||||||
assert 'CMD ["uvicorn", "app.main:app"' in DOCKERFILE
|
assert 'CMD ["uvicorn", "app.main:app"' in DOCKERFILE
|
||||||
|
assert "USER 101:101" in FRONTEND_DOCKERFILE
|
||||||
|
assert "proxy_buffering off" in NGINX
|
||||||
|
assert "server gateway:8000" in NGINX
|
||||||
|
|
||||||
|
|
||||||
def test_demo_queries_pin_governed_offline_identity() -> None:
|
def test_demo_queries_pin_governed_offline_identity() -> None:
|
||||||
|
|||||||
@@ -1,16 +1,38 @@
|
|||||||
import json
|
import json
|
||||||
from collections.abc import AsyncIterator, Callable
|
from collections.abc import AsyncIterator, Callable
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
from starlette.requests import ClientDisconnect
|
||||||
|
from starlette.types import Message, Scope
|
||||||
|
|
||||||
from app.gateway import MAX_REQUEST_BODY_BYTES, create_gateway_app
|
from app.gateway import MAX_REQUEST_BODY_BYTES, create_gateway_app
|
||||||
|
|
||||||
type Handler = Callable[[httpx.Request], httpx.Response]
|
type Handler = Callable[[httpx.Request], httpx.Response]
|
||||||
|
|
||||||
|
|
||||||
|
class _TwoEventStream(httpx.AsyncByteStream):
|
||||||
|
async def __aiter__(self) -> AsyncIterator[bytes]:
|
||||||
|
yield b"data: first\n\n"
|
||||||
|
yield b"data: second\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
class _TrackedStream(httpx.AsyncByteStream):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.closed = False
|
||||||
|
self.started = False
|
||||||
|
|
||||||
|
async def __aiter__(self) -> AsyncIterator[bytes]:
|
||||||
|
self.started = True
|
||||||
|
yield b"data: never-delivered\n\n"
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def _gateway_client(handler: Handler) -> AsyncIterator[httpx.AsyncClient]:
|
async def _gateway_client(handler: Handler) -> AsyncIterator[httpx.AsyncClient]:
|
||||||
gateway = create_gateway_app(httpx.MockTransport(handler))
|
gateway = create_gateway_app(httpx.MockTransport(handler))
|
||||||
@@ -161,3 +183,105 @@ async def test_sensitive_upstream_response_headers_are_not_forwarded() -> None:
|
|||||||
assert "set-cookie" not in response.headers
|
assert "set-cookie" not in response.headers
|
||||||
assert "server" not in response.headers
|
assert "server" not in response.headers
|
||||||
assert "x-internal-token" not in response.headers
|
assert "x-internal-token" not in response.headers
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sse_events_are_forwarded_as_separate_asgi_body_chunks() -> None:
|
||||||
|
def handler(_: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
headers={"content-type": "text/event-stream"},
|
||||||
|
stream=_TwoEventStream(),
|
||||||
|
)
|
||||||
|
|
||||||
|
gateway = create_gateway_app(httpx.MockTransport(handler))
|
||||||
|
sent: list[Message] = []
|
||||||
|
request_delivered = False
|
||||||
|
|
||||||
|
async def receive() -> Message:
|
||||||
|
nonlocal request_delivered
|
||||||
|
if not request_delivered:
|
||||||
|
request_delivered = True
|
||||||
|
return {"type": "http.request", "body": b"", "more_body": False}
|
||||||
|
return {"type": "http.disconnect"}
|
||||||
|
|
||||||
|
async def send(message: Message) -> None:
|
||||||
|
sent.append(message)
|
||||||
|
|
||||||
|
scope = cast(
|
||||||
|
Scope,
|
||||||
|
{
|
||||||
|
"type": "http",
|
||||||
|
"asgi": {"version": "3.0", "spec_version": "2.4"},
|
||||||
|
"http_version": "1.1",
|
||||||
|
"method": "GET",
|
||||||
|
"scheme": "http",
|
||||||
|
"path": "/api/v1/chat/stream",
|
||||||
|
"raw_path": b"/api/v1/chat/stream",
|
||||||
|
"query_string": b"",
|
||||||
|
"headers": [(b"accept", b"text/event-stream")],
|
||||||
|
"client": ("127.0.0.1", 12345),
|
||||||
|
"server": ("gateway", 80),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async with gateway.router.lifespan_context(gateway):
|
||||||
|
await gateway(scope, receive, send)
|
||||||
|
|
||||||
|
body_messages = [message for message in sent if message["type"] == "http.response.body"]
|
||||||
|
assert [message["body"] for message in body_messages] == [
|
||||||
|
b"data: first\n\n",
|
||||||
|
b"data: second\n\n",
|
||||||
|
b"",
|
||||||
|
]
|
||||||
|
assert [message["more_body"] for message in body_messages] == [True, True, False]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upstream_stream_closes_when_downstream_disconnects_before_body_iteration() -> None:
|
||||||
|
upstream_stream = _TrackedStream()
|
||||||
|
|
||||||
|
def handler(_: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
headers={"content-type": "text/event-stream"},
|
||||||
|
stream=upstream_stream,
|
||||||
|
)
|
||||||
|
|
||||||
|
gateway = create_gateway_app(httpx.MockTransport(handler))
|
||||||
|
request_delivered = False
|
||||||
|
|
||||||
|
async def receive() -> Message:
|
||||||
|
nonlocal request_delivered
|
||||||
|
if not request_delivered:
|
||||||
|
request_delivered = True
|
||||||
|
return {"type": "http.request", "body": b"", "more_body": False}
|
||||||
|
return {"type": "http.disconnect"}
|
||||||
|
|
||||||
|
async def disconnect_on_response_start(message: Message) -> None:
|
||||||
|
if message["type"] == "http.response.start":
|
||||||
|
raise OSError("downstream disconnected")
|
||||||
|
|
||||||
|
scope = cast(
|
||||||
|
Scope,
|
||||||
|
{
|
||||||
|
"type": "http",
|
||||||
|
"asgi": {"version": "3.0", "spec_version": "2.4"},
|
||||||
|
"http_version": "1.1",
|
||||||
|
"method": "GET",
|
||||||
|
"scheme": "http",
|
||||||
|
"path": "/api/v1/chat/stream",
|
||||||
|
"raw_path": b"/api/v1/chat/stream",
|
||||||
|
"query_string": b"",
|
||||||
|
"headers": [(b"accept", b"text/event-stream")],
|
||||||
|
"client": ("127.0.0.1", 12345),
|
||||||
|
"server": ("gateway", 80),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async with gateway.router.lifespan_context(gateway):
|
||||||
|
with pytest.raises(ClientDisconnect):
|
||||||
|
await gateway(scope, receive, disconnect_on_response_start)
|
||||||
|
|
||||||
|
assert upstream_stream.closed is True
|
||||||
|
assert upstream_stream.started is False
|
||||||
|
|||||||
40
compose.yaml
40
compose.yaml
@@ -124,10 +124,8 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
api:
|
api:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
ports:
|
|
||||||
- "127.0.0.1:8000:8000"
|
|
||||||
networks:
|
networks:
|
||||||
- edge
|
- ingress
|
||||||
- data
|
- data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
@@ -151,6 +149,39 @@ services:
|
|||||||
- ALL
|
- ALL
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
depends_on:
|
||||||
|
gateway:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8000:8080"
|
||||||
|
networks:
|
||||||
|
- edge
|
||||||
|
- ingress
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD
|
||||||
|
- wget
|
||||||
|
- --quiet
|
||||||
|
- --output-document=-
|
||||||
|
- http://127.0.0.1:8080/nginx-health
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
start_period: 5s
|
||||||
|
init: true
|
||||||
|
user: "101:101"
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:rw,noexec,nosuid,size=16m,uid=101,gid=101,mode=1770
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
provider-smoke:
|
provider-smoke:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
@@ -213,6 +244,9 @@ volumes:
|
|||||||
networks:
|
networks:
|
||||||
edge:
|
edge:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
ingress:
|
||||||
|
driver: bridge
|
||||||
|
internal: true
|
||||||
data:
|
data:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
internal: true
|
internal: true
|
||||||
|
|||||||
@@ -132,7 +132,8 @@ deepseek-v4-flash
|
|||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
U["浏览器用户"] -->|"HTTPS / SSE"| W["React + Nginx"]
|
U["浏览器用户"] -->|"HTTPS / SSE"| W["React + Nginx"]
|
||||||
W -->|"/api/v1"| A["FastAPI API"]
|
W -->|"同源 /api"| G["无 Secret 固定上游 Gateway"]
|
||||||
|
G -->|"internal data"| A["FastAPI API"]
|
||||||
A --> D[("PostgreSQL + pgvector")]
|
A --> D[("PostgreSQL + pgvector")]
|
||||||
A --> F[("文件卷 / OSS 适配器")]
|
A --> F[("文件卷 / OSS 适配器")]
|
||||||
A --> E["text-embedding-v4"]
|
A --> E["text-embedding-v4"]
|
||||||
@@ -150,6 +151,7 @@ flowchart LR
|
|||||||
| 组件 | 职责 | 不承担 |
|
| 组件 | 职责 | 不承担 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `web` | React 静态页面、Nginx 代理、SSE 透传 | 模型凭证、业务权限判断 |
|
| `web` | React 静态页面、Nginx 代理、SSE 透传 | 模型凭证、业务权限判断 |
|
||||||
|
| `gateway` | 固定 API 上游、请求大小/头边界、脱敏错误和流式转发 | 数据库凭证、模型凭证、业务权限判断 |
|
||||||
| `api` | 认证、知识库、检索、问答、评测 API | 长时间 OCR/批量入库 |
|
| `api` | 认证、知识库、检索、问答、评测 API | 长时间 OCR/批量入库 |
|
||||||
| `worker` | 解析、OCR、分块、向量化、索引、评测批任务 | 对外开放端口 |
|
| `worker` | 解析、OCR、分块、向量化、索引、评测批任务 | 对外开放端口 |
|
||||||
| `db` | 元数据、向量、任务、会话、评测事实来源 | 原始大文件长期存储 |
|
| `db` | 元数据、向量、任务、会话、评测事实来源 | 原始大文件长期存储 |
|
||||||
|
|||||||
@@ -29,9 +29,10 @@ docker compose up -d --build
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `db` | 固定版本 pgvector 镜像 | 元数据、向量、任务、会话、评测 | 仅内部网络 |
|
| `db` | 固定版本 pgvector 镜像 | 元数据、向量、任务、会话、评测 | 仅内部网络 |
|
||||||
| `migrate` | 与后端同镜像 | 运行 Alembic,一次成功退出 | 无 |
|
| `migrate` | 与后端同镜像 | 运行 Alembic,一次成功退出 | 无 |
|
||||||
| `api` | `backend/Dockerfile` | FastAPI、检索、问答、管理 API | 仅由 Web 代理 |
|
| `api` | `backend/Dockerfile` | FastAPI、检索、问答、管理 API | 仅 internal data 网络 |
|
||||||
|
| `gateway` | 与后端同镜像 | 固定上游、请求边界、脱敏错误和流式转发 | 仅 internal ingress/data 网络 |
|
||||||
| `worker` | 同一后端镜像 | 解析、向量化、评测后台任务 | 无 |
|
| `worker` | 同一后端镜像 | 解析、向量化、评测后台任务 | 无 |
|
||||||
| `web` | `frontend/Dockerfile` | Nginx 静态资源和反向代理 | 宿主机 8080/HTTPS |
|
| `web` | `frontend/Dockerfile` | React 静态资源、Nginx 同源入口和 SSE 代理 | `127.0.0.1:8000`;生产部署 HTTPS |
|
||||||
| `ocr-worker` | 可选 profile | PaddleOCR 重型任务 | 无 |
|
| `ocr-worker` | 可选 profile | PaddleOCR 重型任务 | 无 |
|
||||||
| `prometheus/grafana` | 可选 profile | 本地观测 | 默认不对公网 |
|
| `prometheus/grafana` | 可选 profile | 本地观测 | 默认不对公网 |
|
||||||
|
|
||||||
@@ -116,26 +117,31 @@ services:
|
|||||||
|
|
||||||
api:
|
api:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
command: ["python", "-m", "app.main"]
|
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
depends_on:
|
depends_on:
|
||||||
migrate:
|
migrate:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
environment:
|
environment: *runtime-config
|
||||||
<<: [*runtime-config, *rag-config]
|
|
||||||
APP_SECRET_KEY_FILE: /run/secrets/app_secret_key
|
|
||||||
secrets:
|
secrets:
|
||||||
- postgres_app_password
|
- postgres_app_password
|
||||||
- app_secret_key
|
|
||||||
- bailian_api_key
|
|
||||||
volumes:
|
volumes:
|
||||||
- uploads:/data/uploads
|
- uploads:/data/uploads
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/v1/health/ready', timeout=2)"]
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/ready', timeout=2)"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 12
|
retries: 12
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
networks: [edge, data, egress]
|
networks: [data]
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
gateway:
|
||||||
|
build: ./backend
|
||||||
|
command: ["uvicorn", "app.gateway:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
depends_on:
|
||||||
|
api:
|
||||||
|
condition: service_healthy
|
||||||
|
networks: [ingress, data]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
worker:
|
worker:
|
||||||
@@ -183,11 +189,11 @@ services:
|
|||||||
web:
|
web:
|
||||||
build: ./frontend
|
build: ./frontend
|
||||||
depends_on:
|
depends_on:
|
||||||
api:
|
gateway:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
- "8080:80"
|
- "127.0.0.1:8000:8080"
|
||||||
networks: [edge]
|
networks: [edge, ingress]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
@@ -197,6 +203,9 @@ volumes:
|
|||||||
networks:
|
networks:
|
||||||
edge:
|
edge:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
ingress:
|
||||||
|
driver: bridge
|
||||||
|
internal: true
|
||||||
data:
|
data:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
internal: true
|
internal: true
|
||||||
@@ -210,15 +219,13 @@ secrets:
|
|||||||
file: ./secrets/postgres_migrator_password
|
file: ./secrets/postgres_migrator_password
|
||||||
postgres_app_password:
|
postgres_app_password:
|
||||||
file: ./secrets/postgres_app_password
|
file: ./secrets/postgres_app_password
|
||||||
app_secret_key:
|
|
||||||
file: ./secrets/app_secret_key
|
|
||||||
bailian_api_key:
|
bailian_api_key:
|
||||||
file: ./secrets/bailian_api_key
|
file: ./secrets/bailian_api_key
|
||||||
```
|
```
|
||||||
|
|
||||||
最终实现固定镜像 digest,示意版本只说明可行结构。`migrate` 只有一个实例执行;API 和 Worker 不在启动时并发自动迁移。首次初始化脚本只在 `db` 容器内使用 bootstrap 凭据创建 `vector` 扩展、无超级用户权限的 migrator/app 登录角色和专属 schema;migrator 拥有 schema/DDL,app 仅获运行期所需的 DML/sequence 权限及默认权限,API/Worker 永远不挂载 bootstrap 或 migrator Secret。最后一个初始化脚本只有在全部 SQL 事务和授权成功后,才在 `PGDATA` 内用同文件系统 rename 原子写入 `.rag-bootstrap-complete`;healthcheck 同时检查 `pg_isready` 与该哨兵,防止临时初始化 server 提前放行迁移。备份再使用独立只读角色。Compose 的 `.env` 仅做变量插值,不会自动注入容器,因此可配置的非敏感项和 `*_FILE` 路径通过 YAML anchor 在 `environment` 中完整声明。后端从各自的 `POSTGRES_PASSWORD_FILE` 读取密码后在内存中组装 DSN,不把明文密码放进 `.env` 或 `DATABASE_URL`。
|
最终实现固定镜像 digest,示意版本只说明可行结构。`migrate` 只有一个实例执行;API 和 Worker 不在启动时并发自动迁移。首次初始化脚本只在 `db` 容器内使用 bootstrap 凭据创建 `vector` 扩展、无超级用户权限的 migrator/app 登录角色和专属 schema;migrator 拥有 schema/DDL,app 仅获运行期所需的 DML/sequence 权限及默认权限,API/Worker 永远不挂载 bootstrap 或 migrator Secret。最后一个初始化脚本只有在全部 SQL 事务和授权成功后,才在 `PGDATA` 内用同文件系统 rename 原子写入 `.rag-bootstrap-complete`;healthcheck 同时检查 `pg_isready` 与该哨兵,防止临时初始化 server 提前放行迁移。备份再使用独立只读角色。Compose 的 `.env` 仅做变量插值,不会自动注入容器,因此可配置的非敏感项和 `*_FILE` 路径通过 YAML anchor 在 `environment` 中完整声明。后端从各自的 `POSTGRES_PASSWORD_FILE` 读取密码后在内存中组装 DSN,不把明文密码放进 `.env` 或 `DATABASE_URL`。
|
||||||
|
|
||||||
网络分为三层:`web + api` 位于 `edge`,`api + worker + migrate + db` 位于 `data`,只有 `api + worker` 位于 `egress`。`data` 设置 `internal: true`,所以 Web 无法横向连接数据库;普通 bridge 本身不构成出站域名白名单,生产机仍需用主机防火墙/出口代理仅放行百炼和对象存储域名。
|
网络分为四层:`web` 位于普通 `edge` 和 internal `ingress`;`gateway` 位于 `ingress + data`;API、迁移、Worker 和数据库位于 internal `data`;只有明确需要云调用的一次性工具或未来 Worker 才能加入命名的 `egress`。因此浏览器入口不能横向连接数据库,数据库感知 API 也没有公网默认出口。只有无 Secret 的 Web 容器发布回环端口;gateway 固定上游并继续执行请求边界和脱敏错误契约。详细理由和被否决方案见 [ADR-0004](adr/0004-secretless-web-ingress.md)。必须注意:普通 `edge` bridge 为 Web 提供宿主机端口发布时,也给 Web 留有技术上的默认公网出口;当前接受这一点的前提是 Web 无 Secret、无数据网络、无挂载且代理上游固定。若部署基线要求入口容器也完全禁网,必须在宿主机防火墙/出口代理层阻断,不能把“未加入名为 egress 的网络”误写成“没有外网出口”。普通 bridge 本身也不构成域名白名单,正式联网 Worker 仍需主机防火墙或出口代理只放行百炼和对象存储域名。
|
||||||
|
|
||||||
### 2.3 后台任务为何不用 Redis
|
### 2.3 后台任务为何不用 Redis
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
| 已完成阶段 | Stage 0:仓库、安全和设计基线 |
|
| 已完成阶段 | Stage 0:仓库、安全和设计基线 |
|
||||||
| 整体完成度 | 约 12%,合理区间 10%–15% |
|
| 整体完成度 | 约 12%,合理区间 10%–15% |
|
||||||
| 设计完成度 | 设计基线已完成;实现中发现新约束时继续通过 ADR 和文档修订维护 |
|
| 设计完成度 | 设计基线已完成;实现中发现新约束时继续通过 ADR 和文档修订维护 |
|
||||||
| 业务代码完成度 | 约 10%;Stage 1 的 Docker API、离线检索和数据库 PoC 已验收,真实百炼 smoke 仍待新 Key,前端产品功能尚未实现 |
|
| 业务代码完成度 | 约 18%;Stage 1 的 Docker API、离线检索、数据库 PoC,以及 React 离线演示、Nginx 单入口和四网络隔离前置均已验收;真实百炼 smoke 仍待轮换后的新 Key |
|
||||||
| 当前预计剩余工期 | 约 9–13 周,含 300 题正式标注、盲测、论文和答辩缓冲 |
|
| 当前预计剩余工期 | 约 9–13 周,含 300 题正式标注、盲测、论文和答辩缓冲 |
|
||||||
| 进度权威来源 | 本文的阶段状态、验收证据和已推送提交 |
|
| 进度权威来源 | 本文的阶段状态、验收证据和已推送提交 |
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
| Stage 9 答辩、发布与归档 | 3% | `TODO` | 0% |
|
| Stage 9 答辩、发布与归档 | 3% | `TODO` | 0% |
|
||||||
| **合计** | **100%** | — | **12%** |
|
| **合计** | **100%** | — | **12%** |
|
||||||
|
|
||||||
Stage 1 已完成可离线验收的数据库、适配器契约、synthetic seed 和只读 API 子闭环,阶段内部约完成 90%;但真实三模型 smoke 仍依赖轮换后的新 Key,因此 Stage 1 不提前计入整体里程碑。本文复选框表示“已通过验收”,不表示“文件是否已经出现”。当前可诚实表述为:**设计基线完成,整体里程碑约 12%,Stage 1 离线后端可运行,真实百炼验证尚未完成。**
|
Stage 1 已完成可离线验收的数据库、适配器契约、synthetic seed、只读 API 和 Web 演示子闭环,安全离线子阶段内部约完成 95%;但真实三模型 smoke 仍依赖轮换后的新 Key,因此 Stage 1 不提前计入整体里程碑。Stage 2 中不依赖云密钥的 React、Nginx、网络隔离和质量门禁前置已经完成,但 Worker 租约、完整产品工作流等必选项尚未完成,Stage 2 仍为 `TODO`。本文复选框表示“已通过验收”,不表示“文件是否已经出现”。当前可诚实表述为:**设计基线完成,整体里程碑约 12%,业务代码约 18%,Stage 1 安全离线链路可运行,真实百炼验证尚未完成。**
|
||||||
|
|
||||||
## 2. 阶段依赖与关键路径
|
## 2. 阶段依赖与关键路径
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ Stage 0 DONE
|
|||||||
| 阶段 | 必须依赖 | 可提前并行的工作 | 不得提前做的工作 |
|
| 阶段 | 必须依赖 | 可提前并行的工作 | 不得提前做的工作 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Stage 1 | Stage 0、已轮换 Secret、受控联网 | 虚构数据和 fake server 契约测试 | 使用真实地质资料,或使用已暴露密钥 |
|
| Stage 1 | Stage 0、已轮换 Secret、受控联网 | 虚构数据和 fake server 契约测试 | 使用真实地质资料,或使用已暴露密钥 |
|
||||||
| Stage 2 | Stage 1 三模型和迁移 PoC | 前端静态布局、OpenAPI 生成脚本 | 绕过任务租约直接做不可恢复长任务 |
|
| Stage 2 | Stage 1 三模型和迁移 PoC | React 离线演示、OpenAPI 类型生成、Nginx 单入口和网络隔离前置 | 绕过任务租约直接做不可恢复长任务 |
|
||||||
| Stage 3 | Stage 2 服务骨架 | 合法语料清单、标注规范准备 | 未通过 `CLOUD_APPROVED` 的内容出域 |
|
| Stage 3 | Stage 2 服务骨架 | 合法语料清单、标注规范准备 | 未通过 `CLOUD_APPROVED` 的内容出域 |
|
||||||
| Stage 4 | Stage 3 可追溯切片与向量 | 100 组三元组完善 | 用测试集调相似度、Top K 或阈值 |
|
| Stage 4 | Stage 3 可追溯切片与向量 | 100 组三元组完善 | 用测试集调相似度、Top K 或阈值 |
|
||||||
| Stage 5 | Stage 4 稳定候选和引用 ID | UI 组件、Prompt fake 测试 | 让前端直接接触模型 Key |
|
| Stage 5 | Stage 4 稳定候选和引用 ID | UI 组件、Prompt fake 测试 | 让前端直接接触模型 Key |
|
||||||
@@ -165,7 +165,7 @@ Stage 0 DONE
|
|||||||
- [x] `docker compose run --rm seed-demo-offline` 完成 20 条虚构数据写入、检索和重排;真实模式待新 Key。
|
- [x] `docker compose run --rm seed-demo-offline` 完成 20 条虚构数据写入、检索和重排;真实模式待新 Key。
|
||||||
- [x] `api + gateway` 镜像构建并达到 healthy;live/ready/meta/demo status/demo search 均通过真实 HTTP 验收。
|
- [x] `api + gateway` 镜像构建并达到 healthy;live/ready/meta/demo status/demo search 均通过真实 HTTP 验收。
|
||||||
- [x] 第二次运行 seed 后数据库计数与第一次相同,均为 chunks/vectors/searchable = 20/20/20。
|
- [x] 第二次运行 seed 后数据库计数与第一次相同,均为 chunks/vectors/searchable = 20/20/20。
|
||||||
- [x] 63 项测试证明模型契约、健康检查、固定上游 gateway、离线 demo、数据治理和失败路径正确。
|
- [x] 65 项后端测试证明模型契约、健康检查、固定上游 gateway、SSE 代理分块与早断连释放契约、离线 demo、数据治理和失败路径正确。
|
||||||
- [x] `make verify`、Secret 扫描、固定镜像构建和 `git diff --check` 通过。
|
- [x] `make verify`、Secret 扫描、固定镜像构建和 `git diff --check` 通过。
|
||||||
|
|
||||||
### 2026-07-12 已验证运行证据
|
### 2026-07-12 已验证运行证据
|
||||||
@@ -177,6 +177,10 @@ Stage 0 DONE
|
|||||||
- API 容器对公网 TCP 探测返回 `ENETUNREACH (101)`;gateway 的 mounts 为空且不含数据库或百炼业务环境变量。
|
- API 容器对公网 TCP 探测返回 `ENETUNREACH (101)`;gateway 的 mounts 为空且不含数据库或百炼业务环境变量。
|
||||||
- PostgreSQL 重启后计数仍为 20/20/20。
|
- PostgreSQL 重启后计数仍为 20/20/20。
|
||||||
- app 尝试修改已 `CLOUD_APPROVED` 的 `cloud_text` 被审批不可变触发器拒绝;尝试建表被权限拒绝。
|
- app 尝试修改已 `CLOUD_APPROVED` 的 `cloud_text` 被审批不可变触发器拒绝;尝试建表被权限拒绝。
|
||||||
|
- React + TypeScript strict 离线演示已实现工作台与系统状态页;14 项前端测试、ESLint、类型检查和生产构建通过。
|
||||||
|
- Nginx 成为唯一回环入口,Compose 仅发布 `127.0.0.1:8000`;`web -> gateway -> api` 同源链路、Swagger、OpenAPI、健康检查和 synthetic search 均通过真实 HTTP 验收。
|
||||||
|
- `edge / ingress / data / egress` 四网络边界已落地;Web 不可解析数据库,API 与 gateway 无公网 TCP 出口,Web 与 gateway 均不持有 Secret。
|
||||||
|
- gateway 与 Nginx 已关闭代理缓冲,自动测试证明两个 SSE 事件保持为独立下游分块,且响应头阶段断连也会释放上游流;真实聊天生成与取消传播仍属于 Stage 5,尚未实现。
|
||||||
- 唯一未验收项是真实 `text-embedding-v4` / `qwen3-rerank` / `deepseek-v4-flash` smoke 与真实模式 seed。
|
- 唯一未验收项是真实 `text-embedding-v4` / `qwen3-rerank` / `deepseek-v4-flash` smoke 与真实模式 seed。
|
||||||
|
|
||||||
### 提交/推送节点
|
### 提交/推送节点
|
||||||
@@ -202,6 +206,7 @@ Stage 0 DONE
|
|||||||
- **状态:** `TODO`
|
- **状态:** `TODO`
|
||||||
- **预计工期:** 3–4 个工作日
|
- **预计工期:** 3–4 个工作日
|
||||||
- **依赖:** Stage 1 `DONE`
|
- **依赖:** Stage 1 `DONE`
|
||||||
|
- **已完成前置:** 不依赖云密钥的 React 离线演示、OpenAPI 类型、Nginx 单入口、四网络隔离和当前质量门禁已验收;这不代表 Stage 2 已启动或完成。
|
||||||
|
|
||||||
### 具体实现任务
|
### 具体实现任务
|
||||||
|
|
||||||
@@ -211,26 +216,26 @@ Stage 0 DONE
|
|||||||
- [ ] 建立 PostgreSQL `background_jobs`、租约、lease token、fencing 和重试状态机。
|
- [ ] 建立 PostgreSQL `background_jobs`、租约、lease token、fencing 和重试状态机。
|
||||||
- [ ] 建立 Worker maintenance loop、advisory-lock reaper 和强杀恢复测试。
|
- [ ] 建立 Worker maintenance loop、advisory-lock reaper 和强杀恢复测试。
|
||||||
- [ ] 建立 `/health/live`、`/health/ready` 和管理员模型能力探测端点。
|
- [ ] 建立 `/health/live`、`/health/ready` 和管理员模型能力探测端点。
|
||||||
- [ ] 建立 React + TypeScript strict、路由、TanStack Query 和 OpenAPI 客户端生成。
|
- [x] 建立 React + TypeScript strict、路由、TanStack Query 和 OpenAPI 客户端生成。
|
||||||
- [ ] 建立 Nginx,仅发布 Web 端口;API、Worker、DB 只在内部网络。
|
- [x] 建立 Nginx,仅发布 Web 端口;当前 API、DB 只在内部网络,Worker 落地后必须沿用同一边界。
|
||||||
- [ ] 建立完整 Compose:`web/api/worker/db/migrate`,OCR profile 默认关闭。
|
- [ ] 建立完整 Compose:`web/api/worker/db/migrate`,OCR profile 默认关闭。
|
||||||
- [ ] 建立 Ruff、类型、pytest、ESLint、TypeScript、Vitest 和镜像检查。
|
- [x] 建立当前范围的 Ruff、类型、pytest、ESLint、TypeScript、Vitest 和镜像检查;Stage 2 新模块必须继续纳入门禁。
|
||||||
- [ ] 建立 Gitea CI 的 fake provider 测试;外部 PR 不运行真实 Key 测试。
|
- [x] 建立 Gitea CI 的 fake provider 测试;外部 PR 不运行真实 Key 测试。
|
||||||
|
|
||||||
### 验收证据
|
### 验收证据
|
||||||
|
|
||||||
- [ ] 空白机器仅按 README 执行即可启动完整骨架。
|
- [ ] 空白机器仅按 README 执行即可启动完整骨架。
|
||||||
- [ ] migration 只执行一次,失败时 API readiness 不通过。
|
- [ ] migration 只执行一次,失败时 API readiness 不通过。
|
||||||
- [ ] `docker compose ps` 只暴露 Nginx/Web 端口。
|
- [x] 当前 `docker compose ps` 只暴露 Nginx/Web 回环端口;加入 Worker 后须重新验收。
|
||||||
- [ ] Docker 重启后数据库数据保留。
|
- [ ] Docker 重启后数据库数据保留。
|
||||||
- [ ] 强杀 Worker 后验证可重领、耗尽进入 `FAILED`、旧 token 不能提交。
|
- [ ] 强杀 Worker 后验证可重领、耗尽进入 `FAILED`、旧 token 不能提交。
|
||||||
- [ ] 后端/前端 lint、类型、单元和集成测试均通过。
|
- [x] 当前后端 65 项、前端 14 项测试及两端 lint、类型和构建门禁均通过;Stage 2 新功能仍需增量验证。
|
||||||
|
|
||||||
### 提交/推送节点
|
### 提交/推送节点
|
||||||
|
|
||||||
- [ ] `S2-A`:FastAPI、持久化、健康端点和错误契约。
|
- [ ] `S2-A`:FastAPI、持久化、健康端点和错误契约。
|
||||||
- [ ] `S2-B`:任务租约、reaper、fencing 和恢复测试。
|
- [ ] `S2-B`:任务租约、reaper、fencing 和恢复测试。
|
||||||
- [ ] `S2-C`:React、Nginx、完整 Compose、CI 和文档。
|
- [ ] `S2-C`:React、Nginx、完整 Compose、CI 和文档;其中 React/Nginx/CI 前置已完成,缺 Worker 与完整 Compose 验收,节点不得提前关闭。
|
||||||
- [ ] 每个节点验证后独立提交并推送;Stage 2 完成时在本文更新状态。
|
- [ ] 每个节点验证后独立提交并推送;Stage 2 完成时在本文更新状态。
|
||||||
|
|
||||||
### 风险门禁
|
### 风险门禁
|
||||||
@@ -555,13 +560,13 @@ Stage 0 DONE
|
|||||||
|
|
||||||
## 15. 下一步执行顺序
|
## 15. 下一步执行顺序
|
||||||
|
|
||||||
当前只推进 Stage 1,建议按以下顺序开始:
|
当前主线仍是关闭 Stage 1 的真实供应商门禁;已完成的 React/Nginx 工作仅作为 Stage 2 安全前置保留。建议按以下顺序继续:
|
||||||
|
|
||||||
1. 轮换并受控注入新百炼 Key,确认真实端点不写入仓库。
|
1. 在百炼控制台撤销聊天中暴露的旧 Key,创建新 Key,并仅通过未提交的 Docker Secret 注入。
|
||||||
2. 创建最小 Python 包、数据库初始化、Alembic 和 `db + migrate` Compose。
|
2. 分别运行 `text-embedding-v4`、`qwen3-rerank`、`deepseek-v4-flash` 受控 live smoke,记录脱敏 provider profile、能力指纹、维度、流式协议和错误映射。
|
||||||
3. 用 fake server 实现并验证三模型适配器契约。
|
3. 用现有 20 条 synthetic 数据运行真实 Embedding/Rerank/Chat 链路和幂等 seed;真实地质资料仍不得出域。
|
||||||
4. 运行受控 live smoke,记录 provider profile 和能力指纹。
|
4. 关闭 S1-B 并把 Stage 1 标记为 `DONE` 后,再实现 Stage 2 的 Worker 租约、fencing、reaper、恢复测试和完整 Compose。
|
||||||
5. 用 20 条虚构数据完成写入、检索、重排和幂等闭环。
|
5. Stage 3 实现数字 PDF/DOCX/TXT/Markdown 的隔离解析、页码/章节恢复、审批状态机、切分、向量化写库和可追溯版本激活。
|
||||||
6. 扩展 `make verify`,保存验收证据,完成 S1-A/S1-B/S1-C 提交与推送。
|
6. Stage 4 完成真实向量召回、重排与验证集评测;Stage 5 再实现 grounded chat SSE、取消传播、引用解析和聊天 UI,不把当前代理分块测试误称为聊天功能完成。
|
||||||
|
|
||||||
在 Stage 1 验收之前,不并行展开真实语料入库、完整 UI 或正式验证集调参。
|
在 Stage 1 验收之前,不展开真实语料云端入库或正式验证集调参;前端可继续做不接触 Secret、不会绕过阶段门禁的离线可用性修正。
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ bash scripts/init-local-secrets.sh
|
|||||||
离线模式使用确定性的 1024 维 feature-hash 向量和词法重排器,只验证数据治理、批处理、迁移、向量写入、权限过滤、检索、重排和幂等性,不把它当成真实模型质量。
|
离线模式使用确定性的 1024 维 feature-hash 向量和词法重排器,只验证数据治理、批处理、迁移、向量写入、权限过滤、检索、重排和幂等性,不把它当成真实模型质量。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d --build gateway
|
docker compose up -d --build web
|
||||||
docker compose --profile tools run --rm seed-demo-offline
|
docker compose --profile tools run --rm seed-demo-offline
|
||||||
docker compose --profile tools run --rm seed-demo-offline
|
docker compose --profile tools run --rm seed-demo-offline
|
||||||
```
|
```
|
||||||
@@ -46,7 +46,7 @@ Seed 的实际流程是:
|
|||||||
|
|
||||||
### 2.1 查看后端运行效果
|
### 2.1 查看后端运行效果
|
||||||
|
|
||||||
内部 API 只连接 `internal` data 网络,只挂载 app 数据库 Secret,不连接外网,也不挂载百炼 Key。无 Secret、无数据库凭证的 gateway 同时连接 ingress 与 data 网络,并且仅发布到本机回环地址 `127.0.0.1:8000`。两个容器都以数值非 root 用户运行,根文件系统只读,并移除 Linux capabilities。
|
内部 API 只连接 `internal` data 网络,只挂载 app 数据库 Secret,不连接外网,也不挂载百炼 Key。无 Secret、无数据库凭证的 gateway 连接 internal ingress/data;React/Nginx Web 连接 edge/ingress,并且是唯一发布到本机回环地址 `127.0.0.1:8000` 的容器。三个运行容器均使用非 root 用户、只读根文件系统并移除 Linux capabilities。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://127.0.0.1:8000/health/live
|
curl http://127.0.0.1:8000/health/live
|
||||||
|
|||||||
91
docs/06-frontend-demo-runbook.md
Normal file
91
docs/06-frontend-demo-runbook.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# React 离线检索演示运行手册
|
||||||
|
|
||||||
|
本手册用于启动 React + Nginx + gateway + FastAPI + PostgreSQL/pgvector 的同源离线演示。当前页面只展示合成数据的检索和重排结果,不调用百炼,不生成大模型答案,也不构成地质勘查结论。
|
||||||
|
|
||||||
|
## 1. 一键启动
|
||||||
|
|
||||||
|
首次运行先创建本地数据库 Secret:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make setup-hooks
|
||||||
|
bash scripts/init-local-secrets.sh
|
||||||
|
docker compose up -d --build web
|
||||||
|
docker compose --profile tools run --rm seed-demo-offline
|
||||||
|
```
|
||||||
|
|
||||||
|
`web` 的依赖链会依次等待 `db -> migrate -> api -> gateway`。离线 seed 完成后刷新页面,即可看到 20/20/20 的演示数据状态。
|
||||||
|
|
||||||
|
访问入口:
|
||||||
|
|
||||||
|
- React 演示:<http://127.0.0.1:8000/>
|
||||||
|
- FastAPI Swagger:<http://127.0.0.1:8000/docs>
|
||||||
|
- 数据库 readiness:<http://127.0.0.1:8000/health/ready>
|
||||||
|
- synthetic 状态:<http://127.0.0.1:8000/api/v1/demo/status>
|
||||||
|
|
||||||
|
## 2. 推荐演示流程
|
||||||
|
|
||||||
|
1. 确认页面顶部持续显示“合成数据、离线模式、不调用云端模型”。
|
||||||
|
2. 确认切片、向量和可检索计数均为 20。
|
||||||
|
3. 选择示例问题或输入“花岗斑岩 铜矿化”,设置返回 3 条。
|
||||||
|
4. 提交检索,查看合成标题、证据摘录、页码、演示排序分和不透明 citation ID。
|
||||||
|
5. 说明 citation ID 尚不能打开真实报告页,当前没有 LLM 生成答案。
|
||||||
|
6. 缩窄浏览器到移动宽度,验证表单和来源卡片仍可完整阅读。
|
||||||
|
|
||||||
|
## 3. 本地前端开发
|
||||||
|
|
||||||
|
先让 Docker Web/API 链保持运行,再启动 Vite:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm ci
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Vite 只监听 `127.0.0.1`,并把 `/api`、`/health` 和 `/openapi.json` 代理到本机 Docker Web 入口。前端 API 基址保持同源,构建产物中不存在数据库地址或百炼凭证。
|
||||||
|
|
||||||
|
后端 OpenAPI 改变后重新生成类型:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run generate:api
|
||||||
|
npm run typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
生成脚本会检查 demo status/search 路径是否存在,然后覆盖 `src/api/schema.generated.ts`;生成结果必须随 API 契约变更一起提交。
|
||||||
|
|
||||||
|
## 4. 质量门禁
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make verify
|
||||||
|
docker compose --env-file .env.example ps -a
|
||||||
|
```
|
||||||
|
|
||||||
|
统一门禁包括:
|
||||||
|
|
||||||
|
- Secret、Markdown 链接和 Git diff 检查;
|
||||||
|
- Ruff、严格 mypy 和 pytest;
|
||||||
|
- Prettier、ESLint、严格 TypeScript、Vitest 和 Vite production build;
|
||||||
|
- Compose 配置与网络/Secret 合同测试。
|
||||||
|
|
||||||
|
容器级验收还应确认:
|
||||||
|
|
||||||
|
- 只有 `web` 发布 `127.0.0.1:8000`;
|
||||||
|
- `web` 和 `gateway` 没有 Secret;
|
||||||
|
- `api` 只连接 internal data 网络,公网 socket 探测失败;
|
||||||
|
- `gateway` 公网 socket 探测失败;Web 因普通 edge 网络可技术性出网,但无 Secret、无数据网络和挂载,此残余风险记录于 ADR-0004;
|
||||||
|
- `/api`、健康检查和 Swagger 经 Web 同源代理可用;
|
||||||
|
- SSE 响应在 gateway 与 Nginx 均不被整体缓冲。
|
||||||
|
|
||||||
|
## 5. 停止与清理
|
||||||
|
|
||||||
|
停止容器但保留 PostgreSQL 数据:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
不要在需要保留验收数据时使用 `docker compose down -v`。该命令会删除 PostgreSQL volume。
|
||||||
|
|
||||||
|
## 6. 当前边界
|
||||||
|
|
||||||
|
已实现的是可运行的离线检索演示;尚未实现登录、真实知识库、文件上传、OCR、生成答案、可点击原文页、会话、反馈、正式评测面板和真实百炼模型探测。只有轮换后的新 Key 完成三模型 smoke 后,Stage 1 才能标记为完成,Stage 2 也不能因页面可见而提前标记为完成。
|
||||||
@@ -5,7 +5,9 @@
|
|||||||
- Stage 0“仓库、安全和设计基线”已完成并推送。
|
- Stage 0“仓库、安全和设计基线”已完成并推送。
|
||||||
- Stage 1“模型与数据库 PoC”正在进行。
|
- Stage 1“模型与数据库 PoC”正在进行。
|
||||||
- 当前整体完成度约 12%,合理区间为 10%–15%。
|
- 当前整体完成度约 12%,合理区间为 10%–15%。
|
||||||
- 00–03 的设计基线已完成;业务代码完成度约 8%,Stage 1 离线数据库/适配器/seed 子闭环已验收,真实百炼 smoke 与前端产品功能尚未完成。
|
- 00–03 的设计基线已完成;业务代码完成度约 18%,Stage 1 安全离线子阶段约 95%。
|
||||||
|
- 后端离线链路、20 条 synthetic 数据、React 离线演示、Nginx 单入口和四网络隔离均已验收;真实百炼三模型 smoke 仍待旧 Key 撤销并轮换。
|
||||||
|
- Stage 2 仍为 `TODO`:当前只完成了不依赖云密钥的前置,Worker 租约、完整 Compose 和产品工作流尚未完成。
|
||||||
- 后续状态、依赖、验收证据、工期和提交节点以 [项目全生命周期 TODO](04-project-todo.md) 为准。
|
- 后续状态、依赖、验收证据、工期和提交节点以 [项目全生命周期 TODO](04-project-todo.md) 为准。
|
||||||
|
|
||||||
## 文档列表
|
## 文档列表
|
||||||
@@ -18,8 +20,9 @@
|
|||||||
| [03-implementation-plan.md](03-implementation-plan.md) | 阶段计划、完成定义、测试与验收清单 | 设计基线完成 |
|
| [03-implementation-plan.md](03-implementation-plan.md) | 阶段计划、完成定义、测试与验收清单 | 设计基线完成 |
|
||||||
| [04-project-todo.md](04-project-todo.md) | 从当前状态到最终答辩、发布和归档的任务看板 | Stage 1 进行中 |
|
| [04-project-todo.md](04-project-todo.md) | 从当前状态到最终答辩、发布和归档的任务看板 | Stage 1 进行中 |
|
||||||
| [05-stage1-runbook.md](05-stage1-runbook.md) | Secret 初始化、离线数据库/seed、真实三模型 smoke 和排障命令 | Stage 1 运行手册 |
|
| [05-stage1-runbook.md](05-stage1-runbook.md) | Secret 初始化、离线数据库/seed、真实三模型 smoke 和排障命令 | Stage 1 运行手册 |
|
||||||
|
| [06-frontend-demo-runbook.md](06-frontend-demo-runbook.md) | React/Nginx 离线检索演示、同源 API、开发与验收命令 | 离线前端运行手册 |
|
||||||
| [adr/](adr/) | 不易逆转的架构决策记录 | 持续维护 |
|
| [adr/](adr/) | 不易逆转的架构决策记录 | 持续维护 |
|
||||||
|
|
||||||
文档中的工作空间地址统一使用 `<workspace-id>` 占位符;真实地址和凭证均属于部署配置,不写入仓库。
|
文档中的工作空间地址统一使用 `<workspace-id>` 占位符;真实地址和凭证均属于部署配置,不写入仓库。
|
||||||
|
|
||||||
建议首次阅读按 00 → 01 → 02 → 03 → 04 的顺序;日常执行查看 04,运行 PoC 查看 05。
|
建议首次阅读按 00 → 01 → 02 → 03 → 04 的顺序;日常执行查看 04,数据库/模型 PoC 查看 05,React 演示查看 06。
|
||||||
|
|||||||
48
docs/adr/0004-secretless-web-ingress.md
Normal file
48
docs/adr/0004-secretless-web-ingress.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# ADR-0004:使用无 Secret 的分层 Web 入口
|
||||||
|
|
||||||
|
- **状态:** accepted
|
||||||
|
- **日期:** 2026-07-12
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
系统需要同时满足三个约束:浏览器只能访问一个本机 Web 端口;数据库感知 API 不能获得公网出口;React/Nginx 不能因反向代理而加入数据库所在网络。仅把 API 同时接入普通 `edge` 和内部 `data` 网络会获得公网出口;仅把 API 接入 `internal` 网络时,Docker Desktop 又不会可靠发布宿主机端口。
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
采用四层单机拓扑:
|
||||||
|
|
||||||
|
```text
|
||||||
|
browser
|
||||||
|
-> web (Nginx + React; edge + ingress)
|
||||||
|
-> gateway (fixed-origin HTTP proxy; ingress + data)
|
||||||
|
-> api (FastAPI; data only)
|
||||||
|
-> PostgreSQL/pgvector (data only)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `edge` 是普通 bridge,只连接无 Secret 的 `web`,用于宿主机回环端口发布。
|
||||||
|
- `ingress` 是 `internal` 网络,只连接 `web` 和 `gateway`。
|
||||||
|
- `data` 是 `internal` 网络,只连接 `gateway`、`api`、迁移和数据库等内部组件。
|
||||||
|
- 命名的 `egress` 只供显式云模型工具使用;`web`、`gateway`、`api` 均不连接该网络。普通 `edge` bridge 仍会给 Web 一个技术上的默认公网出口,不能把网络名称当作防火墙。
|
||||||
|
- 只有 `web` 发布 `127.0.0.1:8000`;gateway、API 和数据库不发布端口。
|
||||||
|
- `web` 与 `gateway` 不挂载数据库或百炼 Secret;gateway 固定上游为 `http://api:8000`,执行请求体上限、头白名单、SSRF 防护和脱敏 502 映射。
|
||||||
|
- API 只挂载 app 数据库 Secret,以非 root、只读根文件系统运行,并通过真实 socket 探测证明无公网出口。
|
||||||
|
|
||||||
|
## 取舍
|
||||||
|
|
||||||
|
多一层 gateway 会增加一个容器和一次本机网络转发,但当前查询耗时主要来自数据库和模型,额外开销相对可忽略。保留应用层 gateway 可以让固定上游、请求大小和错误脱敏形成可单测契约;Nginx 专注静态资源、SPA fallback、安全响应头和 SSE 代理。
|
||||||
|
|
||||||
|
在当前 Docker Desktop 上,`edge` 必须保持普通 bridge 才能可靠发布宿主机回环端口;实测 Web 可以从该网络建立公网 TCP 连接。当前接受这一残余风险,因为 Web 无 Secret、无数据网络、无挂载,且 Nginx 只代理固定 gateway。需要入口容器零出网的部署必须额外使用主机防火墙或受控出口策略,重新验收后再收紧本 ADR。
|
||||||
|
|
||||||
|
## 被否决方案
|
||||||
|
|
||||||
|
1. **API 直接加入 edge 并发布端口:** 实测仍可访问公网,不满足数据库感知进程无 egress 的要求。
|
||||||
|
2. **web 直接加入 data 并代理 API:** web 会获得到数据库网络的横向连通性,扩大入口层受攻陷后的范围。
|
||||||
|
3. **只发布 internal 网络中的 API 端口:** 在当前 Docker Desktop 上没有形成可访问的宿主机端口。
|
||||||
|
4. **只使用 Nginx、删除 gateway:** 可以减少一跳,但会丢失已经测试的固定上游、请求头白名单和脱敏错误契约;本阶段优先保持安全边界可验证。
|
||||||
|
|
||||||
|
## 验证要求
|
||||||
|
|
||||||
|
- Compose 合同测试锁定网络成员、唯一发布端口和 Secret 挂载。
|
||||||
|
- API 与 gateway 容器公网 TCP 探测必须失败;Web 的 edge 出口能力必须被显式记录,web/gateway 镜像历史和环境不得出现 Key、数据库密码或内部 DSN。
|
||||||
|
- 浏览器从 Web 入口访问 SPA、`/api`、健康检查与 SSE 时保持同源。
|
||||||
|
- 每次改变该拓扑都必须更新本 ADR、部署文档并重新执行容器级验收。
|
||||||
@@ -7,3 +7,4 @@ ADR 用于记录会长期影响系统的技术决策。状态使用 `proposed`
|
|||||||
- [0001-use-pgvector.md](0001-use-pgvector.md):选择 PostgreSQL + pgvector 作为第一版向量存储。
|
- [0001-use-pgvector.md](0001-use-pgvector.md):选择 PostgreSQL + pgvector 作为第一版向量存储。
|
||||||
- [0002-separate-bailian-protocols.md](0002-separate-bailian-protocols.md):分离百炼 Chat/Embedding 与 Rerank 协议适配器。
|
- [0002-separate-bailian-protocols.md](0002-separate-bailian-protocols.md):分离百炼 Chat/Embedding 与 Rerank 协议适配器。
|
||||||
- [0003-text-first-scope.md](0003-text-first-scope.md):第一版采用文本优先边界,不宣称地质图空间理解。
|
- [0003-text-first-scope.md](0003-text-first-scope.md):第一版采用文本优先边界,不宣称地质图空间理解。
|
||||||
|
- [0004-secretless-web-ingress.md](0004-secretless-web-ingress.md):用无 Secret 的 Nginx Web 与固定上游 gateway 隔离浏览器、API 和数据库网络。
|
||||||
|
|||||||
11
frontend/.dockerignore
Normal file
11
frontend/.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
.dockerignore
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.npmrc
|
||||||
|
Dockerfile
|
||||||
|
coverage
|
||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
npm-debug.log*
|
||||||
5
frontend/.prettierignore
Normal file
5
frontend/.prettierignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
dist
|
||||||
|
coverage
|
||||||
|
node_modules
|
||||||
|
src/api/schema.generated.ts
|
||||||
|
package-lock.json
|
||||||
7
frontend/.prettierrc.json
Normal file
7
frontend/.prettierrc.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2
|
||||||
|
}
|
||||||
25
frontend/Dockerfile
Normal file
25
frontend/Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e
|
||||||
|
FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS build
|
||||||
|
|
||||||
|
ENV CI=true
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginxinc/nginx-unprivileged:1.29-alpine@sha256:0c79d56aee561a1d81c63f00eee5fb5fe29279560cdc55e91425133104c7fbe6 AS runtime
|
||||||
|
|
||||||
|
COPY nginx.conf /etc/nginx/nginx.conf
|
||||||
|
COPY --chown=101:101 --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
USER 101:101
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# The configuration is immutable and does not use envsubst templates, so skip
|
||||||
|
# the base entrypoint's mutation probes against the read-only filesystem.
|
||||||
|
ENTRYPOINT ["nginx"]
|
||||||
|
CMD ["-g", "daemon off;"]
|
||||||
@@ -1,23 +1,57 @@
|
|||||||
# Frontend
|
# 地质知识离线检索前端
|
||||||
|
|
||||||
React + TypeScript 前端代码位于本目录。构建产物由 Nginx 提供,并由同一入口反向代理 `/api` 与 SSE。
|
React + TypeScript 的 synthetic/offline RAG 演示工作台。前端只访问同源 `/api`,不读取、保存或显示模型 Key、百炼工作区地址和数据库连接信息。
|
||||||
|
|
||||||
规划边界:
|
## 运行环境与固定版本
|
||||||
|
|
||||||
```text
|
- Node.js `>=22.12.0`,本地验证版本 `22.22.3`
|
||||||
frontend/
|
- npm `>=10.9.0`,本地验证版本 `10.9.8`
|
||||||
├── src/
|
- React `19.2.7`
|
||||||
│ ├── api/ 类型安全的后端客户端
|
- React Router `7.18.1`
|
||||||
│ ├── components/ 通用展示组件
|
- TanStack Query `5.101.2`
|
||||||
│ ├── features/ chat、documents、retrieval、evaluation
|
- Vite `8.1.4`
|
||||||
│ ├── hooks/
|
- TypeScript `5.9.3`(`openapi-typescript 7.13.0` 当前要求 TypeScript 5.x)
|
||||||
│ ├── pages/
|
- Vitest `4.1.10`
|
||||||
│ ├── routes/
|
|
||||||
│ ├── styles/
|
精确依赖和完整性哈希记录在 `package-lock.json`,不要绕过 lockfile 安装。
|
||||||
│ └── types/
|
|
||||||
└── tests/
|
## 本地开发
|
||||||
├── unit/
|
|
||||||
└── e2e/
|
先确保后端监听 `127.0.0.1:8000`,再运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci
|
||||||
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
前端只展示后端返回的脱敏模型状态,不提供读取、编辑或回显真实模型密钥的页面。
|
Vite 将同源 `/api` 代理到本机后端。生产构建输出到 `dist/`。
|
||||||
|
|
||||||
|
## OpenAPI 类型
|
||||||
|
|
||||||
|
`src/api/schema.generated.ts` 来自 FastAPI 的真实 OpenAPI 文档。后端 API 契约变化后,启动后端并运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run generate:api
|
||||||
|
```
|
||||||
|
|
||||||
|
如后端使用其他本机端口,可设置非敏感的 `OPENAPI_SCHEMA_URL`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OPENAPI_SCHEMA_URL=http://127.0.0.1:8010/openapi.json npm run generate:api
|
||||||
|
```
|
||||||
|
|
||||||
|
生成脚本会确认 `/api/v1/demo/status` 和 `/api/v1/demo/search` 均存在后才覆盖类型文件。
|
||||||
|
|
||||||
|
## 质量门禁
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run format:check
|
||||||
|
npm run lint
|
||||||
|
npm run typecheck
|
||||||
|
npm run test
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
也可使用 `npm run verify` 顺序执行全部门禁。
|
||||||
|
|
||||||
|
当前界面明确区分:索引 ready、空数据、不完整数据、422 输入边界、503 数据库不可用和网络错误。只有 ready 状态允许检索;结果中的“演示排序分”不是地质结论置信度。
|
||||||
|
|||||||
57
frontend/eslint.config.js
Normal file
57
frontend/eslint.config.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import prettierConfig from "eslint-config-prettier";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
|
import globals from "globals";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ["dist", "coverage", "src/api/schema.generated.ts"] },
|
||||||
|
{
|
||||||
|
files: ["**/*.{js,mjs}"],
|
||||||
|
...js.configs.recommended,
|
||||||
|
languageOptions: { globals: globals.node },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["**/*.{ts,tsx}"],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
|
...tseslint.configs.stylisticTypeChecked,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2023,
|
||||||
|
globals: globals.browser,
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
"react-hooks": reactHooks,
|
||||||
|
"react-refresh": reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
...reactRefresh.configs.vite.rules,
|
||||||
|
"@typescript-eslint/consistent-type-imports": [
|
||||||
|
"error",
|
||||||
|
{ prefer: "type-imports", fixStyle: "inline-type-imports" },
|
||||||
|
],
|
||||||
|
"@typescript-eslint/no-confusing-void-expression": "off",
|
||||||
|
"@typescript-eslint/no-misused-promises": [
|
||||||
|
"error",
|
||||||
|
{ checksVoidReturn: { attributes: false } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["**/*.{test,spec}.{ts,tsx}", "src/test/**/*.ts"],
|
||||||
|
languageOptions: { globals: globals.node },
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/no-unsafe-assignment": "off",
|
||||||
|
"@typescript-eslint/no-unsafe-argument": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
prettierConfig,
|
||||||
|
);
|
||||||
14
frontend/index.html
Normal file
14
frontend/index.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="基于合成地质资料的离线 RAG 检索演示工作台" />
|
||||||
|
<meta name="theme-color" content="#0b2e2b" />
|
||||||
|
<title>地知寻脉 · 地质 RAG 工作台</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
114
frontend/nginx.conf
Normal file
114
frontend/nginx.conf
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
pid /tmp/nginx.pid;
|
||||||
|
|
||||||
|
worker_processes auto;
|
||||||
|
|
||||||
|
error_log /dev/stderr notice;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
access_log /dev/stdout;
|
||||||
|
|
||||||
|
client_body_temp_path /tmp/client_temp;
|
||||||
|
proxy_temp_path /tmp/proxy_temp;
|
||||||
|
fastcgi_temp_path /tmp/fastcgi_temp;
|
||||||
|
uwsgi_temp_path /tmp/uwsgi_temp;
|
||||||
|
scgi_temp_path /tmp/scgi_temp;
|
||||||
|
|
||||||
|
sendfile on;
|
||||||
|
tcp_nopush on;
|
||||||
|
keepalive_timeout 65s;
|
||||||
|
server_tokens off;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_comp_level 5;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_types
|
||||||
|
application/javascript
|
||||||
|
application/json
|
||||||
|
application/xml
|
||||||
|
image/svg+xml
|
||||||
|
text/css
|
||||||
|
text/plain;
|
||||||
|
|
||||||
|
# FastAPI's development Swagger page currently references versioned assets
|
||||||
|
# from jsDelivr and uses an inline bootstrap script. Keep that exception
|
||||||
|
# scoped to /docs; the React application retains the strict self-only policy.
|
||||||
|
map $uri $content_security_policy {
|
||||||
|
~^/docs(?:/|$) "default-src 'self'; base-uri 'self'; connect-src 'self'; font-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data: https://fastapi.tiangolo.com; object-src 'none'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net";
|
||||||
|
default "default-src 'self'; base-uri 'self'; connect-src 'self'; font-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'";
|
||||||
|
}
|
||||||
|
|
||||||
|
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||||
|
|
||||||
|
upstream gateway_upstream {
|
||||||
|
zone gateway_upstream 64k;
|
||||||
|
server gateway:8000 resolve;
|
||||||
|
keepalive 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
client_max_body_size 1m;
|
||||||
|
|
||||||
|
add_header Content-Security-Policy $content_security_policy always;
|
||||||
|
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||||
|
add_header Cross-Origin-Resource-Policy "same-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(), geolocation=(), microphone=()" always;
|
||||||
|
add_header Referrer-Policy "no-referrer" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header X-Permitted-Cross-Domain-Policies "none" always;
|
||||||
|
|
||||||
|
location = /nginx-health {
|
||||||
|
access_log off;
|
||||||
|
default_type text/plain;
|
||||||
|
return 200 "ok\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ /\.(?!well-known(?:/|$)) {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keep all browser API traffic same-origin. Buffering is disabled so
|
||||||
|
# event-stream responses can be delivered incrementally end to end.
|
||||||
|
location ~ ^/(?:api(?:/|$)|health(?:/|$)|docs(?:/|$)|openapi\.json$) {
|
||||||
|
proxy_pass http://gateway_upstream;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /assets/ {
|
||||||
|
expires 1y;
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /index.html {
|
||||||
|
expires -1;
|
||||||
|
try_files /index.html =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4474
frontend/package-lock.json
generated
Normal file
4474
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
52
frontend/package.json
Normal file
52
frontend/package.json
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"name": "geological-rag-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.12.0",
|
||||||
|
"npm": ">=10.9.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 127.0.0.1",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview --host 127.0.0.1",
|
||||||
|
"generate:api": "node scripts/generate-openapi.mjs",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check .",
|
||||||
|
"lint": "eslint . --max-warnings 0",
|
||||||
|
"typecheck": "tsc -b --pretty false",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"verify": "npm run format:check && npm run lint && npm run typecheck && npm run test && npm run build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "5.101.2",
|
||||||
|
"react": "19.2.7",
|
||||||
|
"react-dom": "19.2.7",
|
||||||
|
"react-router-dom": "7.18.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "10.0.1",
|
||||||
|
"@testing-library/jest-dom": "6.9.1",
|
||||||
|
"@testing-library/react": "16.3.2",
|
||||||
|
"@testing-library/user-event": "14.6.1",
|
||||||
|
"@types/node": "26.1.1",
|
||||||
|
"@types/react": "19.2.17",
|
||||||
|
"@types/react-dom": "19.2.3",
|
||||||
|
"@vitejs/plugin-react": "6.0.3",
|
||||||
|
"@vitest/coverage-v8": "4.1.10",
|
||||||
|
"eslint": "10.7.0",
|
||||||
|
"eslint-config-prettier": "10.1.8",
|
||||||
|
"eslint-plugin-react-hooks": "7.1.1",
|
||||||
|
"eslint-plugin-react-refresh": "0.5.3",
|
||||||
|
"globals": "17.7.0",
|
||||||
|
"jsdom": "29.1.1",
|
||||||
|
"openapi-typescript": "7.13.0",
|
||||||
|
"prettier": "3.9.5",
|
||||||
|
"typescript": "5.9.3",
|
||||||
|
"typescript-eslint": "8.63.0",
|
||||||
|
"vite": "8.1.4",
|
||||||
|
"vitest": "4.1.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
41
frontend/scripts/generate-openapi.mjs
Normal file
41
frontend/scripts/generate-openapi.mjs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { writeFile } from "node:fs/promises";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
import openapiTS, { astToString, COMMENT_HEADER } from "openapi-typescript";
|
||||||
|
|
||||||
|
const schemaUrl = process.env.OPENAPI_SCHEMA_URL ?? "http://127.0.0.1:8000/openapi.json";
|
||||||
|
const outputPath = resolve("src/api/schema.generated.ts");
|
||||||
|
const requiredPaths = ["/api/v1/demo/status", "/api/v1/demo/search"];
|
||||||
|
|
||||||
|
const response = await fetch(schemaUrl, {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`OpenAPI schema request failed with HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = await response.json();
|
||||||
|
if (typeof schema !== "object" || schema === null || !("paths" in schema)) {
|
||||||
|
throw new Error("OpenAPI response does not contain a paths object");
|
||||||
|
}
|
||||||
|
|
||||||
|
const paths = schema.paths;
|
||||||
|
if (typeof paths !== "object" || paths === null) {
|
||||||
|
throw new Error("OpenAPI paths value is invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const path of requiredPaths) {
|
||||||
|
if (!(path in paths)) {
|
||||||
|
throw new Error(`Required API path is missing: ${path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ast = await openapiTS(schema, {
|
||||||
|
alphabetize: true,
|
||||||
|
immutable: true,
|
||||||
|
});
|
||||||
|
await writeFile(outputPath, `${COMMENT_HEADER}${astToString(ast)}`, "utf8");
|
||||||
|
|
||||||
|
console.log(`Generated ${outputPath} from ${schemaUrl}`);
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
65
frontend/src/api/client.ts
Normal file
65
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
const DEFAULT_HEADERS = {
|
||||||
|
Accept: "application/json",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ApiErrorKind = "validation" | "unavailable" | "network" | "http";
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
readonly kind: ApiErrorKind;
|
||||||
|
readonly status: number | null;
|
||||||
|
|
||||||
|
constructor(kind: ApiErrorKind, message: string, status: number | null = null) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiError";
|
||||||
|
this.kind = kind;
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeErrorMessage(response: Response): string {
|
||||||
|
if (response.status === 422) {
|
||||||
|
return "查询条件超出允许范围";
|
||||||
|
}
|
||||||
|
if (response.status === 503) {
|
||||||
|
return "本地数据库暂不可用";
|
||||||
|
}
|
||||||
|
return `请求未成功(HTTP ${response.status})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
let response: Response;
|
||||||
|
|
||||||
|
try {
|
||||||
|
response = await fetch(path, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
...DEFAULT_HEADERS,
|
||||||
|
...init?.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DOMException && error.name === "AbortError") {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new ApiError("network", "无法连接本地 API 服务");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const kind: ApiErrorKind =
|
||||||
|
response.status === 422 ? "validation" : response.status === 503 ? "unavailable" : "http";
|
||||||
|
throw new ApiError(kind, safeErrorMessage(response), response.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return (await response.json()) as T;
|
||||||
|
} catch {
|
||||||
|
throw new ApiError("http", "本地 API 返回了无法解析的数据", response.status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getApiErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return "发生了未预期的本地错误,请稍后重试";
|
||||||
|
}
|
||||||
406
frontend/src/api/schema.generated.ts
Normal file
406
frontend/src/api/schema.generated.ts
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
/**
|
||||||
|
* This file was auto-generated by openapi-typescript.
|
||||||
|
* Do not make direct changes to the file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface paths {
|
||||||
|
readonly "/api/v1/demo/search": {
|
||||||
|
readonly parameters: {
|
||||||
|
readonly query?: never;
|
||||||
|
readonly header?: never;
|
||||||
|
readonly path?: never;
|
||||||
|
readonly cookie?: never;
|
||||||
|
};
|
||||||
|
readonly get?: never;
|
||||||
|
readonly put?: never;
|
||||||
|
/** Demo Search */
|
||||||
|
readonly post: operations["demo_search_api_v1_demo_search_post"];
|
||||||
|
readonly delete?: never;
|
||||||
|
readonly options?: never;
|
||||||
|
readonly head?: never;
|
||||||
|
readonly patch?: never;
|
||||||
|
readonly trace?: never;
|
||||||
|
};
|
||||||
|
readonly "/api/v1/demo/status": {
|
||||||
|
readonly parameters: {
|
||||||
|
readonly query?: never;
|
||||||
|
readonly header?: never;
|
||||||
|
readonly path?: never;
|
||||||
|
readonly cookie?: never;
|
||||||
|
};
|
||||||
|
/** Demo Status */
|
||||||
|
readonly get: operations["demo_status_api_v1_demo_status_get"];
|
||||||
|
readonly put?: never;
|
||||||
|
readonly post?: never;
|
||||||
|
readonly delete?: never;
|
||||||
|
readonly options?: never;
|
||||||
|
readonly head?: never;
|
||||||
|
readonly patch?: never;
|
||||||
|
readonly trace?: never;
|
||||||
|
};
|
||||||
|
readonly "/api/v1/health/live": {
|
||||||
|
readonly parameters: {
|
||||||
|
readonly query?: never;
|
||||||
|
readonly header?: never;
|
||||||
|
readonly path?: never;
|
||||||
|
readonly cookie?: never;
|
||||||
|
};
|
||||||
|
/** Live */
|
||||||
|
readonly get: operations["live_api_v1_health_live_get"];
|
||||||
|
readonly put?: never;
|
||||||
|
readonly post?: never;
|
||||||
|
readonly delete?: never;
|
||||||
|
readonly options?: never;
|
||||||
|
readonly head?: never;
|
||||||
|
readonly patch?: never;
|
||||||
|
readonly trace?: never;
|
||||||
|
};
|
||||||
|
readonly "/api/v1/health/ready": {
|
||||||
|
readonly parameters: {
|
||||||
|
readonly query?: never;
|
||||||
|
readonly header?: never;
|
||||||
|
readonly path?: never;
|
||||||
|
readonly cookie?: never;
|
||||||
|
};
|
||||||
|
/** Ready */
|
||||||
|
readonly get: operations["ready_api_v1_health_ready_get"];
|
||||||
|
readonly put?: never;
|
||||||
|
readonly post?: never;
|
||||||
|
readonly delete?: never;
|
||||||
|
readonly options?: never;
|
||||||
|
readonly head?: never;
|
||||||
|
readonly patch?: never;
|
||||||
|
readonly trace?: never;
|
||||||
|
};
|
||||||
|
readonly "/api/v1/meta": {
|
||||||
|
readonly parameters: {
|
||||||
|
readonly query?: never;
|
||||||
|
readonly header?: never;
|
||||||
|
readonly path?: never;
|
||||||
|
readonly cookie?: never;
|
||||||
|
};
|
||||||
|
/** Meta */
|
||||||
|
readonly get: operations["meta_api_v1_meta_get"];
|
||||||
|
readonly put?: never;
|
||||||
|
readonly post?: never;
|
||||||
|
readonly delete?: never;
|
||||||
|
readonly options?: never;
|
||||||
|
readonly head?: never;
|
||||||
|
readonly patch?: never;
|
||||||
|
readonly trace?: never;
|
||||||
|
};
|
||||||
|
readonly "/health/live": {
|
||||||
|
readonly parameters: {
|
||||||
|
readonly query?: never;
|
||||||
|
readonly header?: never;
|
||||||
|
readonly path?: never;
|
||||||
|
readonly cookie?: never;
|
||||||
|
};
|
||||||
|
/** Live */
|
||||||
|
readonly get: operations["live_health_live_get"];
|
||||||
|
readonly put?: never;
|
||||||
|
readonly post?: never;
|
||||||
|
readonly delete?: never;
|
||||||
|
readonly options?: never;
|
||||||
|
readonly head?: never;
|
||||||
|
readonly patch?: never;
|
||||||
|
readonly trace?: never;
|
||||||
|
};
|
||||||
|
readonly "/health/ready": {
|
||||||
|
readonly parameters: {
|
||||||
|
readonly query?: never;
|
||||||
|
readonly header?: never;
|
||||||
|
readonly path?: never;
|
||||||
|
readonly cookie?: never;
|
||||||
|
};
|
||||||
|
/** Ready */
|
||||||
|
readonly get: operations["ready_health_ready_get"];
|
||||||
|
readonly put?: never;
|
||||||
|
readonly post?: never;
|
||||||
|
readonly delete?: never;
|
||||||
|
readonly options?: never;
|
||||||
|
readonly head?: never;
|
||||||
|
readonly patch?: never;
|
||||||
|
readonly trace?: never;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export type webhooks = Record<string, never>;
|
||||||
|
export interface components {
|
||||||
|
schemas: {
|
||||||
|
/**
|
||||||
|
* DemoCounts
|
||||||
|
* @description Public aggregate counts for the synthetic dataset.
|
||||||
|
*/
|
||||||
|
readonly DemoCounts: {
|
||||||
|
/** Chunks */
|
||||||
|
readonly chunks: number;
|
||||||
|
/** Searchable */
|
||||||
|
readonly searchable: number;
|
||||||
|
/** Vectors */
|
||||||
|
readonly vectors: number;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* DemoSearchItem
|
||||||
|
* @description Public synthetic result; internal identifiers and hashes are excluded.
|
||||||
|
*/
|
||||||
|
readonly DemoSearchItem: {
|
||||||
|
/** Citation Id */
|
||||||
|
readonly citation_id: string;
|
||||||
|
/** Page Label */
|
||||||
|
readonly page_label: string;
|
||||||
|
/** Score */
|
||||||
|
readonly score: number;
|
||||||
|
/** Snippet */
|
||||||
|
readonly snippet: string;
|
||||||
|
/** Title */
|
||||||
|
readonly title: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* DemoSearchRequest
|
||||||
|
* @description Bounded request accepted by the offline demo search.
|
||||||
|
*/
|
||||||
|
readonly DemoSearchRequest: {
|
||||||
|
/** Query */
|
||||||
|
readonly query: string;
|
||||||
|
/**
|
||||||
|
* Top K
|
||||||
|
* @default 5
|
||||||
|
*/
|
||||||
|
readonly top_k: number;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* DemoSearchResponse
|
||||||
|
* @description Offline retrieval response with an explicit empty-dataset state.
|
||||||
|
*/
|
||||||
|
readonly DemoSearchResponse: {
|
||||||
|
/**
|
||||||
|
* Dataset
|
||||||
|
* @default synthetic-demo
|
||||||
|
* @constant
|
||||||
|
*/
|
||||||
|
readonly dataset: "synthetic-demo";
|
||||||
|
/** Results */
|
||||||
|
readonly results: readonly components["schemas"]["DemoSearchItem"][];
|
||||||
|
/**
|
||||||
|
* Status
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
readonly status: "ok" | "empty_dataset";
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* DemoStatusResponse
|
||||||
|
* @description Safe readiness summary without database or approval internals.
|
||||||
|
*/
|
||||||
|
readonly DemoStatusResponse: {
|
||||||
|
readonly counts: components["schemas"]["DemoCounts"];
|
||||||
|
/**
|
||||||
|
* Dataset
|
||||||
|
* @default synthetic-demo
|
||||||
|
* @constant
|
||||||
|
*/
|
||||||
|
readonly dataset: "synthetic-demo";
|
||||||
|
/**
|
||||||
|
* Status
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
readonly status: "ready" | "empty_dataset" | "incomplete_dataset";
|
||||||
|
};
|
||||||
|
readonly HealthPayload: {
|
||||||
|
readonly [key: string]: string | {
|
||||||
|
readonly [key: string]: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** HTTPValidationError */
|
||||||
|
readonly HTTPValidationError: {
|
||||||
|
/** Detail */
|
||||||
|
readonly detail?: readonly components["schemas"]["ValidationError"][];
|
||||||
|
};
|
||||||
|
/** ValidationError */
|
||||||
|
readonly ValidationError: {
|
||||||
|
/** Context */
|
||||||
|
readonly ctx?: Record<string, never>;
|
||||||
|
/** Input */
|
||||||
|
readonly input?: unknown;
|
||||||
|
/** Location */
|
||||||
|
readonly loc: readonly (string | number)[];
|
||||||
|
/** Message */
|
||||||
|
readonly msg: string;
|
||||||
|
/** Error Type */
|
||||||
|
readonly type: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: never;
|
||||||
|
parameters: never;
|
||||||
|
requestBodies: never;
|
||||||
|
headers: never;
|
||||||
|
pathItems: never;
|
||||||
|
}
|
||||||
|
export type $defs = Record<string, never>;
|
||||||
|
export interface operations {
|
||||||
|
readonly demo_search_api_v1_demo_search_post: {
|
||||||
|
readonly parameters: {
|
||||||
|
readonly query?: never;
|
||||||
|
readonly header?: never;
|
||||||
|
readonly path?: never;
|
||||||
|
readonly cookie?: never;
|
||||||
|
};
|
||||||
|
readonly requestBody: {
|
||||||
|
readonly content: {
|
||||||
|
readonly "application/json": components["schemas"]["DemoSearchRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
readonly responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
readonly 200: {
|
||||||
|
headers: {
|
||||||
|
readonly [name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
readonly "application/json": components["schemas"]["DemoSearchResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
readonly 422: {
|
||||||
|
headers: {
|
||||||
|
readonly [name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
readonly "application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
readonly demo_status_api_v1_demo_status_get: {
|
||||||
|
readonly parameters: {
|
||||||
|
readonly query?: never;
|
||||||
|
readonly header?: never;
|
||||||
|
readonly path?: never;
|
||||||
|
readonly cookie?: never;
|
||||||
|
};
|
||||||
|
readonly requestBody?: never;
|
||||||
|
readonly responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
readonly 200: {
|
||||||
|
headers: {
|
||||||
|
readonly [name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
readonly "application/json": components["schemas"]["DemoStatusResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
readonly live_api_v1_health_live_get: {
|
||||||
|
readonly parameters: {
|
||||||
|
readonly query?: never;
|
||||||
|
readonly header?: never;
|
||||||
|
readonly path?: never;
|
||||||
|
readonly cookie?: never;
|
||||||
|
};
|
||||||
|
readonly requestBody?: never;
|
||||||
|
readonly responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
readonly 200: {
|
||||||
|
headers: {
|
||||||
|
readonly [name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
readonly "application/json": {
|
||||||
|
readonly [key: string]: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
readonly ready_api_v1_health_ready_get: {
|
||||||
|
readonly parameters: {
|
||||||
|
readonly query?: never;
|
||||||
|
readonly header?: never;
|
||||||
|
readonly path?: never;
|
||||||
|
readonly cookie?: never;
|
||||||
|
};
|
||||||
|
readonly requestBody?: never;
|
||||||
|
readonly responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
readonly 200: {
|
||||||
|
headers: {
|
||||||
|
readonly [name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
readonly "application/json": components["schemas"]["HealthPayload"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
readonly meta_api_v1_meta_get: {
|
||||||
|
readonly parameters: {
|
||||||
|
readonly query?: never;
|
||||||
|
readonly header?: never;
|
||||||
|
readonly path?: never;
|
||||||
|
readonly cookie?: never;
|
||||||
|
};
|
||||||
|
readonly requestBody?: never;
|
||||||
|
readonly responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
readonly 200: {
|
||||||
|
headers: {
|
||||||
|
readonly [name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
readonly "application/json": {
|
||||||
|
readonly [key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
readonly live_health_live_get: {
|
||||||
|
readonly parameters: {
|
||||||
|
readonly query?: never;
|
||||||
|
readonly header?: never;
|
||||||
|
readonly path?: never;
|
||||||
|
readonly cookie?: never;
|
||||||
|
};
|
||||||
|
readonly requestBody?: never;
|
||||||
|
readonly responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
readonly 200: {
|
||||||
|
headers: {
|
||||||
|
readonly [name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
readonly "application/json": {
|
||||||
|
readonly [key: string]: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
readonly ready_health_ready_get: {
|
||||||
|
readonly parameters: {
|
||||||
|
readonly query?: never;
|
||||||
|
readonly header?: never;
|
||||||
|
readonly path?: never;
|
||||||
|
readonly cookie?: never;
|
||||||
|
};
|
||||||
|
readonly requestBody?: never;
|
||||||
|
readonly responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
readonly 200: {
|
||||||
|
headers: {
|
||||||
|
readonly [name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
readonly "application/json": components["schemas"]["HealthPayload"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Database unavailable */
|
||||||
|
readonly 503: {
|
||||||
|
headers: {
|
||||||
|
readonly [name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
16
frontend/src/app/App.tsx
Normal file
16
frontend/src/app/App.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { RouterProvider } from "react-router-dom";
|
||||||
|
|
||||||
|
import { createAppQueryClient } from "./queryClient";
|
||||||
|
import { router } from "./router";
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const [queryClient] = useState(createAppQueryClient);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
frontend/src/app/queryClient.ts
Normal file
15
frontend/src/app/queryClient.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export function createAppQueryClient() {
|
||||||
|
return new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
18
frontend/src/app/router.tsx
Normal file
18
frontend/src/app/router.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { createBrowserRouter } from "react-router-dom";
|
||||||
|
|
||||||
|
import { AppShell } from "../components/AppShell";
|
||||||
|
import { NotFoundPage } from "../pages/NotFoundPage";
|
||||||
|
import { SystemPage } from "../pages/SystemPage";
|
||||||
|
import { WorkbenchPage } from "../pages/WorkbenchPage";
|
||||||
|
|
||||||
|
export const router = createBrowserRouter([
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
element: <AppShell />,
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <WorkbenchPage /> },
|
||||||
|
{ path: "system", element: <SystemPage /> },
|
||||||
|
{ path: "*", element: <NotFoundPage /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
86
frontend/src/components/AppShell.tsx
Normal file
86
frontend/src/components/AppShell.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { NavLink, Outlet } from "react-router-dom";
|
||||||
|
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ to: "/", label: "检索工作台", icon: "search" as const, end: true },
|
||||||
|
{ to: "/system", label: "系统说明", icon: "settings" as const, end: false },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function AppShell() {
|
||||||
|
return (
|
||||||
|
<div className="app-shell">
|
||||||
|
<a className="skip-link" href="#main-content">
|
||||||
|
跳到主要内容
|
||||||
|
</a>
|
||||||
|
<aside className="sidebar" aria-label="主导航">
|
||||||
|
<div className="brand">
|
||||||
|
<div className="brand__mark" aria-hidden="true">
|
||||||
|
<span className="brand__ridge brand__ridge--back" />
|
||||||
|
<span className="brand__ridge brand__ridge--front" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>地知寻脉</strong>
|
||||||
|
<span>Geological RAG</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="nav-list">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
className={({ isActive }) => `nav-link${isActive ? " nav-link--active" : ""}`}
|
||||||
|
end={item.end}
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
>
|
||||||
|
<Icon name={item.icon} size={19} />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="sidebar__note">
|
||||||
|
<Icon name="shield" size={18} />
|
||||||
|
<div>
|
||||||
|
<strong>本地安全边界</strong>
|
||||||
|
<span>页面不读取、不保存模型密钥</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="app-main">
|
||||||
|
<header className="mobile-header">
|
||||||
|
<div className="brand brand--mobile">
|
||||||
|
<div className="brand__mark" aria-hidden="true">
|
||||||
|
<span className="brand__ridge brand__ridge--back" />
|
||||||
|
<span className="brand__ridge brand__ridge--front" />
|
||||||
|
</div>
|
||||||
|
<strong>地知寻脉</strong>
|
||||||
|
</div>
|
||||||
|
<nav className="mobile-nav" aria-label="移动端主导航">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
aria-label={item.label}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`mobile-nav__link${isActive ? " mobile-nav__link--active" : ""}`
|
||||||
|
}
|
||||||
|
end={item.end}
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
>
|
||||||
|
<Icon name={item.icon} size={20} />
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main className="page" id="main-content" tabIndex={-1}>
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
<footer className="footer">
|
||||||
|
<span>毕业设计 · 地质知识问答系统</span>
|
||||||
|
<span>当前仅使用公开合成验证数据</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
frontend/src/components/Icon.tsx
Normal file
118
frontend/src/components/Icon.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export type IconName =
|
||||||
|
| "alert"
|
||||||
|
| "arrow"
|
||||||
|
| "check"
|
||||||
|
| "compass"
|
||||||
|
| "copy"
|
||||||
|
| "database"
|
||||||
|
| "layers"
|
||||||
|
| "search"
|
||||||
|
| "settings"
|
||||||
|
| "shield"
|
||||||
|
| "vector";
|
||||||
|
|
||||||
|
interface IconProps {
|
||||||
|
name: IconName;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconPath(name: IconName): ReactNode {
|
||||||
|
switch (name) {
|
||||||
|
case "alert":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<path d="M12 9v4" />
|
||||||
|
<path d="M12 17h.01" />
|
||||||
|
<path d="M10.3 3.6 2.4 18a2 2 0 0 0 1.8 3h15.6a2 2 0 0 0 1.8-3L13.7 3.6a2 2 0 0 0-3.4 0Z" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "arrow":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<path d="M5 12h14" />
|
||||||
|
<path d="m13 6 6 6-6 6" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "check":
|
||||||
|
return <path d="m5 12 4 4L19 6" />;
|
||||||
|
case "compass":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="m15.5 8.5-2 5-5 2 2-5 5-2Z" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "copy":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<rect width="12" height="12" x="9" y="9" rx="2" />
|
||||||
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "database":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ellipse cx="12" cy="5" rx="8" ry="3" />
|
||||||
|
<path d="M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5" />
|
||||||
|
<path d="M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "layers":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<path d="m12 2 9 5-9 5-9-5 9-5Z" />
|
||||||
|
<path d="m3 12 9 5 9-5" />
|
||||||
|
<path d="m3 17 9 5 9-5" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "search":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<circle cx="11" cy="11" r="7" />
|
||||||
|
<path d="m20 20-4-4" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "settings":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1a1.7 1.7 0 0 0 1.9.3A1.7 1.7 0 0 0 10 3V2.8h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "shield":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<path d="M20 13c0 5-3.5 7.5-8 9-4.5-1.5-8-4-8-9V5l8-3 8 3v8Z" />
|
||||||
|
<path d="m9 12 2 2 4-4" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "vector":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<circle cx="5" cy="6" r="2" />
|
||||||
|
<circle cx="19" cy="6" r="2" />
|
||||||
|
<circle cx="12" cy="18" r="2" />
|
||||||
|
<path d="m7 7 4 9M17 7l-4 9M7 6h10" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Icon({ name, size = 20 }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className="icon"
|
||||||
|
fill="none"
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={size}
|
||||||
|
>
|
||||||
|
<g stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8">
|
||||||
|
{iconPath(name)}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
21
frontend/src/features/demo/api.ts
Normal file
21
frontend/src/features/demo/api.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { requestJson } from "../../api/client";
|
||||||
|
import type { DemoSearchRequest, DemoSearchResponse, DemoStatusResponse } from "./types";
|
||||||
|
|
||||||
|
const DEMO_API_BASE = "/api/v1/demo";
|
||||||
|
|
||||||
|
export function getDemoStatus({
|
||||||
|
signal,
|
||||||
|
}: { signal?: AbortSignal } = {}): Promise<DemoStatusResponse> {
|
||||||
|
return requestJson<DemoStatusResponse>(
|
||||||
|
`${DEMO_API_BASE}/status`,
|
||||||
|
signal === undefined ? {} : { signal },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchDemo(request: DemoSearchRequest): Promise<DemoSearchResponse> {
|
||||||
|
return requestJson<DemoSearchResponse>(`${DEMO_API_BASE}/search`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
});
|
||||||
|
}
|
||||||
137
frontend/src/features/demo/components/DemoStatusPanel.tsx
Normal file
137
frontend/src/features/demo/components/DemoStatusPanel.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { ApiError } from "../../../api/client";
|
||||||
|
import { Icon, type IconName } from "../../../components/Icon";
|
||||||
|
import type { DemoStatusResponse } from "../types";
|
||||||
|
|
||||||
|
interface DemoStatusPanelProps {
|
||||||
|
data: DemoStatusResponse | undefined;
|
||||||
|
error: unknown;
|
||||||
|
isFetching: boolean;
|
||||||
|
isPending: boolean;
|
||||||
|
onRetry: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StateCopy {
|
||||||
|
icon: IconName;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
tone: "ready" | "warning" | "danger" | "neutral";
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusCopy(
|
||||||
|
data: DemoStatusResponse | undefined,
|
||||||
|
error: unknown,
|
||||||
|
isPending: boolean,
|
||||||
|
): StateCopy {
|
||||||
|
if (isPending) {
|
||||||
|
return {
|
||||||
|
icon: "database",
|
||||||
|
label: "正在检查离线索引",
|
||||||
|
description: "正在读取本地 synthetic 数据集的聚合状态。",
|
||||||
|
tone: "neutral",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (error instanceof ApiError && error.kind === "unavailable") {
|
||||||
|
return {
|
||||||
|
icon: "alert",
|
||||||
|
label: "数据库暂不可用",
|
||||||
|
description: "本地 API 已响应,但数据库尚未就绪。检索已暂停,请稍后重试。",
|
||||||
|
tone: "danger",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return {
|
||||||
|
icon: "alert",
|
||||||
|
label: "无法连接本地服务",
|
||||||
|
description: "请确认本地 Docker 服务已启动。页面不会改用云端模型。",
|
||||||
|
tone: "danger",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (data?.status === "ready") {
|
||||||
|
return {
|
||||||
|
icon: "check",
|
||||||
|
label: "离线索引已就绪",
|
||||||
|
description: `${data.counts.searchable} 条 synthetic 切片已完成向量写入并允许检索。`,
|
||||||
|
tone: "ready",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (data?.status === "empty_dataset") {
|
||||||
|
return {
|
||||||
|
icon: "database",
|
||||||
|
label: "合成数据尚未写入",
|
||||||
|
description: "数据库连接正常,但当前没有可检索切片。完成离线初始化后再试。",
|
||||||
|
tone: "warning",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (data?.status === "incomplete_dataset") {
|
||||||
|
return {
|
||||||
|
icon: "alert",
|
||||||
|
label: "离线索引不完整",
|
||||||
|
description: "切片、向量或可检索记录数量不一致。为避免误导,检索已暂停。",
|
||||||
|
tone: "warning",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
icon: "database",
|
||||||
|
label: "等待离线状态",
|
||||||
|
description: "正在等待本地服务返回数据集状态。",
|
||||||
|
tone: "neutral",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const metrics = [
|
||||||
|
{ key: "chunks", label: "知识切片", icon: "layers" as const },
|
||||||
|
{ key: "vectors", label: "向量记录", icon: "vector" as const },
|
||||||
|
{ key: "searchable", label: "可检索", icon: "search" as const },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function DemoStatusPanel({
|
||||||
|
data,
|
||||||
|
error,
|
||||||
|
isFetching,
|
||||||
|
isPending,
|
||||||
|
onRetry,
|
||||||
|
}: DemoStatusPanelProps) {
|
||||||
|
const state = statusCopy(data, error, isPending);
|
||||||
|
const canRetry =
|
||||||
|
!isPending && (Boolean(error) || (data !== undefined && data.status !== "ready"));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="status-overview" aria-labelledby="index-status-heading">
|
||||||
|
<div
|
||||||
|
aria-live="polite"
|
||||||
|
className={`status-callout status-callout--${state.tone}`}
|
||||||
|
role={state.tone === "danger" ? "alert" : "status"}
|
||||||
|
>
|
||||||
|
<span className="status-callout__icon">
|
||||||
|
<Icon name={state.icon} size={20} />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div className="status-callout__title-row">
|
||||||
|
<h2 id="index-status-heading">{state.label}</h2>
|
||||||
|
{isFetching && !isPending ? <span className="status-sync">同步中</span> : null}
|
||||||
|
</div>
|
||||||
|
<p>{state.description}</p>
|
||||||
|
{canRetry ? (
|
||||||
|
<button className="status-retry" disabled={isFetching} onClick={onRetry} type="button">
|
||||||
|
{isFetching ? "正在重新检查" : "重新检查状态"}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="metric-grid" aria-label="离线数据计数">
|
||||||
|
{metrics.map((metric) => (
|
||||||
|
<article className="metric-card" key={metric.key}>
|
||||||
|
<div className="metric-card__icon">
|
||||||
|
<Icon name={metric.icon} size={19} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>{data?.counts[metric.key] ?? "—"}</strong>
|
||||||
|
<span>{metric.label}</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
frontend/src/features/demo/components/SearchForm.tsx
Normal file
153
frontend/src/features/demo/components/SearchForm.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { useId, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { Icon } from "../../../components/Icon";
|
||||||
|
import { EXAMPLE_QUESTIONS } from "../exampleQuestions";
|
||||||
|
import {
|
||||||
|
QUERY_MAX_LENGTH,
|
||||||
|
TOP_K_MAX,
|
||||||
|
TOP_K_MIN,
|
||||||
|
type SearchInput,
|
||||||
|
validateSearchInput,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
interface SearchFormProps {
|
||||||
|
disabled: boolean;
|
||||||
|
isSearching: boolean;
|
||||||
|
onSearch: (input: SearchInput) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchForm({ disabled, isSearching, onSearch }: SearchFormProps) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [topK, setTopK] = useState(5);
|
||||||
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
const hintId = useId();
|
||||||
|
const errorId = useId();
|
||||||
|
const validation = useMemo(() => validateSearchInput({ query, topK }), [query, topK]);
|
||||||
|
const showValidation = submitted && !validation.valid && !disabled;
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
setSubmitted(true);
|
||||||
|
if (disabled || isSearching || !validation.valid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSearch({ query: validation.normalizedQuery, topK });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="search-panel" aria-labelledby="search-heading">
|
||||||
|
<div className="section-heading">
|
||||||
|
<div>
|
||||||
|
<span className="eyebrow">RETRIEVAL LAB</span>
|
||||||
|
<h2 id="search-heading">检索合成地质知识</h2>
|
||||||
|
</div>
|
||||||
|
<span className="section-heading__meta">本地向量召回 + 本地模拟重排</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
aria-busy={isSearching}
|
||||||
|
className="search-form"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
submit();
|
||||||
|
}}
|
||||||
|
ref={formRef}
|
||||||
|
>
|
||||||
|
<label className="field-label" htmlFor="geology-query">
|
||||||
|
地质问题
|
||||||
|
</label>
|
||||||
|
<div className={`query-box${showValidation ? " query-box--error" : ""}`}>
|
||||||
|
<textarea
|
||||||
|
aria-describedby={`${hintId}${showValidation ? ` ${errorId}` : ""}`}
|
||||||
|
aria-invalid={showValidation}
|
||||||
|
disabled={disabled || isSearching}
|
||||||
|
id="geology-query"
|
||||||
|
maxLength={QUERY_MAX_LENGTH}
|
||||||
|
onChange={(event) => {
|
||||||
|
setQuery(event.target.value);
|
||||||
|
if (submitted) setSubmitted(false);
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
|
||||||
|
event.preventDefault();
|
||||||
|
formRef.current?.requestSubmit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="例如:花岗斑岩铜矿化有哪些典型蚀变特征?"
|
||||||
|
rows={4}
|
||||||
|
value={query}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`character-count${query.length >= 450 ? " character-count--warning" : ""}`}
|
||||||
|
>
|
||||||
|
{query.length}/{QUERY_MAX_LENGTH}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="field-hint" id={hintId}>
|
||||||
|
Enter 检索,Shift + Enter 换行。后端会规范化空白字符。
|
||||||
|
</p>
|
||||||
|
{showValidation ? (
|
||||||
|
<p className="field-error" id={errorId} role="alert">
|
||||||
|
{validation.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="search-controls">
|
||||||
|
<label className="top-k-control" htmlFor="top-k">
|
||||||
|
<span>
|
||||||
|
返回来源数 <small>Top K</small>
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
disabled={disabled || isSearching}
|
||||||
|
id="top-k"
|
||||||
|
onChange={(event) => setTopK(Number(event.target.value))}
|
||||||
|
value={topK}
|
||||||
|
>
|
||||||
|
{Array.from(
|
||||||
|
{ length: TOP_K_MAX - TOP_K_MIN + 1 },
|
||||||
|
(_, index) => index + TOP_K_MIN,
|
||||||
|
).map((value) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{value} 条
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button className="primary-button" disabled={disabled || isSearching} type="submit">
|
||||||
|
{isSearching ? <span className="button-spinner" /> : <Icon name="search" size={19} />}
|
||||||
|
{isSearching ? "正在检索" : "开始检索"}
|
||||||
|
{!isSearching ? <Icon name="arrow" size={17} /> : null}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{disabled ? (
|
||||||
|
<p className="disabled-note" role="status">
|
||||||
|
<Icon name="alert" size={17} />
|
||||||
|
离线索引未处于 ready 状态,暂不能提交检索。
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="examples" aria-labelledby="examples-heading">
|
||||||
|
<p id="examples-heading">示例问题 · 仅填充,不会自动发送</p>
|
||||||
|
<div className="example-list">
|
||||||
|
{EXAMPLE_QUESTIONS.map((question) => (
|
||||||
|
<button
|
||||||
|
className="example-chip"
|
||||||
|
disabled={disabled || isSearching}
|
||||||
|
key={question}
|
||||||
|
onClick={() => {
|
||||||
|
setQuery(question);
|
||||||
|
setSubmitted(false);
|
||||||
|
document.querySelector<HTMLTextAreaElement>("#geology-query")?.focus();
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{question}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
194
frontend/src/features/demo/components/SearchResults.tsx
Normal file
194
frontend/src/features/demo/components/SearchResults.tsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { ApiError, getApiErrorMessage } from "../../../api/client";
|
||||||
|
import { Icon } from "../../../components/Icon";
|
||||||
|
import type { DemoSearchItem, DemoSearchResponse } from "../types";
|
||||||
|
|
||||||
|
interface SearchResultsProps {
|
||||||
|
data: DemoSearchResponse | undefined;
|
||||||
|
error: unknown;
|
||||||
|
isIdle: boolean;
|
||||||
|
isPending: boolean;
|
||||||
|
onRetry: () => void;
|
||||||
|
query: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorGuidance(error: unknown): string {
|
||||||
|
if (error instanceof ApiError && error.kind === "validation") {
|
||||||
|
return "查询条件超出边界,请控制在 1–500 个字符,并选择 1–10 条来源。";
|
||||||
|
}
|
||||||
|
if (error instanceof ApiError && error.kind === "unavailable") {
|
||||||
|
return "本地数据库暂不可用。请等待容器恢复健康后重新检索。";
|
||||||
|
}
|
||||||
|
if (error instanceof ApiError && error.kind === "network") {
|
||||||
|
return "无法连接本地 API。请确认 Docker 服务和同源 /api 代理均已启动。";
|
||||||
|
}
|
||||||
|
return getApiErrorMessage(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SourceCard({ item, rank }: { item: DemoSearchItem; rank: number }) {
|
||||||
|
const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle");
|
||||||
|
|
||||||
|
async function copyCitation() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(item.citation_id);
|
||||||
|
setCopyState("copied");
|
||||||
|
window.setTimeout(() => setCopyState("idle"), 1800);
|
||||||
|
} catch {
|
||||||
|
setCopyState("failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="source-card">
|
||||||
|
<div className="source-card__rank" aria-label={`结果 ${rank}`}>
|
||||||
|
{String(rank).padStart(2, "0")}
|
||||||
|
</div>
|
||||||
|
<div className="source-card__body">
|
||||||
|
<div className="source-card__header">
|
||||||
|
<div>
|
||||||
|
<span className="source-card__type">合成来源</span>
|
||||||
|
<h3>{item.title}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="score" title="用于离线演示排序,不表示地质结论置信度">
|
||||||
|
<span>演示排序分</span>
|
||||||
|
<strong>{item.score.toFixed(4)}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="source-card__snippet">{item.snippet}</p>
|
||||||
|
<div className="source-card__footer">
|
||||||
|
<div className="source-meta">
|
||||||
|
<span>{item.page_label}</span>
|
||||||
|
<span className="source-meta__divider" aria-hidden="true" />
|
||||||
|
<code>{item.citation_id}</code>
|
||||||
|
</div>
|
||||||
|
<button className="copy-button" onClick={copyCitation} type="button">
|
||||||
|
<Icon
|
||||||
|
name={copyState === "copied" ? "check" : copyState === "failed" ? "alert" : "copy"}
|
||||||
|
size={16}
|
||||||
|
/>
|
||||||
|
{copyState === "copied"
|
||||||
|
? "已复制"
|
||||||
|
: copyState === "failed"
|
||||||
|
? "复制失败"
|
||||||
|
: "复制引用编号"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="source-card__limitation">当前演示仅返回文本来源,暂不能打开原始页。</p>
|
||||||
|
<span className="visually-hidden" aria-live="polite">
|
||||||
|
{copyState === "copied"
|
||||||
|
? `引用编号 ${item.citation_id} 已复制`
|
||||||
|
: copyState === "failed"
|
||||||
|
? "复制失败,请手动选择引用编号"
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchResults({
|
||||||
|
data,
|
||||||
|
error,
|
||||||
|
isIdle,
|
||||||
|
isPending,
|
||||||
|
onRetry,
|
||||||
|
query,
|
||||||
|
}: SearchResultsProps) {
|
||||||
|
return (
|
||||||
|
<section aria-busy={isPending} aria-labelledby="results-heading" className="results-panel">
|
||||||
|
<div className="section-heading section-heading--results">
|
||||||
|
<div>
|
||||||
|
<span className="eyebrow">SOURCE IDENTIFIERS</span>
|
||||||
|
<h2 id="results-heading">检索结果与来源标识</h2>
|
||||||
|
</div>
|
||||||
|
{data?.status === "ok" ? (
|
||||||
|
<span className="results-count">返回 {data.results.length} 条</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<span className="visually-hidden" aria-live="polite">
|
||||||
|
{isPending
|
||||||
|
? "正在检索合成地质资料"
|
||||||
|
: data?.status === "empty_dataset"
|
||||||
|
? "离线演示数据尚未准备好,当前没有可展示结果"
|
||||||
|
: data?.status === "ok"
|
||||||
|
? `检索完成,返回 ${data.results.length} 条合成资料`
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{isIdle ? (
|
||||||
|
<div className="result-state result-state--idle">
|
||||||
|
<div className="result-state__illustration" aria-hidden="true">
|
||||||
|
<span className="strata strata--one" />
|
||||||
|
<span className="strata strata--two" />
|
||||||
|
<span className="strata strata--three" />
|
||||||
|
<Icon name="compass" size={31} />
|
||||||
|
</div>
|
||||||
|
<h3>从一个地质问题开始</h3>
|
||||||
|
<p>系统将从 1024 维离线向量中召回候选,并以模拟重排给出带稳定引用编号的合成来源。</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isPending ? (
|
||||||
|
<div className="result-skeleton">
|
||||||
|
{[0, 1, 2].map((item) => (
|
||||||
|
<div className="skeleton-card" key={item}>
|
||||||
|
<span className="skeleton-line skeleton-line--short" />
|
||||||
|
<span className="skeleton-line skeleton-line--title" />
|
||||||
|
<span className="skeleton-line" />
|
||||||
|
<span className="skeleton-line skeleton-line--medium" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="result-state result-state--error" role="alert">
|
||||||
|
<span className="result-state__icon">
|
||||||
|
<Icon name="alert" size={24} />
|
||||||
|
</span>
|
||||||
|
<h3>本次检索未完成</h3>
|
||||||
|
<p>{errorGuidance(error)}</p>
|
||||||
|
<button className="secondary-button" onClick={onRetry} type="button">
|
||||||
|
重新检索
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{data?.status === "empty_dataset" ? (
|
||||||
|
<div className="result-state result-state--empty">
|
||||||
|
<span className="result-state__icon">
|
||||||
|
<Icon name="database" size={24} />
|
||||||
|
</span>
|
||||||
|
<h3>离线演示数据尚未准备好</h3>
|
||||||
|
<p>当前数据集没有可检索记录。请完成 synthetic seed 后重新检查数据状态。</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{data?.status === "ok" && data.results.length === 0 ? (
|
||||||
|
<div className="result-state result-state--empty">
|
||||||
|
<span className="result-state__icon">
|
||||||
|
<Icon name="search" size={24} />
|
||||||
|
</span>
|
||||||
|
<h3>没有可展示的演示结果</h3>
|
||||||
|
<p>本次请求没有返回可展示来源,不代表资料库中不存在相关信息。</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{data?.status === "ok" && data.results.length > 0 ? (
|
||||||
|
<div className="source-list">
|
||||||
|
<div className="query-summary">
|
||||||
|
<span>本次问题</span>
|
||||||
|
<p>{query}</p>
|
||||||
|
</div>
|
||||||
|
{data.results.map((item, index) => (
|
||||||
|
<SourceCard item={item} key={item.citation_id} rank={index + 1} />
|
||||||
|
))}
|
||||||
|
<p className="score-disclaimer">
|
||||||
|
“演示排序分”仅用于比较当前离线结果的先后顺序,不是矿产存在概率或答案置信度。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
frontend/src/features/demo/exampleQuestions.ts
Normal file
6
frontend/src/features/demo/exampleQuestions.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export const EXAMPLE_QUESTIONS = [
|
||||||
|
"花岗斑岩铜矿化有哪些典型蚀变特征?",
|
||||||
|
"北岭地区金异常与哪些构造条件有关?",
|
||||||
|
"矽卡岩型铁矿的找矿标志有哪些?",
|
||||||
|
"断裂交汇部位为何有利于热液成矿?",
|
||||||
|
] as const;
|
||||||
7
frontend/src/features/demo/hooks/useDemoSearch.ts
Normal file
7
frontend/src/features/demo/hooks/useDemoSearch.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { searchDemo } from "../api";
|
||||||
|
|
||||||
|
export function useDemoSearch() {
|
||||||
|
return useMutation({ mutationFn: searchDemo });
|
||||||
|
}
|
||||||
15
frontend/src/features/demo/hooks/useDemoStatus.ts
Normal file
15
frontend/src/features/demo/hooks/useDemoStatus.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { getDemoStatus } from "../api";
|
||||||
|
|
||||||
|
export const demoStatusQueryKey = ["demo", "status"] as const;
|
||||||
|
|
||||||
|
export function useDemoStatus() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: demoStatusQueryKey,
|
||||||
|
queryFn: ({ signal }) => getDemoStatus({ signal }),
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
retry: 1,
|
||||||
|
staleTime: 15_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
29
frontend/src/features/demo/types.test.ts
Normal file
29
frontend/src/features/demo/types.test.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { QUERY_MAX_LENGTH, validateSearchInput } from "./types";
|
||||||
|
|
||||||
|
describe("validateSearchInput", () => {
|
||||||
|
it("normalizes valid whitespace without changing the configured top-k", () => {
|
||||||
|
expect(validateSearchInput({ query: " 花岗斑岩\n 铜矿化 ", topK: 5 })).toEqual({
|
||||||
|
valid: true,
|
||||||
|
message: null,
|
||||||
|
normalizedQuery: "花岗斑岩 铜矿化",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ query: " ", topK: 5, expected: "请输入要检索的地质问题" },
|
||||||
|
{
|
||||||
|
query: "x".repeat(QUERY_MAX_LENGTH + 1),
|
||||||
|
topK: 5,
|
||||||
|
expected: `问题不能超过 ${QUERY_MAX_LENGTH} 个字符`,
|
||||||
|
},
|
||||||
|
{ query: "铜矿", topK: 0, expected: "返回条数必须在 1–10 之间" },
|
||||||
|
{ query: "铜矿", topK: 11, expected: "返回条数必须在 1–10 之间" },
|
||||||
|
{ query: "铜矿", topK: 1.5, expected: "返回条数必须在 1–10 之间" },
|
||||||
|
])("rejects the input boundary %#", ({ expected, query, topK }) => {
|
||||||
|
const result = validateSearchInput({ query, topK });
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.message).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
45
frontend/src/features/demo/types.ts
Normal file
45
frontend/src/features/demo/types.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import type { components } from "../../api/schema.generated";
|
||||||
|
|
||||||
|
export type DemoCounts = components["schemas"]["DemoCounts"];
|
||||||
|
export type DemoSearchItem = components["schemas"]["DemoSearchItem"];
|
||||||
|
export type DemoSearchRequest = components["schemas"]["DemoSearchRequest"];
|
||||||
|
export type DemoSearchResponse = components["schemas"]["DemoSearchResponse"];
|
||||||
|
export type DemoStatusResponse = components["schemas"]["DemoStatusResponse"];
|
||||||
|
|
||||||
|
export const QUERY_MAX_LENGTH = 500;
|
||||||
|
export const TOP_K_MIN = 1;
|
||||||
|
export const TOP_K_MAX = 10;
|
||||||
|
|
||||||
|
export interface SearchInput {
|
||||||
|
query: string;
|
||||||
|
topK: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchValidation {
|
||||||
|
valid: boolean;
|
||||||
|
message: string | null;
|
||||||
|
normalizedQuery: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateSearchInput({ query, topK }: SearchInput): SearchValidation {
|
||||||
|
const normalizedQuery = query.replace(/\s+/g, " ").trim();
|
||||||
|
|
||||||
|
if (normalizedQuery.length === 0) {
|
||||||
|
return { valid: false, message: "请输入要检索的地质问题", normalizedQuery };
|
||||||
|
}
|
||||||
|
if (query.length > QUERY_MAX_LENGTH) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
message: `问题不能超过 ${QUERY_MAX_LENGTH} 个字符`,
|
||||||
|
normalizedQuery,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(topK) || topK < TOP_K_MIN || topK > TOP_K_MAX) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
message: `返回条数必须在 ${TOP_K_MIN}–${TOP_K_MAX} 之间`,
|
||||||
|
normalizedQuery,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { valid: true, message: null, normalizedQuery };
|
||||||
|
}
|
||||||
17
frontend/src/main.tsx
Normal file
17
frontend/src/main.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
|
||||||
|
import { App } from "./app/App";
|
||||||
|
import "./styles/global.css";
|
||||||
|
|
||||||
|
const root = document.querySelector<HTMLDivElement>("#root");
|
||||||
|
|
||||||
|
if (!root) {
|
||||||
|
throw new Error("Application root element is missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(root).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
17
frontend/src/pages/NotFoundPage.tsx
Normal file
17
frontend/src/pages/NotFoundPage.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import { Icon } from "../components/Icon";
|
||||||
|
|
||||||
|
export function NotFoundPage() {
|
||||||
|
return (
|
||||||
|
<div className="not-found">
|
||||||
|
<span>404</span>
|
||||||
|
<h1>未找到这个页面</h1>
|
||||||
|
<p>请求的地址不在当前离线演示范围内。</p>
|
||||||
|
<Link className="primary-button" to="/">
|
||||||
|
返回检索工作台
|
||||||
|
<Icon name="arrow" size={17} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
frontend/src/pages/SystemPage.tsx
Normal file
148
frontend/src/pages/SystemPage.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { Icon, type IconName } from "../components/Icon";
|
||||||
|
|
||||||
|
interface ModelCard {
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
state: "离线替身已运行" | "规划接入 · 未验证";
|
||||||
|
description: string;
|
||||||
|
icon: IconName;
|
||||||
|
}
|
||||||
|
|
||||||
|
const models: readonly ModelCard[] = [
|
||||||
|
{
|
||||||
|
name: "fake-feature-hash-v1",
|
||||||
|
role: "当前 Embedding",
|
||||||
|
state: "离线替身已运行",
|
||||||
|
description: "确定性的 1024 维特征哈希,仅用于 Docker 离线链路与回归测试。",
|
||||||
|
icon: "vector",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "text-embedding-v4",
|
||||||
|
role: "规划 Embedding",
|
||||||
|
state: "规划接入 · 未验证",
|
||||||
|
description: "计划通过阿里云百炼兼容接口生成正式 1024 维向量。",
|
||||||
|
icon: "vector",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "qwen3-rerank",
|
||||||
|
role: "规划 Rerank",
|
||||||
|
state: "规划接入 · 未验证",
|
||||||
|
description: "计划对向量召回候选进行二次语义排序。",
|
||||||
|
icon: "layers",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deepseek-v4-flash",
|
||||||
|
role: "规划 Generation",
|
||||||
|
state: "规划接入 · 未验证",
|
||||||
|
description: "计划在来源约束下生成带 citation 的回答;当前页面不调用。",
|
||||||
|
icon: "compass",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const pipeline = [
|
||||||
|
{ index: "01", title: "Synthetic 数据", detail: "20 条公开合成地质记录" },
|
||||||
|
{ index: "02", title: "离线向量化", detail: "fake-feature-hash-v1 · 1024D" },
|
||||||
|
{ index: "03", title: "pgvector 召回", detail: "限定知识库、访问域与审批状态" },
|
||||||
|
{ index: "04", title: "模拟重排", detail: "返回带稳定引用编号的纯文本来源" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function SystemPage() {
|
||||||
|
return (
|
||||||
|
<div className="page-stack system-page">
|
||||||
|
<header className="system-hero">
|
||||||
|
<div>
|
||||||
|
<span className="eyebrow">SYSTEM TRANSPARENCY</span>
|
||||||
|
<h1>系统边界与模型规划</h1>
|
||||||
|
<p>
|
||||||
|
本页只说明已经验证的离线能力和下一阶段模型规划,不读取模型凭据,也不暗示云端模型已经通过验证。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="verification-stamp">
|
||||||
|
<Icon name="alert" size={21} />
|
||||||
|
<div>
|
||||||
|
<strong>真实模型未验证</strong>
|
||||||
|
<span>需在轮换新 Key 后执行独立 smoke test</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="system-section" aria-labelledby="pipeline-heading">
|
||||||
|
<div className="section-heading">
|
||||||
|
<div>
|
||||||
|
<span className="eyebrow">VERIFIED PATH</span>
|
||||||
|
<h2 id="pipeline-heading">当前已运行的离线链路</h2>
|
||||||
|
</div>
|
||||||
|
<span className="verified-label">
|
||||||
|
<Icon name="check" size={16} /> 已通过离线验收 · 非实时探测
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ol className="pipeline-list">
|
||||||
|
{pipeline.map((step) => (
|
||||||
|
<li key={step.index}>
|
||||||
|
<span className="pipeline-list__index">{step.index}</span>
|
||||||
|
<div>
|
||||||
|
<strong>{step.title}</strong>
|
||||||
|
<p>{step.detail}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="system-section" aria-labelledby="models-heading">
|
||||||
|
<div className="section-heading">
|
||||||
|
<div>
|
||||||
|
<span className="eyebrow">MODEL REGISTRY</span>
|
||||||
|
<h2 id="models-heading">模型状态</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="model-grid">
|
||||||
|
{models.map((model) => {
|
||||||
|
const isVerified = model.state === "离线替身已运行";
|
||||||
|
return (
|
||||||
|
<article className="model-card" key={model.name}>
|
||||||
|
<div className="model-card__top">
|
||||||
|
<span className="model-card__icon">
|
||||||
|
<Icon name={model.icon} size={20} />
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`model-state${isVerified ? " model-state--verified" : " model-state--planned"}`}
|
||||||
|
>
|
||||||
|
{model.state}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="model-card__role">{model.role}</span>
|
||||||
|
<h3>{model.name}</h3>
|
||||||
|
<p>{model.description}</p>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="boundary-grid" aria-label="安全与功能边界">
|
||||||
|
<article className="boundary-card">
|
||||||
|
<Icon name="shield" size={22} />
|
||||||
|
<div>
|
||||||
|
<h2>凭据边界</h2>
|
||||||
|
<p>前端只有同源 `/api` 地址,不包含 Key、百炼工作区地址或数据库连接信息。</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article className="boundary-card">
|
||||||
|
<Icon name="database" size={22} />
|
||||||
|
<div>
|
||||||
|
<h2>数据边界</h2>
|
||||||
|
<p>当前仅展示公开 synthetic 数据;真实报告进入云端前必须经过审批与清单绑定。</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article className="boundary-card">
|
||||||
|
<Icon name="compass" size={22} />
|
||||||
|
<div>
|
||||||
|
<h2>能力边界</h2>
|
||||||
|
<p>地图 OCR 不等于空间理解;当前系统也不承担储量估算或靶区预测。</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
197
frontend/src/pages/WorkbenchPage.test.tsx
Normal file
197
frontend/src/pages/WorkbenchPage.test.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { jsonResponse, renderWorkbench } from "../test/renderWorkbench";
|
||||||
|
|
||||||
|
const readyStatus = {
|
||||||
|
status: "ready",
|
||||||
|
dataset: "synthetic-demo",
|
||||||
|
counts: { chunks: 20, vectors: 20, searchable: 20 },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const sourceResponse = {
|
||||||
|
status: "ok",
|
||||||
|
dataset: "synthetic-demo",
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
title: "合成资料|青岚示范区斑岩铜矿蚀变分带",
|
||||||
|
snippet: "合成记录显示花岗斑岩附近发育钾化、绢英岩化与青磐岩化分带。",
|
||||||
|
page_label: "第 3-4 页",
|
||||||
|
score: 0.923456,
|
||||||
|
citation_id: "demo-a1b2c3d4e5f60708",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function requestPath(input: string | URL | Request): string {
|
||||||
|
if (input instanceof Request) return input.url;
|
||||||
|
return String(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("WorkbenchPage", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the explicit offline mode, ready status, and aggregate counts", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValue(jsonResponse(readyStatus));
|
||||||
|
|
||||||
|
renderWorkbench();
|
||||||
|
|
||||||
|
expect(screen.getByText("合成数据 · 离线模式 · 不调用云端模型")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/不构成地质勘查结论、矿权判断或投资建议/)).toBeInTheDocument();
|
||||||
|
expect(await screen.findByRole("heading", { name: "离线索引已就绪" })).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("20")).toHaveLength(3);
|
||||||
|
expect(screen.getByRole("button", { name: "开始检索" })).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["empty_dataset", { chunks: 0, vectors: 0, searchable: 0 }, "合成数据尚未写入"],
|
||||||
|
["incomplete_dataset", { chunks: 20, vectors: 19, searchable: 18 }, "离线索引不完整"],
|
||||||
|
] as const)(
|
||||||
|
"keeps search disabled for %s and offers a status retry",
|
||||||
|
async (status, counts, label) => {
|
||||||
|
vi.mocked(fetch).mockResolvedValue(
|
||||||
|
jsonResponse({ status, dataset: "synthetic-demo", counts }),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderWorkbench();
|
||||||
|
|
||||||
|
expect(await screen.findByRole("heading", { name: label })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "开始检索" })).toBeDisabled();
|
||||||
|
expect(screen.getByRole("button", { name: "重新检查状态" })).toBeEnabled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("fills an example without sending, then renders a traceable successful result", async () => {
|
||||||
|
vi.mocked(fetch).mockImplementation((input) => {
|
||||||
|
const path = requestPath(input);
|
||||||
|
if (path.endsWith("/status")) return Promise.resolve(jsonResponse(readyStatus));
|
||||||
|
if (path.endsWith("/search")) return Promise.resolve(jsonResponse(sourceResponse));
|
||||||
|
return Promise.resolve(jsonResponse({}, 404));
|
||||||
|
});
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
renderWorkbench();
|
||||||
|
await screen.findByRole("heading", { name: "离线索引已就绪" });
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "花岗斑岩铜矿化有哪些典型蚀变特征?" }));
|
||||||
|
expect(screen.getByLabelText("地质问题")).toHaveValue("花岗斑岩铜矿化有哪些典型蚀变特征?");
|
||||||
|
expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await user.selectOptions(screen.getByLabelText(/返回来源数/), "3");
|
||||||
|
await user.click(screen.getByRole("button", { name: "开始检索" }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("heading", {
|
||||||
|
name: "合成资料|青岚示范区斑岩铜矿蚀变分带",
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("第 3-4 页")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("demo-a1b2c3d4e5f60708")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("0.9235")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/暂不能打开原始页/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/不是矿产存在概率或答案置信度/)).toBeInTheDocument();
|
||||||
|
|
||||||
|
const searchCall = vi
|
||||||
|
.mocked(fetch)
|
||||||
|
.mock.calls.find(([input]) => requestPath(input).endsWith("/search"));
|
||||||
|
expect(searchCall).toBeDefined();
|
||||||
|
const searchBody = searchCall?.[1]?.body;
|
||||||
|
expect(typeof searchBody).toBe("string");
|
||||||
|
if (typeof searchBody !== "string") throw new Error("search body must be JSON text");
|
||||||
|
expect(JSON.parse(searchBody)).toEqual({
|
||||||
|
query: "花岗斑岩铜矿化有哪些典型蚀变特征?",
|
||||||
|
top_k: 3,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explains when search finds the ready dataset has no seeded records", async () => {
|
||||||
|
vi.mocked(fetch).mockImplementation((input) => {
|
||||||
|
const path = requestPath(input);
|
||||||
|
if (path.endsWith("/status")) return Promise.resolve(jsonResponse(readyStatus));
|
||||||
|
if (path.endsWith("/search")) {
|
||||||
|
return Promise.resolve(
|
||||||
|
jsonResponse({ status: "empty_dataset", dataset: "synthetic-demo", results: [] }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Promise.resolve(jsonResponse({}, 404));
|
||||||
|
});
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
renderWorkbench();
|
||||||
|
await screen.findByRole("heading", { name: "离线索引已就绪" });
|
||||||
|
await user.type(screen.getByLabelText("地质问题"), "寻找斑岩铜矿蚀变线索");
|
||||||
|
await user.click(screen.getByRole("button", { name: "开始检索" }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("heading", { name: "离线演示数据尚未准备好" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/完成 synthetic seed 后重新检查数据状态/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("distinguishes a 503 and retries the same query", async () => {
|
||||||
|
let searchAttempts = 0;
|
||||||
|
vi.mocked(fetch).mockImplementation((input) => {
|
||||||
|
const path = requestPath(input);
|
||||||
|
if (path.endsWith("/status")) return Promise.resolve(jsonResponse(readyStatus));
|
||||||
|
if (path.endsWith("/search")) {
|
||||||
|
searchAttempts += 1;
|
||||||
|
return Promise.resolve(
|
||||||
|
searchAttempts === 1
|
||||||
|
? jsonResponse({ detail: "database unavailable" }, 503)
|
||||||
|
: jsonResponse(sourceResponse),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Promise.resolve(jsonResponse({}, 404));
|
||||||
|
});
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
renderWorkbench();
|
||||||
|
await screen.findByRole("heading", { name: "离线索引已就绪" });
|
||||||
|
await user.type(screen.getByLabelText("地质问题"), "铜矿蚀变特征");
|
||||||
|
await user.click(screen.getByRole("button", { name: "开始检索" }));
|
||||||
|
|
||||||
|
expect(await screen.findByText(/本地数据库暂不可用/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("地质问题")).toHaveValue("铜矿蚀变特征");
|
||||||
|
await user.click(screen.getByRole("button", { name: "重新检索" }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("heading", {
|
||||||
|
name: "合成资料|青岚示范区斑岩铜矿蚀变分带",
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(searchAttempts).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a bounded 422 message and does not present it as a server outage", async () => {
|
||||||
|
vi.mocked(fetch).mockImplementation((input) => {
|
||||||
|
const path = requestPath(input);
|
||||||
|
if (path.endsWith("/status")) return Promise.resolve(jsonResponse(readyStatus));
|
||||||
|
return Promise.resolve(jsonResponse({ detail: [] }, 422));
|
||||||
|
});
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
renderWorkbench();
|
||||||
|
await screen.findByRole("heading", { name: "离线索引已就绪" });
|
||||||
|
await user.type(screen.getByLabelText("地质问题"), "铜矿");
|
||||||
|
await user.click(screen.getByRole("button", { name: "开始检索" }));
|
||||||
|
|
||||||
|
expect(await screen.findByText(/查询条件超出边界/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/数据库暂不可用/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps whitespace-only input on the client and never calls search", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValue(jsonResponse(readyStatus));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
renderWorkbench();
|
||||||
|
await screen.findByRole("heading", { name: "离线索引已就绪" });
|
||||||
|
await user.type(screen.getByLabelText("地质问题"), " ");
|
||||||
|
await user.click(screen.getByRole("button", { name: "开始检索" }));
|
||||||
|
|
||||||
|
expect(screen.getByText("请输入要检索的地质问题")).toBeInTheDocument();
|
||||||
|
await waitFor(() => expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1));
|
||||||
|
});
|
||||||
|
});
|
||||||
84
frontend/src/pages/WorkbenchPage.tsx
Normal file
84
frontend/src/pages/WorkbenchPage.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
import { DemoStatusPanel } from "../features/demo/components/DemoStatusPanel";
|
||||||
|
import { SearchForm } from "../features/demo/components/SearchForm";
|
||||||
|
import { SearchResults } from "../features/demo/components/SearchResults";
|
||||||
|
import { useDemoSearch } from "../features/demo/hooks/useDemoSearch";
|
||||||
|
import { useDemoStatus } from "../features/demo/hooks/useDemoStatus";
|
||||||
|
import type { SearchInput } from "../features/demo/types";
|
||||||
|
|
||||||
|
export function WorkbenchPage() {
|
||||||
|
const statusQuery = useDemoStatus();
|
||||||
|
const searchMutation = useDemoSearch();
|
||||||
|
const { isIdle: isSearchIdle, reset: resetSearch } = searchMutation;
|
||||||
|
const isReady = statusQuery.data?.status === "ready" && !statusQuery.error;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isReady && !isSearchIdle) {
|
||||||
|
resetSearch();
|
||||||
|
}
|
||||||
|
}, [isReady, isSearchIdle, resetSearch]);
|
||||||
|
|
||||||
|
function handleSearch(input: SearchInput) {
|
||||||
|
searchMutation.mutate({ query: input.query, top_k: input.topK });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<header className="hero">
|
||||||
|
<div className="hero__copy">
|
||||||
|
<div className="mode-badge">
|
||||||
|
<span className="mode-badge__dot" />
|
||||||
|
合成数据 · 离线模式 · 不调用云端模型
|
||||||
|
</div>
|
||||||
|
<span className="eyebrow">GEOLOGICAL KNOWLEDGE RETRIEVAL</span>
|
||||||
|
<h1>地质知识离线检索演示</h1>
|
||||||
|
<p className="hero__lead">
|
||||||
|
在可控的 synthetic
|
||||||
|
数据集中验证向量召回、模拟重排与稳定引用编号。当前阶段只展示检索证据,不生成地质结论。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="hero__seal" aria-hidden="true">
|
||||||
|
<div className="hero__seal-ring">
|
||||||
|
<span>RAG</span>
|
||||||
|
<small>OFFLINE</small>
|
||||||
|
</div>
|
||||||
|
<div className="hero__coordinates">1024D · PGVECTOR</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="safety-notice" role="note">
|
||||||
|
<strong>使用边界</strong>
|
||||||
|
<span>页面内容仅用于软件功能与检索链路验证,不构成地质勘查结论、矿权判断或投资建议。</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DemoStatusPanel
|
||||||
|
data={statusQuery.data}
|
||||||
|
error={statusQuery.error}
|
||||||
|
isFetching={statusQuery.isFetching}
|
||||||
|
isPending={statusQuery.isPending}
|
||||||
|
onRetry={() => void statusQuery.refetch()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="workspace-grid">
|
||||||
|
<SearchForm
|
||||||
|
disabled={!isReady}
|
||||||
|
isSearching={searchMutation.isPending}
|
||||||
|
onSearch={handleSearch}
|
||||||
|
/>
|
||||||
|
<SearchResults
|
||||||
|
data={isReady ? searchMutation.data : undefined}
|
||||||
|
error={isReady ? searchMutation.error : undefined}
|
||||||
|
isIdle={!isReady || searchMutation.isIdle}
|
||||||
|
isPending={isReady && searchMutation.isPending}
|
||||||
|
onRetry={() => {
|
||||||
|
if (searchMutation.variables) {
|
||||||
|
searchMutation.mutate(searchMutation.variables);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
query={searchMutation.variables?.query}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
1837
frontend/src/styles/global.css
Normal file
1837
frontend/src/styles/global.css
Normal file
File diff suppressed because it is too large
Load Diff
21
frontend/src/test/renderWorkbench.tsx
Normal file
21
frontend/src/test/renderWorkbench.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { render } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { createAppQueryClient } from "../app/queryClient";
|
||||||
|
import { WorkbenchPage } from "../pages/WorkbenchPage";
|
||||||
|
|
||||||
|
export function renderWorkbench() {
|
||||||
|
const queryClient = createAppQueryClient();
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<WorkbenchPage />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jsonResponse(body: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
21
frontend/src/test/setup.ts
Normal file
21
frontend/src/test/setup.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import { cleanup } from "@testing-library/react";
|
||||||
|
import { afterEach, vi } from "vitest";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(window, "matchMedia", {
|
||||||
|
configurable: true,
|
||||||
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
29
frontend/tsconfig.app.json
Normal file
29
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
4
frontend/tsconfig.json
Normal file
4
frontend/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
16
frontend/tsconfig.node.json
Normal file
16
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["node"],
|
||||||
|
"allowImportingTsExtensions": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts", "eslint.config.js"]
|
||||||
|
}
|
||||||
37
frontend/vite.config.ts
Normal file
37
frontend/vite.config.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
proxy: Object.fromEntries(
|
||||||
|
["/api", "/health", "/docs", "/openapi.json"].map((path) => [
|
||||||
|
path,
|
||||||
|
{
|
||||||
|
target: "http://127.0.0.1:8000",
|
||||||
|
changeOrigin: false,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
port: 4173,
|
||||||
|
strictPort: true,
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
sourcemap: false,
|
||||||
|
target: "es2022",
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
setupFiles: "./src/test/setup.ts",
|
||||||
|
css: true,
|
||||||
|
restoreMocks: true,
|
||||||
|
clearMocks: true,
|
||||||
|
coverage: {
|
||||||
|
reporter: ["text", "html"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user