From ed70a140a2d6cb2d2108ce7b4e73533a34bb852e Mon Sep 17 00:00:00 2001
From: Ziki Shay
Date: Fri, 8 May 2026 00:05:51 +0800
Subject: [PATCH] =?UTF-8?q?=E6=9A=82=E5=AD=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.env | 5 +-
.version | 1 +
apidoc/README.md | 49 +
apidoc/adminapi.md | 355 ++++++
apidoc/evidenceapi.md | 382 ++++++
apidoc/importapi.md | 6 +-
apidoc/searchapi.md | 49 +-
app/controller/AdminController.php | 64 +
app/controller/Api/AdminAuthController.php | 129 ++
app/controller/Api/AdminConsoleController.php | 351 ++++++
app/controller/Api/EvidenceController.php | 255 ++++
app/service/AdminAuthService.php | 63 +
app/service/AdminConsole/AdminDocService.php | 76 ++
.../AdminConsole/ArchiveAdminService.php | 205 ++++
.../AdminConsole/MaintenanceScriptService.php | 148 +++
app/service/AdminConsole/MarkdownRenderer.php | 185 +++
.../AdminConsole/OpenSearchAdminService.php | 153 +++
app/service/AdminUserRepository.php | 108 ++
app/service/ArchiveRepository.php | 160 +++
app/service/ArticleImportService.php | 65 +-
.../Search/ChunkSearchIndexRepository.php | 18 +
app/service/Search/OpenSearchChunkIndex.php | 27 +
.../Search/OpenSearchSearchService.php | 3 +
app/view/admin/dashboard.html | 835 +++++++++++++
app/view/admin/landing.html | 83 ++
app/view/admin/login.html | 87 ++
config/admin.php | 5 +
config/opensearch.php | 4 +-
config/route.php | 27 +-
public/admin.css | 1054 +++++++++++++++++
readme.md | 27 +-
scriptdoc/README.md | 35 +
scriptdoc/backfill_archive_content.md | 76 ++
scriptdoc/reindex_opensearch.md | 93 ++
scriptdoc/setup_admin_users.md | 56 +
scriptdoc/setup_database.md | 51 +
scriptdoc/setup_opensearch.md | 59 +
scripts/backfill_archive_content.php | 117 ++
scripts/reindex_opensearch.php | 68 ++
scripts/setup_admin_users.php | 92 ++
40 files changed, 5590 insertions(+), 36 deletions(-)
create mode 100644 .version
create mode 100644 apidoc/README.md
create mode 100644 apidoc/adminapi.md
create mode 100644 apidoc/evidenceapi.md
create mode 100644 app/controller/AdminController.php
create mode 100644 app/controller/Api/AdminAuthController.php
create mode 100644 app/controller/Api/AdminConsoleController.php
create mode 100644 app/controller/Api/EvidenceController.php
create mode 100644 app/service/AdminAuthService.php
create mode 100644 app/service/AdminConsole/AdminDocService.php
create mode 100644 app/service/AdminConsole/ArchiveAdminService.php
create mode 100644 app/service/AdminConsole/MaintenanceScriptService.php
create mode 100644 app/service/AdminConsole/MarkdownRenderer.php
create mode 100644 app/service/AdminConsole/OpenSearchAdminService.php
create mode 100644 app/service/AdminUserRepository.php
create mode 100644 app/view/admin/dashboard.html
create mode 100644 app/view/admin/landing.html
create mode 100644 app/view/admin/login.html
create mode 100644 config/admin.php
create mode 100644 public/admin.css
create mode 100644 scriptdoc/README.md
create mode 100644 scriptdoc/backfill_archive_content.md
create mode 100644 scriptdoc/reindex_opensearch.md
create mode 100644 scriptdoc/setup_admin_users.md
create mode 100644 scriptdoc/setup_database.md
create mode 100644 scriptdoc/setup_opensearch.md
create mode 100644 scripts/backfill_archive_content.php
create mode 100644 scripts/reindex_opensearch.php
create mode 100644 scripts/setup_admin_users.php
diff --git a/.env b/.env
index 1568105..ff8a035 100644
--- a/.env
+++ b/.env
@@ -7,6 +7,7 @@ LLM_METADATA_ENABLED="true"
LLM_METADATA_MODEL="glm-4.7-flash"
LLM_METADATA_MAX_TOKENS=2480
LLM_METADATA_TEMPERATURE=0.1
-OPENSEARCH_HOST="http://localhost:9200"
+OPENSEARCH_HOST="https://localhost:9200"
OPENSEARCH_USERNAME="admin"
-OPENSEARCH_PASSWORD="proofdb"
\ No newline at end of file
+OPENSEARCH_PASSWORD="proofdb"
+ARCHIVE_CASK_URL="https://archive-cask.example.com"
diff --git a/.version b/.version
new file mode 100644
index 0000000..6c6aa7c
--- /dev/null
+++ b/.version
@@ -0,0 +1 @@
+0.1.0
\ No newline at end of file
diff --git a/apidoc/README.md b/apidoc/README.md
new file mode 100644
index 0000000..b90938d
--- /dev/null
+++ b/apidoc/README.md
@@ -0,0 +1,49 @@
+# API 文档总览
+
+当前 `apidoc/` 中的文档按接口域拆分:
+
+- [importapi.md](/www/proofdb/apidoc/importapi.md): 档案导入接口
+- [adminapi.md](/www/proofdb/apidoc/adminapi.md): 管理员认证与后台维护接口
+- [searchapi.md](/www/proofdb/apidoc/searchapi.md): 全文、向量、混合搜索接口
+- [evidenceapi.md](/www/proofdb/apidoc/evidenceapi.md): chunk 详情与 evidence 接口
+
+## 当前已实现接口
+
+```http
+POST /api/articles/import
+POST /api/admin/login
+POST /api/admin/logout
+GET /api/admin/me
+GET /api/admin/archives
+GET /api/admin/archives/{archive_uid}
+PATCH /api/admin/archives/{archive_uid}
+DELETE /api/admin/archives/{archive_uid}
+GET /api/admin/opensearch/status
+GET /api/admin/opensearch/documents
+GET /api/admin/users
+POST /api/admin/users
+PATCH /api/admin/users/{id}
+GET /api/admin/docs
+GET /api/admin/docs/{name}
+GET /api/admin/scripts
+GET /api/admin/scripts/{name}
+POST /api/admin/scripts/run
+POST /api/search/fulltext
+POST /api/search/vector
+POST /api/search/hybrid
+GET /api/chunks/{chunk_uid}
+GET /api/evidence/{chunk_uid}
+```
+
+## 当前接口分层
+
+- 导入层:把 Markdown 档案解析为 archive / chunk,并写入 PostgreSQL。
+- 管理层:管理员登录、会话识别、archives 表管理、OpenSearch 状态、用户管理、文档查看与维护脚本执行。
+- 检索层:从 OpenSearch 做 BM25、向量和 hybrid 检索。
+- 证据层:把 `chunk_uid` 落到 citation、页码和证据正文。
+
+## 说明
+
+- 搜索接口中的 `hits` 始终表示“当前请求下返回的候选结果数组”,不是数据库全量导出。
+- `fulltext`、`vector`、`hybrid` 都支持 `limit`。
+- `hybrid` 的 `total` 表示融合后的候选总数;更细的来源统计在 `sources` 字段中。
diff --git a/apidoc/adminapi.md b/apidoc/adminapi.md
new file mode 100644
index 0000000..c963f63
--- /dev/null
+++ b/apidoc/adminapi.md
@@ -0,0 +1,355 @@
+# 管理员后台 API
+
+## 接口说明
+
+这组接口服务于 Proof DB 的管理员维护面板,包括:
+
+- 管理员登录与会话读取
+- `archives` 表管理
+- OpenSearch 状态查看
+- 管理员用户管理
+- APIDOC 文档查看
+- 维护脚本执行
+
+管理员网页入口仍然是:
+
+- `GET /`
+- `GET /admin/login`
+- `GET /admin`
+
+## 管理员认证
+
+### 管理员登录
+
+```http
+POST /api/admin/login
+```
+
+`Content-Type: application/json`
+
+| 字段 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| `username` | string | 是 | 管理员用户名 |
+| `password` | string | 是 | 管理员密码 |
+
+### 管理员退出登录
+
+```http
+POST /api/admin/logout
+```
+
+### 当前管理员会话
+
+```http
+GET /api/admin/me
+```
+
+未登录时返回:
+
+```json
+{
+ "code": 401,
+ "message": "Admin session not found."
+}
+```
+
+## archives 表管理
+
+### 获取档案列表
+
+```http
+GET /api/admin/archives
+```
+
+### 查询参数
+
+| 字段 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| `query` | string | 否 | 按 `archive_uid`、`title`、`summary`、`author`、`source`、`series` 模糊搜索 |
+| `page` | integer | 否 | 页码,默认 `1` |
+| `page_size` | integer | 否 | 每页条数,默认 `20`,最大 `100` |
+
+### 请求示例
+
+```bash
+curl '/api/admin/archives?query=iraq&page=1&page_size=20'
+```
+
+### 成功响应
+
+```json
+{
+ "code": 0,
+ "message": "Archive list loaded.",
+ "data": {
+ "items": [
+ {
+ "archive_uid": "01KQHVREB6XPYF604RVZAP9NNY",
+ "title": "1.test",
+ "summary": "....",
+ "year": 1991,
+ "author": "....",
+ "source": "....",
+ "series": null,
+ "tags": ["Iraq", "Kuwait"],
+ "chunk_count": 14,
+ "created_time": "2026-05-07 12:00:00+00",
+ "updated_time": "2026-05-07 12:10:00+00"
+ }
+ ],
+ "total": 1,
+ "page": 1,
+ "page_size": 20
+ }
+}
+```
+
+### 获取单条档案详情
+
+```http
+GET /api/admin/archives/{archive_uid}
+```
+
+### 更新单条档案
+
+```http
+PATCH /api/admin/archives/{archive_uid}
+```
+
+`Content-Type: application/json`
+
+可更新字段:
+
+- `title`
+- `summary`
+- `year`
+- `author`
+- `source`
+- `series`
+- `tags`
+- `metadata`
+- `content`
+- `raw`
+
+其中:
+
+- `tags` 可以传字符串,也可以传数组;字符串会按逗号或换行拆分
+- `metadata` 可以传 JSON 对象,也可以传 JSON 字符串
+- `year` 为空时会写回 `null`
+
+### 更新请求示例
+
+```bash
+curl -X PATCH /api/admin/archives/01KQHVREB6XPYF604RVZAP9NNY \
+ -H 'Content-Type: application/json' \
+ --data '{
+ "title": "Updated Title",
+ "summary": "Updated summary",
+ "year": 1991,
+ "tags": ["Iraq", "Kuwait"],
+ "metadata": {
+ "reviewed_by": "admin"
+ }
+ }'
+```
+
+### 删除单条档案
+
+```http
+DELETE /api/admin/archives/{archive_uid}
+```
+
+删除后会因外键约束级联删除对应 `chunks` 记录。
+
+## OpenSearch 状态查看
+
+### 获取 OpenSearch 管理状态
+
+```http
+GET /api/admin/opensearch/status
+```
+
+### 成功响应要点
+
+响应中会同时返回:
+
+- OpenSearch 连接配置摘要
+- PostgreSQL 侧 `archives/chunks` 数量
+- `embedded_chunks`
+- `indexed_chunks`
+- 当前索引是否存在
+- `docs_count`
+- cluster 健康状态
+- mapping 字段列表
+
+如果 OpenSearch 当前不可达,仍会返回数据库部分统计,但 `opensearch.error` 会带出错误信息。
+
+### 获取 OpenSearch 索引文档粗览
+
+```http
+GET /api/admin/opensearch/documents
+```
+
+### 查询参数
+
+| 字段 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| `query` | string | 否 | 按 `title`、`summary`、`source`、`author`、`text` 等字段做粗略搜索 |
+| `size` | integer | 否 | 返回条数,默认 `20`,最大 `50` |
+
+说明:
+
+- 这是索引粗览接口,不返回向量字段本身。
+- 返回中会包含 `text_preview`,用于后台快速检查索引内容是否正确进入 OpenSearch。
+
+## 管理员用户管理
+
+### 获取管理员用户列表
+
+```http
+GET /api/admin/users
+```
+
+### 创建管理员用户
+
+```http
+POST /api/admin/users
+```
+
+`Content-Type: application/json`
+
+| 字段 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| `username` | string | 是 | 新管理员用户名 |
+| `password` | string | 是 | 新管理员密码 |
+| `display_name` | string | 否 | 展示名称 |
+
+### 更新管理员用户
+
+```http
+PATCH /api/admin/users/{id}
+```
+
+`Content-Type: application/json`
+
+可更新字段:
+
+- `display_name`
+- `password`
+- `is_active`
+
+说明:
+
+- `password` 为空时表示不修改
+- `is_active=false` 后,该账号将不能再登录
+
+## APIDOC 查看
+
+### 获取文档列表
+
+```http
+GET /api/admin/docs
+```
+
+返回 `/apidoc` 目录下当前可查看的 `.md` 文档列表。
+
+### 获取单份文档内容
+
+```http
+GET /api/admin/docs/{name}
+```
+
+例如:
+
+```bash
+curl /api/admin/docs/searchapi.md
+```
+
+响应中会带:
+
+- `name`
+- `title`
+- `content`
+- `html`
+
+其中:
+
+- `content` 为原始 Markdown 文本
+- `html` 为后台面板可直接渲染的 HTML
+
+## 维护脚本伪终端
+
+### 获取白名单脚本列表
+
+```http
+GET /api/admin/scripts
+```
+
+当前返回的是允许在管理员面板里执行的 `scripts/*.php` 白名单,而不是任意文件系统扫描。
+
+如果对应脚本在 `/scriptdoc` 中存在同名文档,列表接口也会带出:
+
+- `doc_title`
+- `doc_html`
+- `doc_content`
+
+### 获取单个维护脚本详情
+
+```http
+GET /api/admin/scripts/{name}
+```
+
+这个接口会返回单个脚本的说明、参数提示,以及可用的脚本文档内容。
+
+### 执行维护脚本
+
+```http
+POST /api/admin/scripts/run
+```
+
+`Content-Type: application/json`
+
+| 字段 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| `script_name` | string | 是 | 白名单脚本名,如 `reindex_opensearch` |
+| `args` | string[] | 否 | 参数数组,仅允许 `--key=value` 风格 |
+
+### 请求示例
+
+```bash
+curl -X POST /api/admin/scripts/run \
+ -H 'Content-Type: application/json' \
+ --data '{
+ "script_name": "reindex_opensearch",
+ "args": ["--archive_uid=01KQHVREB6XPYF604RVZAP9NNY"]
+ }'
+```
+
+### 成功响应
+
+```json
+{
+ "code": 0,
+ "message": "Maintenance script finished.",
+ "data": {
+ "script_name": "reindex_opensearch",
+ "command": [
+ "php",
+ "scripts/reindex_opensearch.php",
+ "--archive_uid=01KQHVREB6XPYF604RVZAP9NNY"
+ ],
+ "exit_code": 0,
+ "stdout": "....",
+ "stderr": "",
+ "ok": true
+ }
+}
+```
+
+## 权限与错误语义
+
+- 除 `POST /api/admin/login` 外,本文件中的所有接口都要求已有管理员会话。
+- 未登录时统一返回 `401`。
+- 参数不合法时通常返回 `422`。
+- JSON 格式错误时返回 `400`。
+- 后端异常时返回 `500`。
diff --git a/apidoc/evidenceapi.md b/apidoc/evidenceapi.md
new file mode 100644
index 0000000..23c8a99
--- /dev/null
+++ b/apidoc/evidenceapi.md
@@ -0,0 +1,382 @@
+# Chunk 与 Evidence API
+
+## 接口说明
+
+这组接口用于把搜索结果落到可读的证据对象。
+
+- `GET /api/archives/{archive_uid}` 返回 archive 级详情。
+- `GET /api/archives/{archive_uid}/chunks` 返回该 archive 下的 chunk 列表。
+- `GET /api/archives/{archive_uid}/evidence` 返回该 archive 下适合引用/AI 消费的证据列表。
+- `GET /api/chunks/{chunk_uid}` 偏底层,返回 chunk 详情和所属 archive 信息。
+- `GET /api/evidence/{chunk_uid}` 偏引用与展示,返回 citation、页码标签和证据正文。
+
+其中 archive 接口以 `archive_uid` 为主键,另外两者以 `chunk_uid` 为主键。
+
+## Archive 详情
+
+```http
+GET /api/archives/{archive_uid}
+```
+
+### 请求示例
+
+```bash
+curl /api/archives/01KQHVREB6XPYF604RVZAP9NNY
+```
+
+### 成功响应
+
+状态码:
+
+```http
+200 OK
+```
+
+响应示例:
+
+```json
+{
+ "code": 0,
+ "message": "Archive loaded.",
+ "data": {
+ "archive_uid": "01KQHVREB6XPYF604RVZAP9NNY",
+ "title": "1.test",
+ "summary": "This directive, signed by Brent Scowcroft ...",
+ "year": 1992,
+ "author": "Brent Scowcroft",
+ "source": "test/1.test.md",
+ "series": null,
+ "tags": ["National Security", "Policy"],
+ "metadata": {
+ "ai_enrichment": {
+ "provider": "bigmodel"
+ }
+ },
+ "content": "full normalized archive content ...",
+ "raw": "# 1.test ...",
+ "chunks": [
+ "01KQHVREB6XPYF604RVZAP9NNY_1_39003",
+ "01KQHVREB6XPYF604RVZAP9NNY_2_12345"
+ ],
+ "chunk_count": 14
+ }
+}
+```
+
+说明:
+
+- `content` 是归一化后的 archive 正文。
+- `raw` 是导入时保存的原始 Markdown。
+- `chunks` 是当前 archive 关联的 `chunk_uid` 列表。
+- `chunk_count` 方便调用方快速判断档案规模,而不必自己数数组长度。
+
+### 错误响应
+
+#### archive 不存在
+
+状态码:
+
+```http
+404 Not Found
+```
+
+```json
+{
+ "code": 404,
+ "message": "Archive not found.",
+ "errors": {
+ "archive_uid": "missing_archive_uid"
+ }
+}
+```
+
+## Archive 下的 Chunk 列表
+
+```http
+GET /api/archives/{archive_uid}/chunks
+```
+
+### 请求示例
+
+```bash
+curl /api/archives/01KQHVREB6XPYF604RVZAP9NNY/chunks
+```
+
+### 成功响应
+
+状态码:
+
+```http
+200 OK
+```
+
+响应示例:
+
+```json
+{
+ "code": 0,
+ "message": "Archive chunks loaded.",
+ "data": {
+ "archive_uid": "01KQHVREB6XPYF604RVZAP9NNY",
+ "title": "1.test",
+ "summary": "This directive, signed by Brent Scowcroft ...",
+ "source": "test/1.test.md",
+ "author": "Brent Scowcroft",
+ "year": 1992,
+ "series": null,
+ "tags": ["National Security", "Policy"],
+ "chunk_count": 14,
+ "chunks": [
+ {
+ "chunk_uid": "01KQHVREB6XPYF604RVZAP9NNY_1_39003",
+ "archive_uid": "01KQHVREB6XPYF604RVZAP9NNY",
+ "chunk_index": 1,
+ "page_start": 1,
+ "page_end": 1,
+ "pages": [1],
+ "text": "chunk text...",
+ "length": 300,
+ "embedding_status": 3,
+ "embedding_ref": {
+ "provider": "bigmodel",
+ "model": "embedding-3",
+ "dimensions": 2048
+ },
+ "embedding_model": "embedding-3",
+ "embedding_error": null,
+ "search_index_status": 3,
+ "search_index_error": null,
+ "archive": {
+ "archive_uid": "01KQHVREB6XPYF604RVZAP9NNY",
+ "title": "1.test",
+ "summary": "This directive, signed by Brent Scowcroft ...",
+ "year": 1992,
+ "author": "Brent Scowcroft",
+ "source": "test/1.test.md",
+ "series": null,
+ "tags": ["National Security", "Policy"],
+ "metadata": {}
+ }
+ }
+ ]
+ }
+}
+```
+
+说明:
+
+- 这个接口偏底层,适合按 archive 批量读取完整 chunk 数据。
+- `chunks` 按 `chunk_index` 升序返回。
+
+## Archive 级 Evidence 列表
+
+```http
+GET /api/archives/{archive_uid}/evidence
+```
+
+### 请求示例
+
+```bash
+curl /api/archives/01KQHVREB6XPYF604RVZAP9NNY/evidence
+```
+
+### 成功响应
+
+状态码:
+
+```http
+200 OK
+```
+
+响应示例:
+
+```json
+{
+ "code": 0,
+ "message": "Archive evidence loaded.",
+ "data": {
+ "archive_uid": "01KQHVREB6XPYF604RVZAP9NNY",
+ "title": "1.test",
+ "summary": "This directive, signed by Brent Scowcroft ...",
+ "source": "test/1.test.md",
+ "author": "Brent Scowcroft",
+ "year": 1992,
+ "series": null,
+ "tags": ["National Security", "Policy"],
+ "chunk_count": 14,
+ "evidence": [
+ {
+ "chunk_uid": "01KQHVREB6XPYF604RVZAP9NNY_1_39003",
+ "chunk_index": 1,
+ "page_start": 1,
+ "page_end": 1,
+ "pages": [1],
+ "page_label": "p. 1",
+ "citation": "1.test | Brent Scowcroft | 1992 | p. 1 | test/1.test.md",
+ "quote": "chunk text...",
+ "length": 300,
+ "embedding_model": "embedding-3",
+ "embedding_status": 3,
+ "search_index_status": 3
+ }
+ ]
+ }
+}
+```
+
+说明:
+
+- 这个接口偏上层,适合 AI、RAG、引用构造和前端证据列表展示。
+- `evidence` 里的每一项都保留了 citation 所需的页码和引用文本。
+
+## Chunk 详情
+
+```http
+GET /api/chunks/{chunk_uid}
+```
+
+### 请求示例
+
+```bash
+curl /api/chunks/01KQHVREB6XPYF604RVZAP9NNY_14_97554
+```
+
+### 成功响应
+
+状态码:
+
+```http
+200 OK
+```
+
+响应示例:
+
+```json
+{
+ "code": 0,
+ "message": "Chunk loaded.",
+ "data": {
+ "chunk_uid": "01KQHVREB6XPYF604RVZAP9NNY_14_97554",
+ "archive_uid": "01KQHVREB6XPYF604RVZAP9NNY",
+ "chunk_index": 14,
+ "page_start": 8,
+ "page_end": 8,
+ "pages": [8],
+ "text": "NSD 45 20 AUG 90 U.S. Policy in Response to the Iraqi Invasion of Kuwait ...",
+ "length": 148,
+ "embedding_status": 3,
+ "embedding_ref": {
+ "provider": "bigmodel",
+ "model": "embedding-3",
+ "dimensions": 2048
+ },
+ "embedding_model": "embedding-3",
+ "embedding_error": null,
+ "search_index_status": 3,
+ "search_index_error": null,
+ "archive": {
+ "archive_uid": "01KQHVREB6XPYF604RVZAP9NNY",
+ "title": "1.test",
+ "summary": null,
+ "year": 1992,
+ "author": "Brent Scowcroft",
+ "source": "test/1.test.md",
+ "series": null,
+ "tags": [],
+ "metadata": {}
+ }
+ }
+}
+```
+
+### 错误响应
+
+#### chunk 不存在
+
+状态码:
+
+```http
+404 Not Found
+```
+
+```json
+{
+ "code": 404,
+ "message": "Chunk not found.",
+ "errors": {
+ "chunk_uid": "missing_chunk_uid"
+ }
+}
+```
+
+## Evidence 详情
+
+```http
+GET /api/evidence/{chunk_uid}
+```
+
+### 请求示例
+
+```bash
+curl /api/evidence/01KQHVREB6XPYF604RVZAP9NNY_14_97554
+```
+
+### 成功响应
+
+状态码:
+
+```http
+200 OK
+```
+
+响应示例:
+
+```json
+{
+ "code": 0,
+ "message": "Evidence loaded.",
+ "data": {
+ "chunk_uid": "01KQHVREB6XPYF604RVZAP9NNY_14_97554",
+ "archive_uid": "01KQHVREB6XPYF604RVZAP9NNY",
+ "title": "1.test",
+ "source": "test/1.test.md",
+ "author": "Brent Scowcroft",
+ "year": 1992,
+ "series": null,
+ "tags": [],
+ "page_start": 8,
+ "page_end": 8,
+ "pages": [8],
+ "page_label": "p. 8",
+ "citation": "1.test | Brent Scowcroft | 1992 | p. 8 | test/1.test.md",
+ "quote": "NSD 45 20 AUG 90 U.S. Policy in Response to the Iraqi Invasion of Kuwait ...",
+ "chunk": {
+ "chunk_index": 14,
+ "length": 148,
+ "embedding_model": "embedding-3",
+ "embedding_status": 3,
+ "search_index_status": 3
+ }
+ }
+}
+```
+
+### 错误响应
+
+#### evidence 不存在
+
+状态码:
+
+```http
+404 Not Found
+```
+
+```json
+{
+ "code": 404,
+ "message": "Evidence not found.",
+ "errors": {
+ "chunk_uid": "missing_chunk_uid"
+ }
+}
+```
diff --git a/apidoc/importapi.md b/apidoc/importapi.md
index 7d77345..cd1ce23 100644
--- a/apidoc/importapi.md
+++ b/apidoc/importapi.md
@@ -90,7 +90,7 @@ POST /api/articles/import
## 请求示例
```bash
-curl -X POST http://127.0.0.1:8787/api/articles/import \
+curl -X POST /api/articles/import \
-F 'title=NSD 76 Disposition of NSC Policy Documents' \
-F 'source=archive://nsc/nsd-76' \
-F 'chunk_size=800' \
@@ -101,7 +101,7 @@ curl -X POST http://127.0.0.1:8787/api/articles/import \
也可以直接发送 Markdown 原文:
```bash
-curl -X POST 'http://127.0.0.1:8787/api/articles/import?title=NSD%2076&source=archive://nsc/nsd-76' \
+curl -X POST '/api/articles/import?title=NSD%2076&source=archive://nsc/nsd-76' \
-H 'Content-Type: text/markdown' \
--data-binary '@test/1.test.md'
```
@@ -109,7 +109,7 @@ curl -X POST 'http://127.0.0.1:8787/api/articles/import?title=NSD%2076&source=ar
JSON 调用示例:
```bash
-curl -X POST http://127.0.0.1:8787/api/articles/import \
+curl -X POST /api/articles/import \
-H 'Content-Type: application/json' \
--data '{
"title": "NSD 76 Disposition of NSC Policy Documents",
diff --git a/apidoc/searchapi.md b/apidoc/searchapi.md
index 52ad811..0b58411 100644
--- a/apidoc/searchapi.md
+++ b/apidoc/searchapi.md
@@ -7,6 +7,7 @@ Proof DB 的搜索接口基于 OpenSearch `proofdb_chunks` 索引。当前版本
OpenSearch 中每个 chunk 文档同时包含:
- `text` 等全文字段,用于 BM25 检索。
+- `summary` 档案摘要字段,会参与全文检索,也会随搜索结果一起返回。
- `embedding` 2048 维向量字段,用于后续 vector / hybrid 检索。
## 全文搜索
@@ -35,7 +36,7 @@ POST /api/search/fulltext
### 请求示例
```bash
-curl -X POST http://127.0.0.1:8787/api/search/fulltext \
+curl -X POST /api/search/fulltext \
-H 'Content-Type: application/json' \
--data '{
"query": "policy documents",
@@ -46,7 +47,7 @@ curl -X POST http://127.0.0.1:8787/api/search/fulltext \
带过滤条件:
```bash
-curl -X POST http://127.0.0.1:8787/api/search/fulltext \
+curl -X POST /api/search/fulltext \
-H 'Content-Type: application/json' \
--data '{
"query": "Iraq Kuwait",
@@ -87,6 +88,7 @@ curl -X POST http://127.0.0.1:8787/api/search/fulltext \
"page_start": 1,
"page_end": 1,
"title": "NSD 76 Disposition of NSC Policy Documents",
+ "summary": "Summary text...",
"source": "archive://nsc/nsd-76",
"author": "Brent Scowcroft",
"year": 1992,
@@ -101,6 +103,12 @@ curl -X POST http://127.0.0.1:8787/api/search/fulltext \
}
```
+说明:
+
+- `hits` 是当前返回的结果数组。
+- `total` 是当前 full-text 查询下的命中总数。
+- 全文搜索当前会综合匹配 `text`、`title`、`summary`、`source`、`author`、`series`、`tags`。
+
### 错误响应
#### JSON 格式错误
@@ -157,8 +165,6 @@ curl -X POST http://127.0.0.1:8787/api/search/fulltext \
}
```
-## 后续接口
-
## 向量搜索
```http
@@ -179,7 +185,7 @@ POST /api/search/vector
### 请求示例
```bash
-curl -X POST http://127.0.0.1:8787/api/search/vector \
+curl -X POST /api/search/vector \
-H 'Content-Type: application/json' \
--data '{
"query": "Iraq invasion and Desert Storm",
@@ -191,7 +197,7 @@ curl -X POST http://127.0.0.1:8787/api/search/vector \
中文 query 也可以提交给向量搜索:
```bash
-curl -X POST http://127.0.0.1:8787/api/search/vector \
+curl -X POST /api/search/vector \
-H 'Content-Type: application/json' \
--data '{
"query": "伊拉克入侵科威特与沙漠风暴",
@@ -231,6 +237,7 @@ curl -X POST http://127.0.0.1:8787/api/search/vector \
"page_start": 8,
"page_end": 8,
"title": "NSD 76 Disposition of NSC Policy Documents",
+ "summary": "Summary text...",
"source": "archive://nsc/nsd-76",
"author": "Brent Scowcroft",
"year": 1992,
@@ -246,6 +253,12 @@ curl -X POST http://127.0.0.1:8787/api/search/vector \
```
+说明:
+
+- `hits` 是当前返回的结果数组。
+- `total` 是当前 vector 查询返回的候选总数。
+- `embedding_dimensions` 是本次 query embedding 的维度,而不是索引总维度统计字段。
+
### 错误响应
错误响应格式与全文搜索一致。常见错误包括:
@@ -254,8 +267,6 @@ curl -X POST http://127.0.0.1:8787/api/search/vector \
- 缺少 `query`:`422 Unprocessable Entity`
- embedding API 或 OpenSearch 查询失败:`500 Internal Server Error`
-## 后续接口
-
## 混合搜索
```http
@@ -290,7 +301,7 @@ POST /api/search/hybrid
### 请求示例
```bash
-curl -X POST http://127.0.0.1:8787/api/search/hybrid \
+curl -X POST /api/search/hybrid \
-H 'Content-Type: application/json' \
--data '{
"query": "Iraq invasion and Desert Storm",
@@ -302,7 +313,7 @@ curl -X POST http://127.0.0.1:8787/api/search/hybrid \
中文 query:
```bash
-curl -X POST http://127.0.0.1:8787/api/search/hybrid \
+curl -X POST /api/search/hybrid \
-H 'Content-Type: application/json' \
--data '{
"query": "伊拉克入侵科威特与沙漠风暴",
@@ -370,6 +381,7 @@ curl -X POST http://127.0.0.1:8787/api/search/hybrid \
"archive_uid": "01KQHVREB6XPYF604RVZAP9NNY",
"page_start": 8,
"page_end": 8,
+ "summary": "Summary text...",
"text": "chunk text..."
}
]
@@ -377,6 +389,14 @@ curl -X POST http://127.0.0.1:8787/api/search/hybrid \
}
```
+说明:
+
+- `hits` 是融合排序后的结果数组。
+- `total` 是融合后的候选总数。
+- `sources.fulltext_total` 与 `sources.vector_total` 分别表示两路召回的原始统计。
+- `rank_sources` 用于说明某条结果在 fulltext / vector 两路中的排名与 RRF 贡献。
+- `summary` 来自 archive 级摘要元数据,不是 chunk 单独生成的摘要。
+
### 错误响应
错误响应格式与全文搜索一致。常见错误包括:
@@ -385,11 +405,8 @@ curl -X POST http://127.0.0.1:8787/api/search/hybrid \
- 缺少 `query`:`422 Unprocessable Entity`
- embedding API、全文搜索或向量搜索失败:`500 Internal Server Error`
-## 后续接口
+## 相关接口
-以下能力尚未实现:
+与搜索结果配套的证据查看接口见:
-```http
-GET /api/chunks/{chunk_uid}
-GET /api/evidence/{chunk_uid}
-```
+- [evidenceapi.md](/www/proofdb/apidoc/evidenceapi.md)
diff --git a/app/controller/AdminController.php b/app/controller/AdminController.php
new file mode 100644
index 0000000..20fd667
--- /dev/null
+++ b/app/controller/AdminController.php
@@ -0,0 +1,64 @@
+current($request) !== null) {
+ return $this->redirect('/admin');
+ }
+
+ return view('admin/landing', [
+ 'archiveCaskUrl' => config('admin.archive_cask_url', ''),
+ 'version' => $this->version(),
+ ]);
+ }
+
+ public function login(Request $request): Response
+ {
+ if ((new AdminAuthService())->current($request) !== null) {
+ return $this->redirect('/admin');
+ }
+
+ return view('admin/login', [
+ 'archiveCaskUrl' => config('admin.archive_cask_url', ''),
+ 'version' => $this->version(),
+ ]);
+ }
+
+ public function dashboard(Request $request): Response
+ {
+ $admin = (new AdminAuthService())->current($request);
+ if ($admin === null) {
+ return $this->redirect('/admin/login');
+ }
+
+ return view('admin/dashboard', [
+ 'archiveCaskUrl' => config('admin.archive_cask_url', ''),
+ 'admin' => $admin,
+ 'version' => $this->version(),
+ ]);
+ }
+
+ private function redirect(string $location): Response
+ {
+ return response('', 302, ['Location' => $location]);
+ }
+
+ private function version(): string
+ {
+ $path = base_path('.version');
+ if (!is_file($path)) {
+ return 'unknown';
+ }
+
+ $value = trim((string) file_get_contents($path));
+ return $value !== '' ? $value : 'unknown';
+ }
+}
diff --git a/app/controller/Api/AdminAuthController.php b/app/controller/Api/AdminAuthController.php
new file mode 100644
index 0000000..7403aca
--- /dev/null
+++ b/app/controller/Api/AdminAuthController.php
@@ -0,0 +1,129 @@
+payload($request);
+ $username = trim((string) ($payload['username'] ?? ''));
+ $password = (string) ($payload['password'] ?? '');
+
+ if ($username === '' || $password === '') {
+ throw new InvalidArgumentException('username and password are required.');
+ }
+
+ $auth = new AdminAuthService();
+ $user = $auth->authenticate($username, $password);
+ if ($user === null) {
+ return $this->jsonResponse([
+ 'code' => 401,
+ 'message' => 'Admin login failed.',
+ 'errors' => ['auth' => 'invalid username or password.'],
+ ], 401);
+ }
+
+ $auth->login($request, $user);
+ } catch (JsonException $exception) {
+ return $this->jsonResponse([
+ 'code' => 400,
+ 'message' => 'Invalid JSON body.',
+ 'errors' => ['body' => $exception->getMessage()],
+ ], 400);
+ } catch (InvalidArgumentException $exception) {
+ return $this->jsonResponse([
+ 'code' => 422,
+ 'message' => 'Admin login validation failed.',
+ 'errors' => ['auth' => $exception->getMessage()],
+ ], 422);
+ } catch (Throwable $exception) {
+ return $this->jsonResponse([
+ 'code' => 500,
+ 'message' => 'Admin login failed.',
+ 'errors' => ['auth' => $exception->getMessage()],
+ ], 500);
+ }
+
+ return $this->jsonResponse([
+ 'code' => 0,
+ 'message' => 'Admin login completed.',
+ 'data' => ['admin' => $user],
+ ], 200);
+ }
+
+ public function logout(Request $request): Response
+ {
+ try {
+ (new AdminAuthService())->logout($request);
+ } catch (Throwable $exception) {
+ return $this->jsonResponse([
+ 'code' => 500,
+ 'message' => 'Admin logout failed.',
+ 'errors' => ['auth' => $exception->getMessage()],
+ ], 500);
+ }
+
+ return $this->jsonResponse([
+ 'code' => 0,
+ 'message' => 'Admin logout completed.',
+ ], 200);
+ }
+
+ public function me(Request $request): Response
+ {
+ try {
+ $admin = (new AdminAuthService())->current($request);
+ } catch (Throwable $exception) {
+ return $this->jsonResponse([
+ 'code' => 500,
+ 'message' => 'Admin session lookup failed.',
+ 'errors' => ['auth' => $exception->getMessage()],
+ ], 500);
+ }
+
+ if ($admin === null) {
+ return $this->jsonResponse([
+ 'code' => 401,
+ 'message' => 'Admin session not found.',
+ ], 401);
+ }
+
+ return $this->jsonResponse([
+ 'code' => 0,
+ 'message' => 'Admin session loaded.',
+ 'data' => ['admin' => $admin],
+ ], 200);
+ }
+
+ /**
+ * @throws JsonException
+ */
+ private function payload(Request $request): array
+ {
+ $rawBody = trim($request->rawBody());
+ if ($rawBody === '') {
+ return $request->post();
+ }
+
+ $payload = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
+ return is_array($payload) ? $payload : [];
+ }
+
+ private function jsonResponse(array $data, int $status): Response
+ {
+ return response(
+ json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
+ $status,
+ ['Content-Type' => 'application/json']
+ );
+ }
+}
diff --git a/app/controller/Api/AdminConsoleController.php b/app/controller/Api/AdminConsoleController.php
new file mode 100644
index 0000000..d2cbdfd
--- /dev/null
+++ b/app/controller/Api/AdminConsoleController.php
@@ -0,0 +1,351 @@
+guard($request)) {
+ return $guard;
+ }
+
+ try {
+ $data = (new ArchiveAdminService())->list(
+ trim((string) $request->get('query', '')),
+ (int) $request->get('page', 1),
+ (int) $request->get('page_size', 20),
+ );
+ } catch (Throwable $exception) {
+ return $this->error(500, 'Archive list lookup failed.', ['archives' => $exception->getMessage()]);
+ }
+
+ return $this->ok('Archive list loaded.', $data);
+ }
+
+ public function archive(Request $request, string $archiveUid): Response
+ {
+ if ($guard = $this->guard($request)) {
+ return $guard;
+ }
+
+ try {
+ $archive = (new ArchiveAdminService())->detail($archiveUid);
+ } catch (Throwable $exception) {
+ return $this->error(500, 'Archive lookup failed.', ['archive' => $exception->getMessage()]);
+ }
+
+ if ($archive === null) {
+ return $this->error(404, 'Archive not found.', ['archive_uid' => $archiveUid], 404);
+ }
+
+ return $this->ok('Archive loaded.', $archive);
+ }
+
+ public function updateArchive(Request $request, string $archiveUid): Response
+ {
+ if ($guard = $this->guard($request)) {
+ return $guard;
+ }
+
+ try {
+ $archive = (new ArchiveAdminService())->update($archiveUid, $this->payload($request));
+ } catch (JsonException $exception) {
+ return $this->error(400, 'Invalid JSON body.', ['body' => $exception->getMessage()], 400);
+ } catch (InvalidArgumentException $exception) {
+ return $this->error(422, 'Archive update validation failed.', ['archive' => $exception->getMessage()], 422);
+ } catch (Throwable $exception) {
+ return $this->error(500, 'Archive update failed.', ['archive' => $exception->getMessage()]);
+ }
+
+ if ($archive === null) {
+ return $this->error(404, 'Archive not found.', ['archive_uid' => $archiveUid], 404);
+ }
+
+ return $this->ok('Archive updated.', $archive);
+ }
+
+ public function deleteArchive(Request $request, string $archiveUid): Response
+ {
+ if ($guard = $this->guard($request)) {
+ return $guard;
+ }
+
+ try {
+ $deleted = (new ArchiveAdminService())->delete($archiveUid);
+ } catch (Throwable $exception) {
+ return $this->error(500, 'Archive delete failed.', ['archive' => $exception->getMessage()]);
+ }
+
+ if (!$deleted) {
+ return $this->error(404, 'Archive not found.', ['archive_uid' => $archiveUid], 404);
+ }
+
+ return $this->ok('Archive deleted.', ['archive_uid' => $archiveUid]);
+ }
+
+ public function openSearchStatus(Request $request): Response
+ {
+ if ($guard = $this->guard($request)) {
+ return $guard;
+ }
+
+ try {
+ $status = (new OpenSearchAdminService())->status();
+ } catch (Throwable $exception) {
+ return $this->error(500, 'OpenSearch status lookup failed.', ['opensearch' => $exception->getMessage()]);
+ }
+
+ return $this->ok('OpenSearch status loaded.', $status);
+ }
+
+ public function openSearchDocuments(Request $request): Response
+ {
+ if ($guard = $this->guard($request)) {
+ return $guard;
+ }
+
+ try {
+ $documents = (new OpenSearchAdminService())->documents(
+ trim((string) $request->get('query', '')),
+ (int) $request->get('size', 20),
+ );
+ } catch (Throwable $exception) {
+ return $this->error(500, 'OpenSearch document lookup failed.', ['opensearch' => $exception->getMessage()]);
+ }
+
+ return $this->ok('OpenSearch documents loaded.', $documents);
+ }
+
+ public function users(Request $request): Response
+ {
+ if ($guard = $this->guard($request)) {
+ return $guard;
+ }
+
+ try {
+ $users = (new AdminUserRepository())->listAll();
+ } catch (Throwable $exception) {
+ return $this->error(500, 'Admin users lookup failed.', ['users' => $exception->getMessage()]);
+ }
+
+ return $this->ok('Admin users loaded.', ['items' => array_map(fn (array $user): array => $this->sanitizeUser($user), $users)]);
+ }
+
+ public function createUser(Request $request): Response
+ {
+ if ($guard = $this->guard($request)) {
+ return $guard;
+ }
+
+ try {
+ $payload = $this->payload($request);
+ $username = trim((string) ($payload['username'] ?? ''));
+ $password = trim((string) ($payload['password'] ?? ''));
+ $displayName = trim((string) ($payload['display_name'] ?? ''));
+
+ if ($username === '' || $password === '') {
+ throw new InvalidArgumentException('username and password are required.');
+ }
+
+ $repository = new AdminUserRepository();
+ if ($repository->findAnyByUsername($username)) {
+ throw new InvalidArgumentException('username already exists.');
+ }
+
+ $user = $repository->create($username, $password, $displayName !== '' ? $displayName : null);
+ } catch (JsonException $exception) {
+ return $this->error(400, 'Invalid JSON body.', ['body' => $exception->getMessage()], 400);
+ } catch (InvalidArgumentException $exception) {
+ return $this->error(422, 'Admin user creation validation failed.', ['user' => $exception->getMessage()], 422);
+ } catch (Throwable $exception) {
+ return $this->error(500, 'Admin user creation failed.', ['user' => $exception->getMessage()]);
+ }
+
+ return $this->ok('Admin user created.', ['user' => $this->sanitizeUser($user)]);
+ }
+
+ public function updateUser(Request $request, int $id): Response
+ {
+ if ($guard = $this->guard($request)) {
+ return $guard;
+ }
+
+ try {
+ $payload = $this->payload($request);
+ $repository = new AdminUserRepository();
+ if ($repository->findAnyById($id) === null) {
+ return $this->error(404, 'Admin user not found.', ['id' => $id], 404);
+ }
+
+ $updates = [];
+ if (array_key_exists('display_name', $payload)) {
+ $updates['display_name'] = $payload['display_name'];
+ }
+ if (array_key_exists('password', $payload)) {
+ $updates['password'] = $payload['password'];
+ }
+ if (array_key_exists('is_active', $payload)) {
+ $updates['is_active'] = (bool) $payload['is_active'];
+ }
+
+ $user = $repository->updateUser($id, $updates);
+ } catch (JsonException $exception) {
+ return $this->error(400, 'Invalid JSON body.', ['body' => $exception->getMessage()], 400);
+ } catch (Throwable $exception) {
+ return $this->error(500, 'Admin user update failed.', ['user' => $exception->getMessage()]);
+ }
+
+ return $this->ok('Admin user updated.', ['user' => $this->sanitizeUser($user ?? [])]);
+ }
+
+ public function docs(Request $request): Response
+ {
+ if ($guard = $this->guard($request)) {
+ return $guard;
+ }
+
+ try {
+ $docs = (new AdminDocService())->list();
+ } catch (Throwable $exception) {
+ return $this->error(500, 'API docs lookup failed.', ['docs' => $exception->getMessage()]);
+ }
+
+ return $this->ok('API docs loaded.', ['items' => $docs]);
+ }
+
+ public function doc(Request $request, string $name): Response
+ {
+ if ($guard = $this->guard($request)) {
+ return $guard;
+ }
+
+ try {
+ $doc = (new AdminDocService())->read($name);
+ } catch (Throwable $exception) {
+ return $this->error(404, 'API doc not found.', ['doc' => $exception->getMessage()], 404);
+ }
+
+ return $this->ok('API doc loaded.', $doc);
+ }
+
+ public function scripts(Request $request): Response
+ {
+ if ($guard = $this->guard($request)) {
+ return $guard;
+ }
+
+ return $this->ok('Maintenance scripts loaded.', ['items' => (new MaintenanceScriptService())->list()]);
+ }
+
+ public function script(Request $request, string $name): Response
+ {
+ if ($guard = $this->guard($request)) {
+ return $guard;
+ }
+
+ try {
+ $script = (new MaintenanceScriptService())->describe($name);
+ } catch (Throwable $exception) {
+ return $this->error(404, 'Maintenance script not found.', ['script' => $exception->getMessage()], 404);
+ }
+
+ return $this->ok('Maintenance script loaded.', $script);
+ }
+
+ public function runScript(Request $request): Response
+ {
+ if ($guard = $this->guard($request)) {
+ return $guard;
+ }
+
+ try {
+ $payload = $this->payload($request);
+ $scriptName = trim((string) ($payload['script_name'] ?? ''));
+ $args = $payload['args'] ?? [];
+
+ if ($scriptName === '') {
+ throw new InvalidArgumentException('script_name is required.');
+ }
+ if (!is_array($args)) {
+ throw new InvalidArgumentException('args must be an array.');
+ }
+
+ $result = (new MaintenanceScriptService())->run($scriptName, $args);
+ } catch (JsonException $exception) {
+ return $this->error(400, 'Invalid JSON body.', ['body' => $exception->getMessage()], 400);
+ } catch (InvalidArgumentException $exception) {
+ return $this->error(422, 'Script execution validation failed.', ['script' => $exception->getMessage()], 422);
+ } catch (Throwable $exception) {
+ return $this->error(500, 'Script execution failed.', ['script' => $exception->getMessage()]);
+ }
+
+ return $this->ok('Maintenance script finished.', $result);
+ }
+
+ private function guard(Request $request): ?Response
+ {
+ return (new AdminAuthService())->current($request) === null
+ ? $this->error(401, 'Admin session not found.', [], 401)
+ : null;
+ }
+
+ /**
+ * @throws JsonException
+ */
+ private function payload(Request $request): array
+ {
+ $rawBody = trim($request->rawBody());
+ if ($rawBody === '') {
+ return $request->post();
+ }
+
+ $payload = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
+ return is_array($payload) ? $payload : [];
+ }
+
+ private function ok(string $message, array $data): Response
+ {
+ return $this->jsonResponse([
+ 'code' => 0,
+ 'message' => $message,
+ 'data' => $data,
+ ], 200);
+ }
+
+ private function error(int $code, string $message, array $errors = [], int $status = 500): Response
+ {
+ return $this->jsonResponse([
+ 'code' => $code,
+ 'message' => $message,
+ 'errors' => $errors,
+ ], $status);
+ }
+
+ private function sanitizeUser(array $user): array
+ {
+ unset($user['password_hash']);
+ return $user;
+ }
+
+ private function jsonResponse(array $data, int $status): Response
+ {
+ return response(
+ json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
+ $status,
+ ['Content-Type' => 'application/json']
+ );
+ }
+}
diff --git a/app/controller/Api/EvidenceController.php b/app/controller/Api/EvidenceController.php
new file mode 100644
index 0000000..0e3dff4
--- /dev/null
+++ b/app/controller/Api/EvidenceController.php
@@ -0,0 +1,255 @@
+findArchive($archiveUid);
+ } catch (Throwable $exception) {
+ return $this->jsonResponse([
+ 'code' => 500,
+ 'message' => 'Archive lookup failed.',
+ 'errors' => ['archive' => $exception->getMessage()],
+ ], 500);
+ }
+
+ if ($archive === null) {
+ return $this->jsonResponse([
+ 'code' => 404,
+ 'message' => 'Archive not found.',
+ 'errors' => ['archive_uid' => $archiveUid],
+ ], 404);
+ }
+
+ $archive['chunk_count'] = is_array($archive['chunks'] ?? null) ? count($archive['chunks']) : 0;
+
+ return $this->jsonResponse([
+ 'code' => 0,
+ 'message' => 'Archive loaded.',
+ 'data' => $archive,
+ ], 200);
+ }
+
+ public function chunk(string $chunkUid): Response
+ {
+ try {
+ $chunk = (new ArchiveRepository())->findChunk($chunkUid);
+ } catch (Throwable $exception) {
+ return $this->jsonResponse([
+ 'code' => 500,
+ 'message' => 'Chunk lookup failed.',
+ 'errors' => ['chunk' => $exception->getMessage()],
+ ], 500);
+ }
+
+ if ($chunk === null) {
+ return $this->jsonResponse([
+ 'code' => 404,
+ 'message' => 'Chunk not found.',
+ 'errors' => ['chunk_uid' => $chunkUid],
+ ], 404);
+ }
+
+ return $this->jsonResponse([
+ 'code' => 0,
+ 'message' => 'Chunk loaded.',
+ 'data' => $chunk,
+ ], 200);
+ }
+
+ public function archiveChunks(string $archiveUid): Response
+ {
+ try {
+ $repository = new ArchiveRepository();
+ $archive = $repository->findArchive($archiveUid);
+ $chunks = $archive === null ? [] : $repository->findArchiveChunks($archiveUid);
+ } catch (Throwable $exception) {
+ return $this->jsonResponse([
+ 'code' => 500,
+ 'message' => 'Archive chunks lookup failed.',
+ 'errors' => ['archive_chunks' => $exception->getMessage()],
+ ], 500);
+ }
+
+ if ($archive === null) {
+ return $this->jsonResponse([
+ 'code' => 404,
+ 'message' => 'Archive not found.',
+ 'errors' => ['archive_uid' => $archiveUid],
+ ], 404);
+ }
+
+ return $this->jsonResponse([
+ 'code' => 0,
+ 'message' => 'Archive chunks loaded.',
+ 'data' => [
+ 'archive_uid' => $archive['archive_uid'],
+ 'title' => $archive['title'],
+ 'summary' => $archive['summary'],
+ 'source' => $archive['source'],
+ 'author' => $archive['author'],
+ 'year' => $archive['year'],
+ 'series' => $archive['series'],
+ 'tags' => $archive['tags'],
+ 'chunk_count' => count($chunks),
+ 'chunks' => $chunks,
+ ],
+ ], 200);
+ }
+
+ public function evidence(string $chunkUid): Response
+ {
+ try {
+ $chunk = (new ArchiveRepository())->findChunk($chunkUid);
+ } catch (Throwable $exception) {
+ return $this->jsonResponse([
+ 'code' => 500,
+ 'message' => 'Evidence lookup failed.',
+ 'errors' => ['evidence' => $exception->getMessage()],
+ ], 500);
+ }
+
+ if ($chunk === null) {
+ return $this->jsonResponse([
+ 'code' => 404,
+ 'message' => 'Evidence not found.',
+ 'errors' => ['chunk_uid' => $chunkUid],
+ ], 404);
+ }
+
+ $archive = $chunk['archive'];
+ $pages = $chunk['pages'];
+ $pageLabel = $this->pageLabel($pages);
+
+ return $this->jsonResponse([
+ 'code' => 0,
+ 'message' => 'Evidence loaded.',
+ 'data' => [
+ 'chunk_uid' => $chunk['chunk_uid'],
+ 'archive_uid' => $chunk['archive_uid'],
+ 'title' => $archive['title'] ?? null,
+ 'source' => $archive['source'] ?? null,
+ 'author' => $archive['author'] ?? null,
+ 'year' => $archive['year'] ?? null,
+ 'series' => $archive['series'] ?? null,
+ 'tags' => $archive['tags'] ?? [],
+ 'page_start' => $chunk['page_start'],
+ 'page_end' => $chunk['page_end'],
+ 'pages' => $pages,
+ 'page_label' => $pageLabel,
+ 'citation' => $this->citation($archive, $pageLabel),
+ 'quote' => $chunk['text'],
+ 'chunk' => [
+ 'chunk_index' => $chunk['chunk_index'],
+ 'length' => $chunk['length'],
+ 'embedding_model' => $chunk['embedding_model'],
+ 'embedding_status' => $chunk['embedding_status'],
+ 'search_index_status' => $chunk['search_index_status'],
+ ],
+ ],
+ ], 200);
+ }
+
+ public function archiveEvidence(string $archiveUid): Response
+ {
+ try {
+ $repository = new ArchiveRepository();
+ $archive = $repository->findArchive($archiveUid);
+ $chunks = $archive === null ? [] : $repository->findArchiveChunks($archiveUid);
+ } catch (Throwable $exception) {
+ return $this->jsonResponse([
+ 'code' => 500,
+ 'message' => 'Archive evidence lookup failed.',
+ 'errors' => ['archive_evidence' => $exception->getMessage()],
+ ], 500);
+ }
+
+ if ($archive === null) {
+ return $this->jsonResponse([
+ 'code' => 404,
+ 'message' => 'Archive not found.',
+ 'errors' => ['archive_uid' => $archiveUid],
+ ], 404);
+ }
+
+ $evidence = array_map(function (array $chunk): array {
+ $archive = $chunk['archive'];
+ $pages = $chunk['pages'];
+ $pageLabel = $this->pageLabel($pages);
+
+ return [
+ 'chunk_uid' => $chunk['chunk_uid'],
+ 'chunk_index' => $chunk['chunk_index'],
+ 'page_start' => $chunk['page_start'],
+ 'page_end' => $chunk['page_end'],
+ 'pages' => $pages,
+ 'page_label' => $pageLabel,
+ 'citation' => $this->citation($archive, $pageLabel),
+ 'quote' => $chunk['text'],
+ 'length' => $chunk['length'],
+ 'embedding_model' => $chunk['embedding_model'],
+ 'embedding_status' => $chunk['embedding_status'],
+ 'search_index_status' => $chunk['search_index_status'],
+ ];
+ }, $chunks);
+
+ return $this->jsonResponse([
+ 'code' => 0,
+ 'message' => 'Archive evidence loaded.',
+ 'data' => [
+ 'archive_uid' => $archive['archive_uid'],
+ 'title' => $archive['title'],
+ 'summary' => $archive['summary'],
+ 'source' => $archive['source'],
+ 'author' => $archive['author'],
+ 'year' => $archive['year'],
+ 'series' => $archive['series'],
+ 'tags' => $archive['tags'],
+ 'chunk_count' => count($evidence),
+ 'evidence' => $evidence,
+ ],
+ ], 200);
+ }
+
+ private function citation(array $archive, string $pageLabel): string
+ {
+ $parts = array_values(array_filter([
+ $archive['title'] ?? null,
+ $archive['author'] ?? null,
+ isset($archive['year']) ? (string) $archive['year'] : null,
+ $pageLabel === '' ? null : $pageLabel,
+ $archive['source'] ?? null,
+ ], static fn ($value): bool => $value !== null && trim((string) $value) !== ''));
+
+ return implode(' | ', $parts);
+ }
+
+ private function pageLabel(array $pages): string
+ {
+ if ($pages === []) {
+ return '';
+ }
+
+ if (count($pages) === 1) {
+ return 'p. ' . (string) $pages[0];
+ }
+
+ return 'pp. ' . (string) $pages[0] . '-' . (string) $pages[count($pages) - 1];
+ }
+
+ private function jsonResponse(array $data, int $status): Response
+ {
+ return response(
+ json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
+ $status,
+ ['Content-Type' => 'application/json']
+ );
+ }
+}
diff --git a/app/service/AdminAuthService.php b/app/service/AdminAuthService.php
new file mode 100644
index 0000000..bceaa2a
--- /dev/null
+++ b/app/service/AdminAuthService.php
@@ -0,0 +1,63 @@
+users()->findByUsername($username);
+ if ($user === null || !password_verify($password, $user['password_hash'])) {
+ return null;
+ }
+
+ unset($user['password_hash']);
+ return $user;
+ }
+
+ public function login(Request $request, array $user): void
+ {
+ $request->session()->set(self::SESSION_KEY, (int) $user['id']);
+ $this->users()->touchLastLogin((int) $user['id']);
+ }
+
+ public function logout(Request $request): void
+ {
+ $request->session()->delete(self::SESSION_KEY);
+ }
+
+ public function current(Request $request): ?array
+ {
+ $id = (int) $request->session()->get(self::SESSION_KEY, 0);
+ if ($id <= 0) {
+ return null;
+ }
+
+ $user = $this->users()->findById($id);
+ if ($user === null) {
+ $request->session()->delete(self::SESSION_KEY);
+ return null;
+ }
+
+ unset($user['password_hash']);
+ return $user;
+ }
+
+ private function users(): AdminUserRepository
+ {
+ return $this->users ?? new AdminUserRepository();
+ }
+}
diff --git a/app/service/AdminConsole/AdminDocService.php b/app/service/AdminConsole/AdminDocService.php
new file mode 100644
index 0000000..c4248b1
--- /dev/null
+++ b/app/service/AdminConsole/AdminDocService.php
@@ -0,0 +1,76 @@
+ $name,
+ 'title' => $this->title($content, $name),
+ ];
+ }
+
+ usort($items, fn (array $a, array $b): int => strcmp($a['name'], $b['name']));
+ return $items;
+ }
+
+ public function read(string $name): array
+ {
+ $safeName = basename($name);
+ $path = base_path('apidoc/' . $safeName);
+ if (!is_file($path) || pathinfo($path, PATHINFO_EXTENSION) !== 'md') {
+ throw new RuntimeException('API doc not found.');
+ }
+
+ $content = (string) file_get_contents($path);
+ return [
+ 'name' => $safeName,
+ 'title' => $this->title($content, $safeName),
+ 'content' => $content,
+ 'html' => $this->renderer()->render($content),
+ ];
+ }
+
+ public function readScriptDoc(string $name): array
+ {
+ $safeName = basename($name);
+ $path = base_path('scriptdoc/' . $safeName);
+ if (!is_file($path) || pathinfo($path, PATHINFO_EXTENSION) !== 'md') {
+ throw new RuntimeException('Script doc not found.');
+ }
+
+ $content = (string) file_get_contents($path);
+ return [
+ 'name' => $safeName,
+ 'title' => $this->title($content, $safeName),
+ 'content' => $content,
+ 'html' => $this->renderer()->render($content),
+ ];
+ }
+
+ private function title(string $content, string $fallback): string
+ {
+ if (preg_match('/^#\s+(.+)$/m', $content, $matches)) {
+ return trim($matches[1]);
+ }
+
+ return $fallback;
+ }
+
+ private function renderer(): MarkdownRenderer
+ {
+ return $this->renderer ?? new MarkdownRenderer();
+ }
+}
diff --git a/app/service/AdminConsole/ArchiveAdminService.php b/app/service/AdminConsole/ArchiveAdminService.php
new file mode 100644
index 0000000..5976920
--- /dev/null
+++ b/app/service/AdminConsole/ArchiveAdminService.php
@@ -0,0 +1,205 @@
+where(function ($subQuery) use ($like): void {
+ $subQuery
+ ->orWhere('archive_uid', 'like', $like)
+ ->orWhere('title', 'like', $like)
+ ->orWhere('summary', 'like', $like)
+ ->orWhere('author', 'like', $like)
+ ->orWhere('source', 'like', $like)
+ ->orWhere('series', 'like', $like);
+ });
+ }
+
+ $total = (clone $builder)->count();
+ $rows = $builder
+ ->orderByDesc('updated_time')
+ ->offset(($page - 1) * $pageSize)
+ ->limit($pageSize)
+ ->get([
+ 'archive_uid',
+ 'title',
+ 'summary',
+ 'year',
+ 'author',
+ 'source',
+ 'series',
+ 'tags',
+ 'created_time',
+ 'updated_time',
+ Db::raw('jsonb_array_length(chunks) as chunk_count'),
+ ])
+ ->all();
+
+ return [
+ 'items' => array_map(fn (object $row): array => $this->listItem($row), $rows),
+ 'total' => (int) $total,
+ 'page' => $page,
+ 'page_size' => $pageSize,
+ ];
+ }
+
+ public function detail(string $archiveUid): ?array
+ {
+ $row = Db::table('archives')->where('archive_uid', $archiveUid)->first();
+ if (!$row) {
+ return null;
+ }
+
+ return $this->detailItem($row);
+ }
+
+ public function update(string $archiveUid, array $payload): ?array
+ {
+ if (!$this->detail($archiveUid)) {
+ return null;
+ }
+
+ $updates = [];
+ foreach (['title', 'summary', 'author', 'source', 'series', 'content', 'raw'] as $field) {
+ if (array_key_exists($field, $payload)) {
+ $updates[$field] = $this->nullableText($payload[$field]);
+ }
+ }
+
+ if (array_key_exists('year', $payload)) {
+ $year = trim((string) ($payload['year'] ?? ''));
+ if ($year === '') {
+ $updates['year'] = null;
+ } elseif (!preg_match('/^\d{1,4}$/', $year)) {
+ throw new InvalidArgumentException('year must be empty or a 1-4 digit number.');
+ } else {
+ $updates['year'] = (int) $year;
+ }
+ }
+
+ if (array_key_exists('tags', $payload)) {
+ $updates['tags'] = json_encode($this->normalizeTags($payload['tags']), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ }
+
+ if (array_key_exists('metadata', $payload)) {
+ $updates['metadata'] = json_encode($this->normalizeMetadata($payload['metadata']), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ }
+
+ if ($updates !== []) {
+ Db::table('archives')->where('archive_uid', $archiveUid)->update($updates);
+ }
+
+ return $this->detail($archiveUid);
+ }
+
+ public function delete(string $archiveUid): bool
+ {
+ return (int) Db::table('archives')->where('archive_uid', $archiveUid)->delete() > 0;
+ }
+
+ private function listItem(object $row): array
+ {
+ $chunks = $this->decodeJson($row->chunks ?? null, []);
+
+ return [
+ 'archive_uid' => (string) $row->archive_uid,
+ 'title' => $row->title,
+ 'summary' => $row->summary,
+ 'year' => $row->year === null ? null : (int) $row->year,
+ 'author' => $row->author,
+ 'source' => $row->source,
+ 'series' => $row->series,
+ 'tags' => $this->decodeJson($row->tags ?? null, []),
+ 'chunk_count' => property_exists($row, 'chunk_count')
+ ? ($row->chunk_count === null ? 0 : (int) $row->chunk_count)
+ : count(is_array($chunks) ? $chunks : []),
+ 'created_time' => $row->created_time,
+ 'updated_time' => $row->updated_time,
+ ];
+ }
+
+ private function detailItem(object $row): array
+ {
+ $data = $this->listItem($row);
+ $data['metadata'] = $this->decodeJson($row->metadata ?? null, []);
+ $data['content'] = $row->content;
+ $data['raw'] = $row->raw;
+ $data['chunks'] = $this->decodeJson($row->chunks ?? null, []);
+
+ return $data;
+ }
+
+ private function normalizeTags(mixed $value): array
+ {
+ if (is_array($value)) {
+ $items = $value;
+ } else {
+ $text = trim((string) $value);
+ if ($text === '') {
+ return [];
+ }
+ $items = preg_split('/[\r\n,]+/', $text) ?: [];
+ }
+
+ $tags = [];
+ foreach ($items as $item) {
+ $tag = trim((string) $item);
+ if ($tag !== '') {
+ $tags[] = $tag;
+ }
+ }
+
+ return array_values(array_unique($tags));
+ }
+
+ private function normalizeMetadata(mixed $value): array
+ {
+ if (is_array($value)) {
+ return $value;
+ }
+
+ $text = trim((string) $value);
+ if ($text === '') {
+ return [];
+ }
+
+ $decoded = json_decode($text, true);
+ if (!is_array($decoded)) {
+ throw new InvalidArgumentException('metadata must be a JSON object or array.');
+ }
+
+ return $decoded;
+ }
+
+ private function nullableText(mixed $value): ?string
+ {
+ $text = trim((string) $value);
+ return $text === '' ? null : $text;
+ }
+
+ private function decodeJson(mixed $value, mixed $fallback): mixed
+ {
+ if ($value === null) {
+ return $fallback;
+ }
+
+ if (is_array($value)) {
+ return $value;
+ }
+
+ $decoded = json_decode((string) $value, true);
+ return $decoded === null && json_last_error() !== JSON_ERROR_NONE ? $fallback : $decoded;
+ }
+}
diff --git a/app/service/AdminConsole/MaintenanceScriptService.php b/app/service/AdminConsole/MaintenanceScriptService.php
new file mode 100644
index 0000000..5fbb3a5
--- /dev/null
+++ b/app/service/AdminConsole/MaintenanceScriptService.php
@@ -0,0 +1,148 @@
+definitions() as $definition) {
+ $item = $definition;
+ try {
+ $doc = $docs->readScriptDoc($definition['doc_name']);
+ $item['doc_title'] = $doc['title'];
+ $item['doc_html'] = $doc['html'];
+ $item['doc_content'] = $doc['content'];
+ } catch (RuntimeException) {
+ $item['doc_title'] = null;
+ $item['doc_html'] = null;
+ $item['doc_content'] = null;
+ }
+ $items[] = $item;
+ }
+
+ return $items;
+ }
+
+ public function describe(string $name): array
+ {
+ $definitions = $this->definitions();
+ if (!isset($definitions[$name])) {
+ throw new RuntimeException('Script is not allowed.');
+ }
+
+ foreach ($this->list() as $item) {
+ if ($item['name'] === $name) {
+ return $item;
+ }
+ }
+
+ throw new RuntimeException('Script metadata not found.');
+ }
+
+ public function run(string $name, array $args = []): array
+ {
+ $definitions = $this->definitions();
+ if (!isset($definitions[$name])) {
+ throw new RuntimeException('Script is not allowed.');
+ }
+
+ $script = $definitions[$name];
+ $scriptPath = base_path('scripts/' . $script['file']);
+ if (!is_file($scriptPath)) {
+ throw new RuntimeException('Script file not found.');
+ }
+
+ $safeArgs = [];
+ foreach ($args as $arg) {
+ $arg = trim((string) $arg);
+ if ($arg === '') {
+ continue;
+ }
+ if (!preg_match(self::ARG_PATTERN, $arg)) {
+ throw new RuntimeException('Only --key=value style arguments are allowed.');
+ }
+ $safeArgs[] = $arg;
+ }
+
+ $command = array_merge([PHP_BINARY, $scriptPath], $safeArgs);
+ $descriptors = [
+ 0 => ['pipe', 'r'],
+ 1 => ['pipe', 'w'],
+ 2 => ['pipe', 'w'],
+ ];
+
+ $process = proc_open($command, $descriptors, $pipes, base_path());
+ if (!is_resource($process)) {
+ throw new RuntimeException('Failed to start script process.');
+ }
+
+ fclose($pipes[0]);
+ $stdout = (string) stream_get_contents($pipes[1]);
+ fclose($pipes[1]);
+ $stderr = (string) stream_get_contents($pipes[2]);
+ fclose($pipes[2]);
+ $exitCode = proc_close($process);
+
+ return [
+ 'script_name' => $name,
+ 'command' => array_merge(['php', 'scripts/' . $script['file']], $safeArgs),
+ 'exit_code' => $exitCode,
+ 'stdout' => $stdout,
+ 'stderr' => $stderr,
+ 'ok' => $exitCode === 0,
+ ];
+ }
+
+ private function definitions(): array
+ {
+ return [
+ 'setup_database' => [
+ 'name' => 'setup_database',
+ 'file' => 'setup_database.php',
+ 'label' => '初始化数据库',
+ 'description' => '创建或补齐 archives、chunks 相关表结构与索引。',
+ 'doc_name' => 'setup_database.md',
+ 'args_hint' => '无参数',
+ ],
+ 'setup_opensearch' => [
+ 'name' => 'setup_opensearch',
+ 'file' => 'setup_opensearch.php',
+ 'label' => '初始化 OpenSearch',
+ 'description' => '创建或补齐 proofdb_chunks 索引与 mapping。',
+ 'doc_name' => 'setup_opensearch.md',
+ 'args_hint' => '无参数',
+ ],
+ 'reindex_opensearch' => [
+ 'name' => 'reindex_opensearch',
+ 'file' => 'reindex_opensearch.php',
+ 'label' => '重建 OpenSearch 索引',
+ 'description' => '把 PostgreSQL 中已向量化的数据重新写入 OpenSearch。',
+ 'doc_name' => 'reindex_opensearch.md',
+ 'args_hint' => '--archive_uid=01...',
+ ],
+ 'backfill_archive_content' => [
+ 'name' => 'backfill_archive_content',
+ 'file' => 'backfill_archive_content.php',
+ 'label' => '回填 archive content',
+ 'description' => '从 raw 或 chunks 回填 archives.content。',
+ 'doc_name' => 'backfill_archive_content.md',
+ 'args_hint' => '--archive_uid=01...',
+ ],
+ 'setup_admin_users' => [
+ 'name' => 'setup_admin_users',
+ 'file' => 'setup_admin_users.php',
+ 'label' => '初始化管理员用户',
+ 'description' => '创建 admin_users 表并写入或更新管理员账号。',
+ 'doc_name' => 'setup_admin_users.md',
+ 'args_hint' => '--username=admin --password=secret',
+ ],
+ ];
+ }
+}
diff --git a/app/service/AdminConsole/MarkdownRenderer.php b/app/service/AdminConsole/MarkdownRenderer.php
new file mode 100644
index 0000000..e62587c
--- /dev/null
+++ b/app/service/AdminConsole/MarkdownRenderer.php
@@ -0,0 +1,185 @@
+' . $this->renderInline($text) . '
';
+ $paragraph = [];
+ };
+
+ $flushList = function () use (&$listType, &$html): void {
+ if ($listType !== null) {
+ $html[] = '' . $listType . '>';
+ $listType = null;
+ }
+ };
+
+ $flushTable = function () use (&$table, &$html): void {
+ if ($table === null) {
+ return;
+ }
+
+ $html[] = '' .
+ implode('', array_map(fn (string $cell): string => '| ' . $this->renderInline($cell) . ' | ', $table['headers'])) .
+ '
';
+ foreach ($table['rows'] as $row) {
+ $html[] = '' .
+ implode('', array_map(fn (string $cell): string => '| ' . $this->renderInline($cell) . ' | ', $row)) .
+ '
';
+ }
+ $html[] = '
';
+ $table = null;
+ };
+
+ foreach ($lines as $line) {
+ if (preg_match('/^```/', $line)) {
+ $flushParagraph();
+ $flushList();
+ $flushTable();
+
+ if ($inCodeBlock) {
+ $html[] = '' . htmlspecialchars(implode("\n", $codeLines), ENT_QUOTES, 'UTF-8') . '
';
+ $codeLines = [];
+ $inCodeBlock = false;
+ } else {
+ $inCodeBlock = true;
+ }
+ continue;
+ }
+
+ if ($inCodeBlock) {
+ $codeLines[] = $line;
+ continue;
+ }
+
+ $trimmed = trim($line);
+ if ($trimmed === '') {
+ $flushParagraph();
+ $flushList();
+ $flushTable();
+ continue;
+ }
+
+ if (preg_match('/^(#{1,6})\s+(.+)$/', $trimmed, $matches)) {
+ $flushParagraph();
+ $flushList();
+ $flushTable();
+ $level = strlen($matches[1]);
+ $html[] = sprintf('%s', $level, $this->renderInline($matches[2]), $level);
+ continue;
+ }
+
+ if (preg_match('/^>\s?(.+)$/', $trimmed, $matches)) {
+ $flushParagraph();
+ $flushList();
+ $flushTable();
+ $html[] = '' . $this->renderInline($matches[1]) . '
';
+ continue;
+ }
+
+ if (preg_match('/^---+$/', $trimmed)) {
+ $flushParagraph();
+ $flushList();
+ $flushTable();
+ $html[] = '
';
+ continue;
+ }
+
+ if ($this->isTableDelimiter($trimmed) && $table !== null) {
+ continue;
+ }
+
+ if (str_contains($trimmed, '|')) {
+ $cells = $this->tableCells($trimmed);
+ if (count($cells) >= 2) {
+ $flushParagraph();
+ $flushList();
+ if ($table === null) {
+ $table = ['headers' => $cells, 'rows' => []];
+ } else {
+ $table['rows'][] = $cells;
+ }
+ continue;
+ }
+ }
+
+ if (preg_match('/^[-*]\s+(.+)$/', $trimmed, $matches)) {
+ $flushParagraph();
+ $flushTable();
+ if ($listType !== 'ul') {
+ $flushList();
+ $listType = 'ul';
+ $html[] = '