Compare commits
No commits in common. "master" and "master" have entirely different histories.
12
.env.example
12
.env.example
|
|
@ -1,8 +1,6 @@
|
|||
# ── Gitee.AI ───────────────────────────────────────────
|
||||
# 必填。从 https://ai.gitee.com/ 获取
|
||||
GITEE_API_KEY = "your-api-key"
|
||||
OPENAI_API_KEY = "your-api-key"
|
||||
|
||||
# ── LangSmith (可观测性) ───────────────────────────────
|
||||
# 必填。从 https://smith.langchain.com/ 获取
|
||||
# enabled / project / endpoint 在 config.toml [langsmith] 中配置
|
||||
LANGSMITH_API_KEY = "your-langsmith-api-key"
|
||||
LANGSMITH_TRACING = true
|
||||
LANGSMITH_ENDPOINT = https://api.smith.langchain.com
|
||||
LANGSMITH_API_KEY = ""
|
||||
LANGSMITH_PROJECT = ""
|
||||
|
|
@ -95,14 +95,14 @@ uv sync
|
|||
|
||||
```toml
|
||||
[llm]
|
||||
model = "Qwen3-Next-80B-A3B-Instruct" # 或 DeepSeek-R1,需要支持 function_call 的 llm
|
||||
model = "Qwen2.5-72B-Instruct" # 或 deepseek-V3,需要支持 function_call 的 llm
|
||||
base_url = "https://ai.gitee.com/v1"
|
||||
api_key = "${GITEE_API_KEY}" # 不建议硬编码,建议引用 .env 中的变量
|
||||
api_key = "your_api_key" # 不建议硬编码,建议引用 .env 中的变量
|
||||
|
||||
[embedding]
|
||||
model = "Qwen3-Embedding-4B"
|
||||
model = "Qwen/Qwen3-Embedding-8B"
|
||||
base_url = "https://ai.gitee.com/v1"
|
||||
api_key = "${GITEE_API_KEY}"
|
||||
api_key = "your_api_key"
|
||||
```
|
||||
|
||||
> 💡 敏感信息不建议硬编码,可以在 `config.toml` 中使用 `${VAR}` 语法动态替换。
|
||||
|
|
|
|||
16
config.toml
16
config.toml
|
|
@ -7,12 +7,12 @@ max_tokens = 50000
|
|||
timeout = 60.0
|
||||
|
||||
[embedding]
|
||||
model = "Qwen3-Embedding-4B"
|
||||
model = "Qwen/Qwen3-Embedding-4B"
|
||||
base_url = "https://ai.gitee.com/v1"
|
||||
api_key = "${GITEE_API_KEY}"
|
||||
|
||||
[reranker]
|
||||
model = "Qwen3-Reranker-4B"
|
||||
model = "Qwen/Qwen3-Reranker-8B"
|
||||
base_url = "https://ai.gitee.com/v1"
|
||||
api_key = "${GITEE_API_KEY}"
|
||||
top_n = 3
|
||||
|
|
@ -23,18 +23,6 @@ file = "logs/app.log"
|
|||
max_bytes = 10485760
|
||||
backup_count = 5
|
||||
|
||||
[perf_logging]
|
||||
enabled = true
|
||||
file = "logs/perf.log"
|
||||
max_bytes = 10485760
|
||||
backup_count = 3
|
||||
|
||||
[langsmith]
|
||||
enabled = true
|
||||
api_key = "${LANGSMITH_API_KEY}"
|
||||
project = "algonotes-rag"
|
||||
endpoint = "https://api.smith.langchain.com"
|
||||
|
||||
[store]
|
||||
chroma_dir = "data/chroma_db"
|
||||
files_dir = "data/files"
|
||||
|
|
|
|||
|
|
@ -15,8 +15,7 @@ config.toml → 业务配置(提交 Git,${VAR} 引用环境变量)
|
|||
|
||||
```bash
|
||||
# .env
|
||||
GITEE_API_KEY = "your-gitee-api-key"
|
||||
LANGSMITH_API_KEY = "your-langsmith-api-key"
|
||||
OPENAI_API_KEY = "your-api-key-here"
|
||||
```
|
||||
|
||||
## config.toml 说明
|
||||
|
|
@ -26,22 +25,22 @@ LANGSMITH_API_KEY = "your-langsmith-api-key"
|
|||
[llm]
|
||||
model = "Qwen3-Next-80B-A3B-Instruct"
|
||||
base_url = "https://ai.gitee.com/v1"
|
||||
api_key = "${GITEE_API_KEY}"
|
||||
api_key = "${OPENAI_API_KEY}"
|
||||
temperature = 0.7
|
||||
max_tokens = 50000
|
||||
max_tokens = 4096
|
||||
timeout = 60.0
|
||||
|
||||
# ── Embedding Model ──
|
||||
[embedding]
|
||||
model = "Qwen3-Embedding-4B"
|
||||
model = "Qwen/Qwen3-Embedding-4B"
|
||||
base_url = "https://ai.gitee.com/v1"
|
||||
api_key = "${GITEE_API_KEY}"
|
||||
api_key = "${OPENAI_API_KEY}"
|
||||
|
||||
# ── Reranker Model ──
|
||||
[reranker]
|
||||
model = "Qwen3-Reranker-4B"
|
||||
base_url = "https://ai.gitee.com/v1"
|
||||
api_key = "${GITEE_API_KEY}"
|
||||
api_key = "${OPENAI_API_KEY}"
|
||||
top_n = 3
|
||||
|
||||
# ── Logging ──
|
||||
|
|
@ -51,20 +50,6 @@ file = "logs/app.log"
|
|||
max_bytes = 10485760
|
||||
backup_count = 5
|
||||
|
||||
# ── Performance Logging ──
|
||||
[perf_logging]
|
||||
enabled = true
|
||||
file = "logs/perf.log"
|
||||
max_bytes = 10485760
|
||||
backup_count = 3
|
||||
|
||||
# ── LangSmith Tracing ──
|
||||
[langsmith]
|
||||
enabled = true
|
||||
api_key = "${LANGSMITH_API_KEY}"
|
||||
project = "algonotes-rag"
|
||||
endpoint = "https://api.smith.langchain.com"
|
||||
|
||||
# ── Storage Paths ──
|
||||
[store]
|
||||
chroma_dir = "data/chroma_db"
|
||||
|
|
@ -84,7 +69,7 @@ model = "DeepSeek-R1"
|
|||
base_url = "https://ai.gitee.com/v1"
|
||||
|
||||
[embedding]
|
||||
model = "Qwen3-Embedding-4B"
|
||||
model = "Qwen/Qwen3-Embedding-4B"
|
||||
base_url = "https://ai.gitee.com/v1"
|
||||
```
|
||||
|
||||
|
|
@ -96,7 +81,7 @@ model = "deepseek-ai/DeepSeek-V4-Flash"
|
|||
base_url = "https://api.siliconflow.cn/v1"
|
||||
|
||||
[embedding]
|
||||
model = "Qwen3-Embedding-4B"
|
||||
model = "Qwen/Qwen3-Embedding-4B"
|
||||
base_url = "https://api.siliconflow.cn/v1"
|
||||
```
|
||||
|
||||
|
|
@ -135,7 +120,7 @@ logger = setup_logger()
|
|||
|
||||
1. 如果 `config.toml` 不存在,使用 Pydantic 模型中的硬编码默认值
|
||||
2. 如果 `config.toml` 存在,以其为准
|
||||
3. `${GITEE_API_KEY}` 等占位符在加载时被 `.env` 中的实际值替换
|
||||
3. `${OPENAI_API_KEY}` 等占位符在加载时被 `.env` 中的实际值替换
|
||||
|
||||
## 公共题库(CPGraph)
|
||||
|
||||
|
|
@ -149,39 +134,3 @@ url = "https://mcp.cpgraph.top/mcp" # CPGraph MCP 服务地址
|
|||
|
||||
- `enabled = false`:仅查询个人笔记(默认)
|
||||
- `enabled = true`:同时查询公共题库,需要 CPGraph MCP 服务可用
|
||||
|
||||
## 性能监控
|
||||
|
||||
### 双通道日志
|
||||
|
||||
系统输出两条独立的日志流:
|
||||
|
||||
| 通道 | 文件 | 内容 |
|
||||
|:---|:---|:---|
|
||||
| 操作日志 | `logs/app.log` | 所有模块的 INFO/DEBUG/WARNING/ERROR 日志 |
|
||||
| 性能日志 | `logs/perf.log` | 仅性能埋点(PerfTimer),含 `latency_ms`/`trace_id`/`score_range` 等 |
|
||||
|
||||
两条通道通过 `algonotes.perf` 独立 logger(`propagate = False`)隔离,互不污染。
|
||||
|
||||
### 性能埋点覆盖
|
||||
|
||||
| call_type | 文件 | 采集方式 |
|
||||
|:---|:---|:---|
|
||||
| `reranker` | `src/api/reranker_client.py` | `PerfTimer` 上下文管理器 |
|
||||
| `pipeline` | `scripts/rag.py` | `PerfTimer` 上下文管理器 |
|
||||
| `text_cleaning` | `src/ingestion/cleaner.py` | `PerfTimer` 上下文管理器 |
|
||||
| `tag_extraction` | `src/ingestion/tagger.py` | `PerfTimer` 上下文管理器 |
|
||||
|
||||
### 查看性能报告
|
||||
|
||||
```bash
|
||||
algonotes perf # 按 call_type 聚合的延迟统计表
|
||||
algonotes perf --detail # 额外展示每条记录的详细信息
|
||||
algonotes perf --json report.json # 导出 JSON 结构化数据
|
||||
```
|
||||
|
||||
### LangSmith 全链路追踪
|
||||
|
||||
`config.toml` 中 `[langsmith]` 段控制 LangSmith 集成。设置 `enabled = true` 后,
|
||||
LangChain 自动为所有 LLM / Tool / Retriever 调用上报 Trace 到 LangSmith Cloud。
|
||||
本地 `perf.log` 的 `trace_id` 与 LangSmith Trace 的 `run_id` 一致,可交叉查询。
|
||||
|
|
|
|||
460
docs/LOGGER.md
460
docs/LOGGER.md
|
|
@ -1,460 +0,0 @@
|
|||
# 📋 日志系统设计
|
||||
|
||||
> 本文档描述 AlgoNotes RAG 的日志系统架构,包括双通道设计、JSON 格式化器、性能日志集成、
|
||||
> 配置模型与改进计划。当前版本为 v1.0 基线分析,v1.1 目标架构在此基础上扩展。
|
||||
|
||||
---
|
||||
|
||||
## 📊 当前架构(v1.0 基线)
|
||||
|
||||
### 总览
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph entry["入口"]
|
||||
CLI["scripts/cli.py<br/>setup_logger console=True/False"]
|
||||
end
|
||||
|
||||
subgraph core["src/logger.py"]
|
||||
ROOT["root logger 'algonotes'<br/>level: INFO (config)"]
|
||||
FILE["RotatingFileHandler<br/>logs/app.log<br/>10MB × 5, DEBUG"]
|
||||
STDERR["StreamHandler stderr<br/>plain text, INFO<br/>仅 console=True"]
|
||||
FMT["JsonFormatter<br/>固定 6 字段"]
|
||||
end
|
||||
|
||||
subgraph modules["~20 个模块"]
|
||||
SRC["src/**/*.py<br/>logging.getLogger('algonotes.xxx')"]
|
||||
SCRIPTS["scripts/*.py<br/>setup_logger('algonotes.cli.xxx')"]
|
||||
end
|
||||
|
||||
CLI --> ROOT
|
||||
ROOT --> FILE
|
||||
ROOT --> STDERR
|
||||
FILE --> FMT
|
||||
SRC --> ROOT
|
||||
SCRIPTS --> ROOT
|
||||
```
|
||||
|
||||
### 模块清单
|
||||
|
||||
| 模块 | 文件 | 行数 | 职责 |
|
||||
|:---|:---|:---:|:---|
|
||||
| **setup_logger()** | `src/logger.py` | 49 | 创建 root logger + 两个 Handler,可重入 |
|
||||
| **JsonFormatter** | `src/logger.py` | 12 | 每条日志格式化为一行 JSON,固定 6 字段 |
|
||||
| **LoggingConfig** | `src/config.py` | 6 | Pydantic 模型:`level` / `file` / `max_bytes` / `backup_count` |
|
||||
| **config.toml** | `config.toml` | 5 | `[logging]` 段:INFO 级别,10MB 滚动,5 个备份 |
|
||||
|
||||
### 日志层级
|
||||
|
||||
```
|
||||
algonotes # root,INFO 级别
|
||||
├── algonotes.api.llm_client # LLM 客户端初始化
|
||||
├── algonotes.api.embedding_client # Embedding 客户端初始化
|
||||
├── algonotes.api.reranker_client # Reranker 请求/响应
|
||||
├── algonotes.store.file_store # 文件 CRUD
|
||||
├── algonotes.store.sql_store # SQL 操作
|
||||
├── algonotes.store.vector_store # Chroma CRUD + 搜索
|
||||
├── algonotes.ingestion.loader # 文档加载
|
||||
├── algonotes.ingestion.cleaner # LLM 文本清洗(含重试)
|
||||
├── algonotes.ingestion.splitter # 分块统计
|
||||
├── algonotes.ingestion.tagger # LLM 标签提取
|
||||
├── algonotes.rag.agent # Agent 创建
|
||||
├── algonotes.rag.retriever # 检索工具调用
|
||||
├── algonotes.rag.generation # 重排序工具调用
|
||||
├── algonotes.cli.ingest # CLI 摄入入口
|
||||
├── algonotes.cli.update # CLI 更新入口
|
||||
├── algonotes.cli.delete # CLI 删除入口
|
||||
└── algonotes.cli.query # CLI 查询入口
|
||||
```
|
||||
|
||||
### JsonFormatter(v1.0)
|
||||
|
||||
```python
|
||||
class JsonFormatter(logging.Formatter):
|
||||
def format(self, record):
|
||||
log_entry = {
|
||||
"timestamp": self.formatTime(record),
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"message": record.getMessage(),
|
||||
"latency_ms": getattr(record, "latency_ms", None), # 始终为 None
|
||||
"tokens": getattr(record, "tokens", None), # 始终为 None
|
||||
}
|
||||
return json.dumps(log_entry, ensure_ascii=False)
|
||||
```
|
||||
|
||||
固定 6 字段,扩展性为零。`latency_ms` 和 `tokens` 字段预留了接口但从未有代码写入。
|
||||
|
||||
---
|
||||
|
||||
## 🔍 v1.0 问题诊断
|
||||
|
||||
### 结构性问题
|
||||
|
||||
| # | 问题 | 严重度 | 影响 |
|
||||
|:---:|:---|:---:|:---|
|
||||
| 1 | **JsonFormatter 字段硬编码** | 🔴 高 | 无法追加 `call_type` / `model` / `doc_count` 等性能字段;任何新增字段都需改源码 |
|
||||
| 2 | **`latency_ms` / `tokens` 永为 None** | 🔴 高 | README 宣称有性能日志,实际零数据产出 |
|
||||
| 3 | **无独立性能日志通道** | 🟡 中 | 性能数据与操作日志混在同一文件,无法按需过滤或单独轮转 |
|
||||
| 4 | **无 Trace 关联** | 🟡 中 | 本地日志与 LangSmith Trace 之间无法关联——同一条查询在两边的时间戳可能相差秒级 |
|
||||
| 5 | **`setup_logger` 被 scripts 层重复调用** | 🟢 低 | 每个 `scripts/*.py` 都调用 `setup_logger()`,虽因可重入设计无害,但语义混乱(src 层用 `getLogger`,scripts 层用 `setup_logger`) |
|
||||
|
||||
### 安全隐患
|
||||
|
||||
| # | 问题 | 严重度 | 影响 |
|
||||
|:---:|:---|:---:|:---|
|
||||
| 6 | **Reranker 日志打印完整请求体** | 🔴 高 | `reranker_client.py:123` `logger.info(f"body: {payload}")` — 若 API Key 误写入 payload 中则会以明文泄露到日志文件 |
|
||||
| 7 | **URL 未脱敏** | 🟡 中 | 多处日志打印 `base_url`,虽当前不含敏感参数,但缺乏防御性 |
|
||||
|
||||
### 运维问题
|
||||
|
||||
| # | 问题 | 严重度 | 影响 |
|
||||
|:---:|:---|:---:|:---|
|
||||
| 8 | **仅按大小轮转,无时间维度** | 🟢 低 | 无法按"保留最近 7 天"策略清理旧日志 |
|
||||
| 9 | **无日志级别动态切换** | 🟢 低 | 生产环境需 DEBUG 排查时,必须改 `config.toml` 重启 |
|
||||
| 10 | **`console=True` 无第三方日志控制** | 🟢 低 | 只控制了 `algonotes` 命名空间的日志,LangChain/httpx 等第三方库的 DEBUG 日志无法开关 |
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ v1.1 目标架构
|
||||
|
||||
### 设计原则
|
||||
|
||||
1. **双通道分离** — 操作日志 (`app.log`) 与性能日志 (`perf.log`) 独立文件、独立轮转、独立格式
|
||||
2. **模板化 JSON** — Formatter 接受 `extra` 中任意字段,不再硬编码字段列表
|
||||
3. **LangSmith 关联** — 每条性能日志携带 `trace_id` 和 `run_id`,与 LangSmith Trace 一一对应
|
||||
4. **安全默认** — 请求体默认不记录,需显式开启;URL 自动脱敏
|
||||
5. **类型安全** — `log_perf()` 方法通过参数类型约束性能字段,避免拼写错误
|
||||
6. **零侵入** — 现有 20+ 模块的 `logger.info(...)` 调用无需改动
|
||||
|
||||
### 架构图
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph config["配置层"]
|
||||
LOG_CFG["[logging] 操作日志<br/>level / file / rotation"]
|
||||
PERF_CFG["[logging.perf] 性能日志<br/>enabled / file / rotation"]
|
||||
end
|
||||
|
||||
subgraph core["src/logger.py"]
|
||||
ROOT["root 'algonotes'"]
|
||||
OP_FILE["RotatingFileHandler<br/>logs/app.log, JSON"]
|
||||
PERF_FILE["RotatingFileHandler<br/>logs/perf.log, JSON"]
|
||||
CONSOLE["StreamHandler stderr<br/>plain text, INFO"]
|
||||
|
||||
OP_FMT["OpJsonFormatter<br/>模板化: extra 字段全量输出"]
|
||||
PERF_FMT["PerfJsonFormatter<br/>模板化 + trace_id 注入"]
|
||||
end
|
||||
|
||||
subgraph perf["src/performance.py"]
|
||||
MONITOR["log_perf()<br/>类型安全便捷方法"]
|
||||
TIMER["PerfTimer<br/>自动调用 log_perf()"]
|
||||
end
|
||||
|
||||
subgraph modules["~20 个模块 (零改动)"]
|
||||
EXISTING["现有 logger.info/warning/error"]
|
||||
end
|
||||
|
||||
LOG_CFG --> OP_FILE
|
||||
PERF_CFG --> PERF_FILE
|
||||
ROOT --> OP_FILE
|
||||
ROOT --> PERF_FILE
|
||||
ROOT --> CONSOLE
|
||||
OP_FILE --> OP_FMT
|
||||
PERF_FILE --> PERF_FMT
|
||||
PERF_FILE -->|"注入"| LS["LangSmith trace_id"]
|
||||
MONITOR -->|"logger.info(..., extra={...})"| PERF_FILE
|
||||
TIMER --> MONITOR
|
||||
EXISTING --> ROOT
|
||||
```
|
||||
|
||||
### 改动清单
|
||||
|
||||
| # | 文件 | 操作 | 行数 | 说明 |
|
||||
|:---:|:---|:---|:---:|:---|
|
||||
| 1 | `src/logger.py` | **重写** | ~120 | 双 Handler、`OpJsonFormatter`、`PerfJsonFormatter`、`log_perf()` |
|
||||
| 2 | `src/config.py` | 修改 | +15 | `LoggingConfig` 扩展 `perf` 子配置;新增 `LangSmithConfig` |
|
||||
| 3 | `config.toml` | 修改 | +10 | 新增 `[logging.perf]` 和 `[langsmith]` 配置段 |
|
||||
| 4 | `src/api/reranker_client.py` | 修改 | -2 | 删除 `logger.info(f"body: {payload}")` |
|
||||
| 5 | `scripts/*.py` (5 文件) | 修改 | -5 | 删除冗余 `setup_logger()` 调用,改用 `getLogger()` |
|
||||
| 6 | `src/performance.py` | 新增 | ~80 | PerfTimer + `_trace_to_langsmith()`(来自 PERFORMANCE.md) |
|
||||
|
||||
---
|
||||
|
||||
## 📦 核心模块设计
|
||||
|
||||
### OpJsonFormatter — 操作日志格式化器
|
||||
|
||||
```python
|
||||
class OpJsonFormatter(logging.Formatter):
|
||||
"""操作日志 JSON 格式化器。
|
||||
|
||||
输出规则:
|
||||
- 固定字段: timestamp, level, logger, message
|
||||
- 扩展字段: 自动展开 logging.LogRecord 的 extra 字典
|
||||
- 排除: args, exc_info, 等 logging 内部字段
|
||||
"""
|
||||
|
||||
_FIXED_FIELDS = {"timestamp", "level", "logger", "message"}
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
entry = {
|
||||
"timestamp": self.formatTime(record),
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"message": record.getMessage(),
|
||||
}
|
||||
# 自动展开 extra 中的自定义字段
|
||||
for key, value in record.__dict__.items():
|
||||
if key not in self._FIXED_FIELDS and not key.startswith("_"):
|
||||
if key not in logging.LogRecord.__dict__:
|
||||
entry[key] = value
|
||||
return json.dumps(entry, ensure_ascii=False, default=str)
|
||||
```
|
||||
|
||||
> **关键改进**:不再硬编码字段列表。调用方通过 `logger.info("msg", extra={"custom_field": val})` 即可向日志注入任意结构化字段,无需修改 Formatter。
|
||||
|
||||
### PerfJsonFormatter — 性能日志格式化器
|
||||
|
||||
```python
|
||||
class PerfJsonFormatter(OpJsonFormatter):
|
||||
"""性能日志 JSON 格式化器。
|
||||
|
||||
在操作日志基础上:
|
||||
- 强制注入 trace_id(从 contextvars 读取 LangSmith run_id)
|
||||
- 可配置是否美化输出(开发环境)
|
||||
"""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
# 注入当前 LangSmith trace context
|
||||
if not hasattr(record, "trace_id"):
|
||||
record.trace_id = _get_current_trace_id() # 从 contextvars 读取
|
||||
return super().format(record)
|
||||
```
|
||||
|
||||
### log_perf() — 性能日志便捷方法
|
||||
|
||||
```python
|
||||
# 定义在 src/logger.py,供 src/performance.py 调用
|
||||
|
||||
def log_perf(
|
||||
call_type: str,
|
||||
latency_ms: float,
|
||||
*,
|
||||
model: str | None = None,
|
||||
doc_count: int | None = None,
|
||||
chunk_count: int | None = None,
|
||||
token_count: int | None = None,
|
||||
success: bool = True,
|
||||
error: str | None = None,
|
||||
extra: dict | None = None,
|
||||
) -> None:
|
||||
"""写入一条性能日志到 logs/perf.log。
|
||||
|
||||
内部实现: 将所有参数打包进 extra,调用
|
||||
perf_logger.info(f"{call_type} call completed", extra={...})
|
||||
|
||||
Args 类型约束确保性能字段不会拼写错误。
|
||||
"""
|
||||
_perf_logger = logging.getLogger("algonotes.perf")
|
||||
|
||||
perf_data = {
|
||||
"call_type": call_type,
|
||||
"latency_ms": round(latency_ms, 2),
|
||||
"model": model,
|
||||
"doc_count": doc_count,
|
||||
"chunk_count": chunk_count,
|
||||
"token_count": token_count,
|
||||
"success": success,
|
||||
"error": error,
|
||||
}
|
||||
# 合并调用方追加的额外字段(如 score_range, finish_reason 等)
|
||||
if extra:
|
||||
perf_data.update(extra)
|
||||
|
||||
# 过滤 None 值,保持日志简洁
|
||||
perf_data = {k: v for k, v in perf_data.items() if v is not None}
|
||||
|
||||
_perf_logger.info(f"{call_type} completed in {latency_ms:.1f}ms", extra=perf_data)
|
||||
```
|
||||
|
||||
### setup_logger() — 入口函数
|
||||
|
||||
```python
|
||||
def setup_logger(name: str = "algonotes", *, console: bool = False) -> logging.Logger:
|
||||
"""初始化日志系统 — 可重入,后续调用为 no-op。
|
||||
|
||||
创建 Handler:
|
||||
1. 操作日志 Handler — 写入 logs/app.log
|
||||
- Formatter: OpJsonFormatter
|
||||
- Level: DEBUG (config.logging.level 控制消息级别)
|
||||
- Rotation: 按大小 (max_bytes) + 备份数 (backup_count)
|
||||
|
||||
2. 性能日志 Handler — 写入 logs/perf.log
|
||||
- Formatter: PerfJsonFormatter
|
||||
- Level: INFO
|
||||
- Rotation: 独立配置 (perf_max_bytes / perf_backup_count)
|
||||
- enabled=False 时不创建此 Handler
|
||||
|
||||
3. Console Handler (可选) — 写入 stderr
|
||||
- Formatter: plain text
|
||||
- Level: INFO
|
||||
- 仅 console=True 时创建
|
||||
"""
|
||||
|
||||
# ... 创建 root logger + 三个 Handler
|
||||
|
||||
# 静默第三方库的 DEBUG 噪音
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("chromadb").setLevel(logging.WARNING)
|
||||
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
||||
```
|
||||
|
||||
### 配置模型(`src/config.py` 扩展)
|
||||
|
||||
```python
|
||||
class PerfLoggingConfig(BaseModel):
|
||||
"""性能日志独立配置"""
|
||||
enabled: bool = True
|
||||
file: str = "logs/perf.log"
|
||||
max_bytes: int = 10 * 1024 * 1024 # 10MB
|
||||
backup_count: int = 3
|
||||
|
||||
|
||||
class LoggingConfig(BaseModel):
|
||||
"""操作日志 + 性能日志配置"""
|
||||
level: str = "INFO"
|
||||
file: str = "logs/app.log"
|
||||
max_bytes: int = 10 * 1024 * 1024
|
||||
backup_count: int = 5
|
||||
perf: PerfLoggingConfig = PerfLoggingConfig() # ← 新增
|
||||
|
||||
|
||||
class LangSmithConfig(BaseModel):
|
||||
"""LangSmith 可观测性配置(原仅环境变量,现正式化)"""
|
||||
enabled: bool = True
|
||||
api_key: str = ""
|
||||
project: str = "algonotes-rag"
|
||||
endpoint: str = "https://api.smith.langchain.com"
|
||||
```
|
||||
|
||||
对应的 `config.toml`:
|
||||
|
||||
> ⚠️ `[langsmith]` 和 `[llm]` 等段中的 `api_key` 必须使用 `${VAR}` 引用环境变量,**禁止**在 `config.toml` 中硬编码真实 Key。这一模式已在项目现有的 `[llm]` / `[embedding]` / `[reranker]` 配置中统一采用。
|
||||
|
||||
```toml
|
||||
[logging]
|
||||
level = "INFO"
|
||||
file = "logs/app.log"
|
||||
max_bytes = 10485760
|
||||
backup_count = 5
|
||||
|
||||
[logging.perf]
|
||||
enabled = true
|
||||
file = "logs/perf.log"
|
||||
max_bytes = 10485760
|
||||
backup_count = 3
|
||||
|
||||
[langsmith]
|
||||
enabled = true
|
||||
api_key = "${LANGSMITH_API_KEY}"
|
||||
project = "${LANGSMITH_PROJECT}"
|
||||
endpoint = "https://api.smith.langchain.com"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 日志格式示例
|
||||
|
||||
### 操作日志(`logs/app.log`)
|
||||
|
||||
```json
|
||||
{"timestamp":"2026-07-04 15:30:00,123","level":"INFO","logger":"algonotes.api.llm_client","message":"Initializing chat model: Qwen3-Next-80B-A3B-Instruct"}
|
||||
{"timestamp":"2026-07-04 15:30:00,456","level":"DEBUG","logger":"algonotes.ingestion.splitter","message":"Header split produced 5 chunks"}
|
||||
{"timestamp":"2026-07-04 15:30:01,789","level":"INFO","logger":"algonotes.rag.retriever","message":"search_notes: query='树状数组', top_k=5"}
|
||||
{"timestamp":"2026-07-04 15:30:01,890","level":"WARNING","logger":"algonotes.rag.retriever","message":"get_file_content: file not found 'unknown.md'"}
|
||||
{"timestamp":"2026-07-04 15:30:03,456","level":"ERROR","logger":"algonotes.api.reranker_client","message":"Rerank API error: 500 Internal Server Error"}
|
||||
```
|
||||
|
||||
### 性能日志(`logs/perf.log`)
|
||||
|
||||
```json
|
||||
{"timestamp":"2026-07-04 15:30:01,234","level":"INFO","logger":"algonotes.perf","message":"reranker completed in 500.1ms","call_type":"reranker","latency_ms":500.12,"model":"Qwen3-Reranker-4B","doc_count_input":15,"doc_count_output":3,"score_range":{"min":0.32,"max":0.95,"mean":0.67},"success":true,"trace_id":"run-abc123"}
|
||||
{"timestamp":"2026-07-04 15:30:04,567","level":"INFO","logger":"algonotes.perf","message":"pipeline completed in 3200.8ms","call_type":"pipeline","latency_ms":3200.80,"agent_steps":4,"tool_calls":{"search_notes":1,"get_file_content":1,"rerank_results":1},"context_truncated":false,"finish_reason":"stop","success":true,"trace_id":"run-abc123"}
|
||||
{"timestamp":"2026-07-04 16:00:10,123","level":"INFO","logger":"algonotes.perf","message":"ingest completed in 4500.0ms","call_type":"ingest","latency_ms":4500.00,"filename":"fenwick.md","file_size_bytes":3200,"chunk_count":5,"chunk_truncated":0,"tags":"树状数组, 数据结构","success":true}
|
||||
```
|
||||
|
||||
> **`trace_id` 字段**:与 LangSmith Trace 的 `run_id` 一致,可直接在 LangSmith Web UI 搜索跳转。
|
||||
|
||||
---
|
||||
|
||||
## 🔒 安全与运维改进
|
||||
|
||||
### 问题:Reranker 日志膨胀
|
||||
|
||||
`reranker_client.py:121-123` 当前代码:
|
||||
|
||||
```python
|
||||
logger.info(f"POST {url}")
|
||||
logger.info(f"body: {payload}") # ← 打印完整请求体,含 documents 全文
|
||||
```
|
||||
|
||||
API Key 在 `Authorization` header 中,**body 日志不会泄露 Key**。但 `documents` 是待重排序的候选文本列表,在 Agent 检索场景下每条可能数百字符、总计数千字符——这会导致单条日志行极长(>5000 chars),日志文件快速膨胀。
|
||||
|
||||
**修复**:
|
||||
|
||||
```python
|
||||
# 替换为摘要日志(保护 Key 的 header 本就未打印,此处只控制 body 长度)
|
||||
logger.info(
|
||||
f"Rerank request: model={self._model}, "
|
||||
f"docs={len(documents)}, top_n={top_n}"
|
||||
)
|
||||
# 完整 payload 仅在 DEBUG 级别 + 显式开启时记录
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug(
|
||||
f"Rerank payload: query='{query[:100]}...', "
|
||||
f"docs_count={len(documents)}, "
|
||||
f"total_chars={sum(len(d) for d in documents)}"
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 验证方式
|
||||
|
||||
```bash
|
||||
# 1. 确认双通道正常
|
||||
ls -la logs/
|
||||
# 预期: app.log (操作日志) + perf.log (性能日志)
|
||||
|
||||
# 2. 操作日志包含 DEBUG 级别的分块/检索细节
|
||||
uv run algonotes query ask "树状数组" --verbose
|
||||
tail -5 logs/app.log
|
||||
# 预期看到: splitter, retriever, agent 的 INFO/DEBUG 日志
|
||||
|
||||
# 3. 性能日志包含 latency_ms / trace_id
|
||||
tail -3 logs/perf.log | python -m json.tool
|
||||
# 预期: call_type, latency_ms, trace_id 字段均非 null
|
||||
|
||||
# 4. 验证 trace_id 可关联 LangSmith
|
||||
# 从 perf.log 取一条 trace_id → LangSmith Web UI 搜索 → 应跳转到对应 Trace
|
||||
|
||||
# 5. 验证敏感信息不泄露
|
||||
grep -i "api_key\|token\|secret" logs/app.log logs/perf.log
|
||||
# 预期: 无匹配 (除非显式配置了 DEBUG payload logging)
|
||||
|
||||
# 6. 验证轮转正常 (可选,需要触发)
|
||||
# 写入超过 10MB 后检查 logs/app.log.1 是否生成
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
1. **现有 logger.info() 调用零改动**:`OpJsonFormatter` 自动展开 `extra` 字段,不传 `extra` 时输出与 v1.0 完全兼容(仅多了文件头一致的时间戳格式)。
|
||||
2. **性能日志默认分离**:`logs/perf.log` 独立轮转,不因性能数据高频写入而挤占操作日志的保留空间。
|
||||
3. **`trace_id` 自动注入**:PerfJsonFormatter 通过 `contextvars` 读取当前 LangChain/LangSmith 的 `run_id`,调用方无需手动传入。若上下文无 trace(如非 LangChain 调用路径),则 `trace_id` 为 `null`。
|
||||
4. **PerfTimer 与 log_perf() 的关系**:`PerfTimer.__exit__` 内部调用 `log_perf()`,应用代码不应同时使用两者记录同一段逻辑。
|
||||
5. **日志级别建议**:操作日志 `INFO`(生产)/ `DEBUG`(开发排查);性能日志始终 `INFO`(每条都是一次 API 调用)。
|
||||
6. **第三方库噪音控制**:`setup_logger()` 自动将 `httpx`、`chromadb`、`urllib3` 的日志级别设为 `WARNING`,避免 DEBUG 模式下日志被 HTTP 请求头刷屏。
|
||||
7. **`console=True` 不输出性能日志**:stderr handler 仅绑到 root `algonotes` 的操作日志通道,`algonotes.perf` 日志仅写入文件(避免干扰终端交互体验)。
|
||||
|
|
@ -1,626 +0,0 @@
|
|||
# 📈 性能收集方案设计
|
||||
|
||||
> 本文档描述 AlgoNotes RAG 的性能数据收集方案,基于 LangSmith 全链路追踪 + 本地 PerfTimer 补充埋点。
|
||||
> 目标:自动采集 LLM / Embedding / Reranker / Tool 各环节的延迟与 Token 消耗,产出本地 JSON Lines 日志
|
||||
> 与 `docs/PERFORMANCE_REPORT.md`,同时保留 LangSmith Web 端的交互式 Trace 探索能力。
|
||||
|
||||
---
|
||||
|
||||
## 📊 总览
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph auto["LangSmith 自动追踪 — 零代码"]
|
||||
LLM["LLM 调用"]
|
||||
EMB["Embedding 调用"]
|
||||
TOOL["Tool 调用"]
|
||||
RETR["Retriever 调用"]
|
||||
end
|
||||
|
||||
subgraph manual["PerfTimer 补充埋点"]
|
||||
RERANK["Reranker 调用 — raw httpx"]
|
||||
PIPE["整体流水线 — 端到端计时"]
|
||||
end
|
||||
|
||||
LLM -->|"自动上报"| LS["LangSmith Cloud — Web UI 交互探索"]
|
||||
EMB -->|"自动上报"| LS
|
||||
TOOL -->|"自动上报"| LS
|
||||
RETR -->|"自动上报"| LS
|
||||
RERANK -->|"PerfTimer"| LOCAL["本地 JSON Lines 日志"]
|
||||
PIPE -->|"PerfTimer"| LOCAL
|
||||
|
||||
LS -->|"langsmith.Client.list_runs"| REPORT["docs/PERFORMANCE_REPORT.md"]
|
||||
LOCAL -->|"补充非 LangChain 调用"| REPORT
|
||||
```
|
||||
|
||||
| 阶段 | 采集方式 | 代码量 | 说明 |
|
||||
|:---|:---|:---:|:---|
|
||||
| **LLM 调用** | LangSmith 自动追踪 | 0 行 | 环境变量 `LANGSMITH_TRACING=true` 即可启用 |
|
||||
| **Embedding 调用** | LangSmith 自动追踪 | 0 行 | 同上,LangChain 自动区分 LLM / Embedding |
|
||||
| **Tool 调用** | LangSmith 自动追踪 | 0 行 | 含 `search_notes` / `get_file_content` / `search_by_tags` / `rerank_results` |
|
||||
| **Retriever 调用** | LangSmith 自动追踪 | 0 行 | Chroma 语义检索的延迟与文档数 |
|
||||
| **Reranker 调用** | `PerfTimer` 上下文管理器 | ~8 行 | raw httpx,不走 LangChain,需手动埋点 |
|
||||
| **整体流水线** | `PerfTimer` 上下文管理器 | ~5 行×4 | 单次 RAG 问答端到端、笔记摄入端到端 |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 设计选型
|
||||
|
||||
### 为什么用 LangSmith 替代自定义 CallbackHandler?
|
||||
|
||||
| 维度 | 自定义 CallbackHandler | LangSmith 自动追踪 |
|
||||
|:---|:---|:---|
|
||||
| **LLM 埋点** | 需实现 `on_llm_start/end`,~60 行 | 环境变量一行启用 |
|
||||
| **Tool 埋点** | 需实现 `on_tool_start/end`,~30 行 | 自动覆盖 |
|
||||
| **Retriever 埋点** | 需实现 `on_retriever_end`,~20 行 | 自动覆盖 |
|
||||
| **contextvars 隔离** | 需手动维护 `run_id → t0` 映射 | 框架层面已处理 |
|
||||
| **线程安全** | 需加锁保护聚合器 | 无需关心 |
|
||||
| **Token 用量提取** | 需解析 `response.llm_output` | 自动,且支持流式 |
|
||||
| **嵌套 Trace 树** | 需手动关联 parent_run_id | 自动构建完整调用树 |
|
||||
| **可视化** | 无 | Web UI 免费提供 |
|
||||
| **数据查询** | 需自己实现聚合逻辑 | `langsmith.Client` SDK 直接查询 |
|
||||
| **总代码量** | ~120 行 | **0 行** |
|
||||
|
||||
**结论**:LangSmith 已经做到了自定义 CallbackHandler 想做的一切,而且做得更好。我们只需要:
|
||||
1. 在 `config.toml` 中正式化 LangSmith 配置(环境变量 → 配置模型)
|
||||
2. 用 `PerfTimer` 覆盖 LangSmith 管不到的 Reranker 和整体流水线
|
||||
3. 用 `langsmith.Client` 查询数据生成离线报告
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 架构设计
|
||||
|
||||
### 模块职责
|
||||
|
||||
| 模块 | 文件 | 职责 |
|
||||
|:---|:---|:---|
|
||||
| **LangSmith 自动追踪** | 无需代码 | 设置 `LANGSMITH_TRACING=true` 后,LangChain 自动为所有 LLM/Tool/Retriever 调用上报 Trace |
|
||||
| **LangSmithConfig** | `src/config.py` | Pydantic 配置模型,从 `config.toml` `[langsmith]` 段读取,替代裸环境变量 |
|
||||
| **PerfTimer** | `src/performance.py` | 上下文管理器,计时任意代码块,写入本地 JSON Lines + 同步上报 LangSmith |
|
||||
| **log_perf()** | `src/logger.py` | 便捷方法,写入带 `latency_ms` / `tokens` / `call_type` 的 JSON Lines 日志 |
|
||||
| **perf CLI** | `scripts/perf_report.py` | CLI 子命令:从 LangSmith API + 本地日志聚合数据,生成 Markdown 报告 |
|
||||
|
||||
### 数据流
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph zero["零代码 — 环境变量启用"]
|
||||
ENV["LANGSMITH_TRACING = true"]
|
||||
end
|
||||
|
||||
subgraph langsmith["LangSmith Cloud"]
|
||||
TRACE["自动追踪<br/>LLM / Tool / Retriever<br/>完整嵌套 Trace 树"]
|
||||
UI["Web UI 交互探索"]
|
||||
SDK["langsmith.Client 编程查询"]
|
||||
end
|
||||
|
||||
subgraph perf["PerfTimer 手动埋点"]
|
||||
RERANK["reranker_client.py"]
|
||||
CLEAN["cleaner.py"]
|
||||
TAG["tagger.py"]
|
||||
RAG["rag.py"]
|
||||
CHAT["chat.py"]
|
||||
end
|
||||
|
||||
subgraph local["本地存储"]
|
||||
LOG["logs/app.log<br/>JSON Lines<br/>latency_ms / tokens / call_type"]
|
||||
end
|
||||
|
||||
subgraph report["报告生成"]
|
||||
CLI["scripts/perf_report.py<br/>algonotes perf"]
|
||||
MD["docs/PERFORMANCE_REPORT.md"]
|
||||
end
|
||||
|
||||
ENV -->|"零配置自动生效"| TRACE
|
||||
TRACE --> UI
|
||||
TRACE --> SDK
|
||||
RERANK -->|"__exit__ 写入"| LOG
|
||||
CLEAN -->|"__exit__ 写入"| LOG
|
||||
TAG -->|"__exit__ 写入"| LOG
|
||||
RAG -->|"__exit__ 写入"| LOG
|
||||
CHAT -->|"__exit__ 写入"| LOG
|
||||
LOG -->|"回读解析"| CLI
|
||||
SDK -->|"list_runs 查询"| CLI
|
||||
CLI -->|"聚合输出"| MD
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 指标全景
|
||||
|
||||
RAG 管线的性能指标分为四个维度,覆盖从查询理解到答案生成的每个阶段:
|
||||
|
||||
| 维度 | 说明 | 采集难度 |
|
||||
|:---|:---|:---:|
|
||||
| **⏱️ 延迟** | 各阶段耗时,定位瓶颈 | 🟢 自动 |
|
||||
| **🔢 吞吐与成本** | Token 消耗、调用次数、单次查询成本 | 🟢 自动 |
|
||||
| **🛡️ 可靠性** | 空召回、截断、异常退出、finish_reason | 🟢 自动 |
|
||||
| **🎯 质量** | 召回精度、答案忠实度、引用正确性 | 🟡 需标注集 / LLM-as-Judge |
|
||||
|
||||
### 按阶段展开
|
||||
|
||||
#### 1. 检索阶段(Retrieval)
|
||||
|
||||
| 指标 | 类型 | 定义 | 采集方式 |
|
||||
|:---|:---|:---|:---|
|
||||
| `retrieval_latency_ms` | 延迟 | 向量检索耗时(`search_notes` 内 Chroma 相似度搜索) | LangSmith 自动(`on_retriever_end`) |
|
||||
| `retrieval_result_count` | 吞吐 | 实际返回的文档数(≤ top_k) | LangSmith 自动 |
|
||||
| `retrieval_empty_result` | 可靠性 | 是否空召回(`result_count == 0`) | LangSmith 自动,**>5% 告警** |
|
||||
| `retrieval_top_k` | 配置 | 请求的 top_k 值 | LangSmith 自动 |
|
||||
| `retrieval_scores` | 质量 | 返回文档的相似度分数分布 | LangSmith 自动(Chroma distance → similarity) |
|
||||
| `recall_at_k` | 质量 | Top-k 结果中包含相关文档的比例 | 🟡 需标注集 |
|
||||
| `precision_at_k` | 质量 | Top-k 结果中相关文档的比例 | 🟡 需标注集 |
|
||||
| `mrr` | 质量 | Mean Reciprocal Rank — 第一个相关文档的排名倒数 | 🟡 需标注集 |
|
||||
|
||||
> **空召回**是 RAG 系统最危险的静默故障——检索返回空结果时,Agent 要么编造答案,要么回复"未找到",两者都损害用户体验。竞赛场景下需重点展示**空召回率**和处理策略。
|
||||
|
||||
#### 2. 重排序阶段(Reranker)
|
||||
|
||||
| 指标 | 类型 | 定义 | 采集方式 |
|
||||
|:---|:---|:---|:---|
|
||||
| `rerank_latency_ms` | 延迟 | Gitee.AI `/v1/rerank` 接口耗时 | PerfTimer 手动 |
|
||||
| `rerank_input_count` | 吞吐 | 送入重排序的候选文档数 | PerfTimer 手动 |
|
||||
| `rerank_output_count` | 吞吐 | 重排序后保留的文档数(top_n) | PerfTimer 手动 |
|
||||
| `rerank_score_range` | 质量 | 重排序分数的 min / max / mean | PerfTimer 手动 |
|
||||
| `rerank_delta` | 质量 | 重排序前后 Top-3 集合变化率(判断重排序是否做了有用功) | PerfTimer + LangSmith 对比 |
|
||||
|
||||
> **`rerank_delta`** 是关键判断指标:如果重排序前后的 Top-3 几乎不变,说明向量检索已足够精确,重排序可能是多余的延迟开销。
|
||||
|
||||
#### 3. LLM 生成阶段(Generation)
|
||||
|
||||
| 指标 | 类型 | 定义 | 采集方式 |
|
||||
|:---|:---|:---|:---|
|
||||
| **`ttft_ms`** | 延迟 | **Time To First Token** — 首个 Token 产出耗时。= Prefill 时间 + 检索时间 + 重排序时间 | LangSmith 自动(首次 `on_llm_end` 或流式首个 chunk) |
|
||||
| **`tpot_ms`** | 延迟 | **Time Per Output Token** — 平均每个输出 Token 耗时。= `(total_latency - ttft) / (completion_tokens - 1)` | LangSmith 自动(计算得出) |
|
||||
| **`itl_ms`** | 延迟 | **Inter-Token Latency** — Token 间间隔。反映 decode 阶段 GPU 批处理争用,比 TPOT 更精细 | LangSmith 自动(需流式追踪) |
|
||||
| `generation_latency_ms` | 延迟 | LLM 调用总耗时(含 Prefill + Decode) | LangSmith 自动 |
|
||||
| `prompt_tokens` | 成本 | 输入 Token 数(System Prompt + 检索上下文 + 用户问题) | LangSmith 自动 |
|
||||
| `completion_tokens` | 成本 | 输出 Token 数 | LangSmith 自动 |
|
||||
| `total_tokens` | 成本 | 总 Token 消耗 | LangSmith 自动 |
|
||||
| `tokens_per_second` | 吞吐 | 生成速度(`completion_tokens / generation_latency_s`) | LangSmith 计算 |
|
||||
| `finish_reason` | 可靠性 | 生成终止原因。`"stop"` 正常 / `"length"` 截断 | LangSmith 自动,**length 率 >2% 告警** |
|
||||
| `context_token_count` | 成本 | 注入 LLM 的检索上下文 Token 数(反映 Chunk 利用率) | PerfTimer 手动 |
|
||||
| `context_truncated` | 可靠性 | 检索上下文是否因超长被截断(`bool`) | PerfTimer 手动 |
|
||||
|
||||
> **TTFT vs TPOT 的权衡**:TTFT 主要受 Prefill 计算和检索延迟影响;TPOT/ITL 主要受 Decode 阶段 GPU 批处理争用影响。小模型 TTFT 低但 ITL 高(更多并发请求争用),大模型反之。详见 [NVIDIA RAG Benchmark](https://docs.nvidia.com/rag/2.6.0/performance-benchmarking.html)。
|
||||
|
||||
#### 4. Agent 执行阶段
|
||||
|
||||
| 指标 | 类型 | 定义 | 采集方式 |
|
||||
|:---|:---|:---|:---|
|
||||
| `agent_step_count` | 吞吐 | Agent 执行步数(Tool 调用 + LLM 推理轮次) | LangSmith 自动(统计 on_tool_start 次数) |
|
||||
| `tool_call_breakdown` | 吞吐 | 各 Tool 调用次数分布(`search_notes` / `search_by_tags` / `get_file_content` / `rerank_results`) | LangSmith 自动 |
|
||||
| `tool_call_latency_by_name` | 延迟 | 每种 Tool 的平均/P50/P95 延迟 | LangSmith 自动 |
|
||||
| `reretrieval_rate` | 可靠性 | 触发二次检索的比例(Agent 循环检测) | LangSmith 自动(同 Tool 同参数重复调用) |
|
||||
| `agent_timeout_rate` | 可靠性 | Agent 执行超时的比例(无有效答案产出) | LangSmith 自动 |
|
||||
|
||||
> **Agent 步数过多**(>6 步)通常意味着:检索结果不够好导致反复重试,或 Agent Prompt 缺少退出条件。应在 System Prompt 中设定明确的"够了就停"信号。
|
||||
|
||||
#### 5. 摄入阶段(Ingestion)
|
||||
|
||||
| 指标 | 类型 | 定义 | 采集方式 |
|
||||
|:---|:---|:---|:---|
|
||||
| `ingest_latency_ms` | 延迟 | 单篇笔记摄入总耗时(Load → Split → Tag → Store) | PerfTimer 手动 |
|
||||
| `chunk_count` | 吞吐 | 分块数量 | SQLStore 记录 |
|
||||
| `chunk_truncation_rate` | 可靠性 | 超大 Chunk 被二次分割的比例 | Splitter 内计数 |
|
||||
| `chunk_size_distribution` | 质量 | 分块大小分布(min / max / mean / median) | Splitter 内统计 |
|
||||
| `ingest_file_size` | 吞吐 | 源文件大小(bytes) | FileStore 记录 |
|
||||
| `tag_extraction_latency_ms` | 延迟 | LLM 标签提取耗时 | PerfTimer 手动 |
|
||||
|
||||
> **Chunk 截断率**反映分块策略(当前 700 chars/chunk,overlap 120)是否合理。高截断率说明大部分笔记超过单 chunk 上限,应考虑调整 `chunk_size` 或按语义边界切分。
|
||||
|
||||
#### 6. 端到端(E2E)
|
||||
|
||||
| 指标 | 类型 | 定义 | 采集方式 |
|
||||
|:---|:---|:---|:---|
|
||||
| `e2e_latency_ms` | 延迟 | 用户提问 → 完整答案的总耗时 | PerfTimer 手动 |
|
||||
| `e2e_latency_breakdown` | 延迟 | 各阶段耗时占比(检索 / 重排 / LLM / 其他) | 综合计算 |
|
||||
| `cost_per_query` | 成本 | 单次查询 Token 费用(prompt_tokens × prompt_price + completion_tokens × completion_price) | 计算得出 |
|
||||
| `query_success_rate` | 可靠性 | 成功产出答案的查询比例 | PerfTimer 统计 |
|
||||
|
||||
---
|
||||
|
||||
### 分阶段实施计划
|
||||
|
||||
考虑到比赛交付周期,指标分两批落地:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph phase1["Phase 1 — 比赛交付 (自动采集)"]
|
||||
L1["延迟指标<br/>TTFT / TPOT / ITL / E2E"]
|
||||
C1["成本指标<br/>Token / 调用次数"]
|
||||
R1["可靠性指标<br/>空召回率 / finish_reason / 截断"]
|
||||
end
|
||||
|
||||
subgraph phase2["Phase 2 — 比赛后 (评估体系)"]
|
||||
Q2["质量指标<br/>Recall / Precision / MRR"]
|
||||
F2["忠实度指标<br/>Faithfulness / 引用正确性"]
|
||||
E2["Eval 数据集<br/>50-100 标注问题"]
|
||||
end
|
||||
|
||||
L1 --> REPORT["docs/PERFORMANCE_REPORT.md"]
|
||||
C1 --> REPORT
|
||||
R1 --> REPORT
|
||||
Q2 -.->|"需标注集"| REPORT
|
||||
F2 -.->|"需 LLM-as-Judge"| REPORT
|
||||
```
|
||||
|
||||
**Phase 1**(当前实现)聚焦自动采集的延迟、成本、可靠性三类指标——这些零人工标注成本,LangSmith + PerfTimer 全自动产出,满足比赛"真实调用验证 + 性能数据"的提交要求。
|
||||
|
||||
**Phase 2**(比赛后规划)引入质量评估:构建 50-100 道标注问题(已知正确答案和关联笔记),用 `recall@k`、`precision@k` 和 LLM-as-Judge 的忠实度评估来量化 RAG 回答质量。这部分需要人工标注投入,不适合在比赛周期内完成。
|
||||
|
||||
---
|
||||
|
||||
## 📦 核心类型
|
||||
|
||||
### PerfTimer(`src/performance.py`,~60 行)
|
||||
|
||||
```python
|
||||
import time
|
||||
from contextlib import ContextDecorator
|
||||
from typing import Literal
|
||||
|
||||
class PerfTimer(ContextDecorator):
|
||||
"""上下文管理器 + 装饰器 — 计时任意代码块,收集多维度指标。
|
||||
|
||||
用法(上下文管理器):
|
||||
with PerfTimer("reranker", model="Qwen3-Reranker-4B",
|
||||
doc_count=10, extra={"top_n": 3}) as t:
|
||||
results = reranker.rerank(query, docs)
|
||||
t.set_extra("score_range", {"min": 0.3, "max": 0.95})
|
||||
|
||||
用法(装饰器):
|
||||
@PerfTimer("pipeline")
|
||||
def ask_question(question): ...
|
||||
|
||||
__exit__ 时自动:
|
||||
1. 写入本地 JSON Lines 日志 (via log_perf)
|
||||
2. 同步上报 LangSmith (via langsmith.run_helpers.trace)
|
||||
3. 异常时标记 success=False 并记录 error
|
||||
"""
|
||||
|
||||
# 支持的调用类型
|
||||
CallType = Literal[
|
||||
"reranker", # 重排序调用
|
||||
"pipeline", # 端到端流水线
|
||||
"ingest", # 笔记摄入
|
||||
"tag_extraction", # LLM 标签提取
|
||||
"text_cleaning", # LLM 文本清洗
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
call_type: CallType,
|
||||
*,
|
||||
model: str | None = None,
|
||||
doc_count: int | None = None,
|
||||
chunk_count: int | None = None,
|
||||
token_count: int | None = None,
|
||||
extra: dict | None = None,
|
||||
):
|
||||
self.call_type = call_type
|
||||
self.model = model
|
||||
self.doc_count = doc_count
|
||||
self.chunk_count = chunk_count
|
||||
self.token_count = token_count
|
||||
self.extra = extra or {}
|
||||
self._start: float | None = None
|
||||
|
||||
def set_extra(self, key: str, value) -> None:
|
||||
"""在上下文内追加指标(如重排序分数分布)。"""
|
||||
self.extra[key] = value
|
||||
|
||||
@property
|
||||
def elapsed_ms(self) -> float:
|
||||
"""当前已用时间(可在上下文内随时读取)。"""
|
||||
if self._start is None:
|
||||
return 0.0
|
||||
return (time.perf_counter() - self._start) * 1000
|
||||
|
||||
def __enter__(self):
|
||||
self._start = time.perf_counter()
|
||||
return self # 返回 self 允许 with...as t 语法
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
elapsed = self.elapsed_ms
|
||||
success = exc_type is None
|
||||
|
||||
# 1. 本地 JSON Lines 日志
|
||||
log_perf(
|
||||
call_type=self.call_type,
|
||||
latency_ms=elapsed,
|
||||
model=self.model,
|
||||
doc_count=self.doc_count,
|
||||
chunk_count=self.chunk_count,
|
||||
token_count=self.token_count,
|
||||
success=success,
|
||||
error=str(exc_val) if exc_val else None,
|
||||
extra=self.extra,
|
||||
)
|
||||
|
||||
# 2. 同步上报 LangSmith (可选,由 config 控制)
|
||||
if _langsmith_enabled():
|
||||
_trace_to_langsmith(
|
||||
name=self.call_type,
|
||||
latency_ms=elapsed,
|
||||
model=self.model,
|
||||
doc_count=self.doc_count,
|
||||
extra=self.extra,
|
||||
error=exc_val,
|
||||
)
|
||||
|
||||
return False # 不吞异常
|
||||
```
|
||||
|
||||
> **设计要点**:
|
||||
> - 同时继承 `ContextDecorator`,既可用 `with` 也可用 `@` 装饰器。
|
||||
> - `__enter__` 返回 `self`,允许在 `with` 块内通过 `t.set_extra()` 动态追加指标。
|
||||
> - `elapsed_ms` 属性允许代码块内部实时读取已用时间(如记录中间检查点)。
|
||||
> - `CallType` 用 Literal 约束,防止拼写错误。
|
||||
|
||||
### LangSmithConfig(`src/config.py` 新增,~10 行)
|
||||
|
||||
```python
|
||||
class LangSmithConfig(BaseModel):
|
||||
"""LangSmith 可观测性配置。
|
||||
|
||||
环境变量 (.env) 中已有的 LANGSMITH_* 变量将被此配置模型统一管理,
|
||||
在 config.toml 中以 ${VAR} 语法引用。也可以通过 config.toml 直接写值。
|
||||
"""
|
||||
enabled: bool = True
|
||||
api_key: str = ""
|
||||
project: str = "algonotes-rag"
|
||||
endpoint: str = "https://api.smith.langchain.com"
|
||||
```
|
||||
|
||||
对应的 `config.toml`:
|
||||
|
||||
> ⚠️ `api_key` 必须使用 `${LANGSMITH_API_KEY}` 引用环境变量,**禁止**在 `config.toml` 中硬编码真实 Key。
|
||||
|
||||
```toml
|
||||
[langsmith]
|
||||
enabled = true
|
||||
api_key = "${LANGSMITH_API_KEY}"
|
||||
project = "${LANGSMITH_PROJECT}"
|
||||
endpoint = "https://api.smith.langchain.com"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 数据格式
|
||||
|
||||
### 本地 JSON Lines 日志示例
|
||||
|
||||
```json
|
||||
// LLM 调用 (LangSmith 自动上报,不在本地日志中)
|
||||
// → 数据存储于 LangSmith Cloud,通过 langsmith.Client.list_runs() 查询
|
||||
|
||||
// Reranker 调用 (PerfTimer 手动)
|
||||
{
|
||||
"timestamp": "2026-07-04 15:30:01,234",
|
||||
"level": "INFO",
|
||||
"logger": "algonotes.perf",
|
||||
"call_type": "reranker",
|
||||
"latency_ms": 500.12,
|
||||
"model": "Qwen3-Reranker-4B",
|
||||
"doc_count_input": 15,
|
||||
"doc_count_output": 3,
|
||||
"score_range": {"min": 0.32, "max": 0.95, "mean": 0.67},
|
||||
"success": true
|
||||
}
|
||||
|
||||
// Pipeline 端到端 (PerfTimer 手动)
|
||||
{
|
||||
"timestamp": "2026-07-04 15:30:04,567",
|
||||
"level": "INFO",
|
||||
"logger": "algonotes.perf",
|
||||
"call_type": "pipeline",
|
||||
"latency_ms": 3200.80,
|
||||
"agent_steps": 4,
|
||||
"tool_calls": {"search_notes": 1, "get_file_content": 1, "rerank_results": 1},
|
||||
"context_truncated": false,
|
||||
"finish_reason": "stop",
|
||||
"success": true
|
||||
}
|
||||
|
||||
// 摄入笔记 (PerfTimer 手动)
|
||||
{
|
||||
"timestamp": "2026-07-04 16:00:10,123",
|
||||
"level": "INFO",
|
||||
"logger": "algonotes.perf",
|
||||
"call_type": "ingest",
|
||||
"latency_ms": 4500.00,
|
||||
"filename": "fenwick.md",
|
||||
"file_size_bytes": 3200,
|
||||
"chunk_count": 5,
|
||||
"chunk_truncated": 0,
|
||||
"tags": "树状数组, 数据结构",
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
> **设计原则**:LangSmith 自动上报 LLM / Tool / Retriever 数据(在云端),本地日志仅记录 PerfTimer 覆盖的 Reranker / Pipeline / Ingest 调用。两者互补,`algonotes perf --export` 时会合并查询。
|
||||
|
||||
### 聚合报告(由 `algonotes perf --export` 生成)
|
||||
|
||||
```markdown
|
||||
## 📈 性能测试数据
|
||||
|
||||
> 测试时间:2026-07-04 15:30 ~ 16:00
|
||||
> 测试环境:沐曦 GPU (Gitee.AI)
|
||||
> LLM:Qwen3-Next-80B-A3B-Instruct
|
||||
> Embedding:Qwen3-Embedding-4B | Reranker:Qwen3-Reranker-4B
|
||||
> 数据来源:LangSmith Trace + 本地日志
|
||||
|
||||
### 延迟指标
|
||||
|
||||
| 指标 | 调用次数 | 平均 | P50 | P95 | P99 |
|
||||
|:---|:---:|:---:|:---:|:---:|:---:|
|
||||
| **TTFT** (首 Token 延迟) | 50 | 1850 ms | 1700 ms | 3200 ms | 4500 ms |
|
||||
| **TPOT** (每 Token 耗时) | 50 | 85 ms | 75 ms | 150 ms | 220 ms |
|
||||
| **ITL** (Token 间间隔) | 50 | 82 ms | 72 ms | 145 ms | 210 ms |
|
||||
| E2E RAG 问答 | 50 | 3200 ms | 2900 ms | 5200 ms | 6800 ms |
|
||||
| 向量检索 | 50 | 45 ms | 40 ms | 80 ms | 120 ms |
|
||||
| 重排序 | 50 | 500 ms | 450 ms | 780 ms | 950 ms |
|
||||
| 文件读取 | 20 | 2 ms | 1 ms | 5 ms | 10 ms |
|
||||
|
||||
### E2E 延迟分解
|
||||
|
||||
| 阶段 | 占比 | 平均耗时 |
|
||||
|:---|:---:|:---:|
|
||||
| 向量检索 (search_notes) | 1.4% | 45 ms |
|
||||
| 文件读取 (get_file_content) | <0.1% | 2 ms |
|
||||
| 重排序 (rerank_results) | 15.6% | 500 ms |
|
||||
| LLM Prefill (TTFT − 检索 − 重排) | 40.6% | 1300 ms |
|
||||
| LLM Decode (TPOT × output_tokens) | 42.4% | 1353 ms |
|
||||
| **合计** | **100%** | **3200 ms** |
|
||||
|
||||
### 吞吐与成本
|
||||
|
||||
| 指标 | 值 |
|
||||
|:---|:---|
|
||||
| 总查询次数 | 50 |
|
||||
| 总 Token 消耗 | 125,000 |
|
||||
| 平均 Prompt Token / 查询 | 520 |
|
||||
| 平均 Completion Token / 查询 | 180 |
|
||||
| 平均 Token / 秒 (生成速度) | 11.8 tok/s |
|
||||
| 平均每查询成本 | — (待填入 Gitee.AI 定价) |
|
||||
| 上下文平均 Token 数 | 420 |
|
||||
| Chunk 利用率 (被引用的上下文比例) | 65% |
|
||||
|
||||
### 可靠性
|
||||
|
||||
| 指标 | 次数 | 占比 | 告警阈值 |
|
||||
|:---|:---:|:---:|:---:|
|
||||
| 空召回 (检索返回 0 条) | 1 / 50 | 2% | >5% |
|
||||
| 生成截断 (finish_reason="length") | 0 / 50 | 0% | >2% |
|
||||
| 上下文截断 | 0 / 50 | 0% | >5% |
|
||||
| Agent 超步数 (>6 steps) | 2 / 50 | 4% | — |
|
||||
| 查询失败 (异常退出) | 0 / 50 | 0% | >1% |
|
||||
|
||||
### Agent 行为统计
|
||||
|
||||
| 指标 | 平均 | P50 | P95 |
|
||||
|:---|:---:|:---:|:---:|
|
||||
| Agent 步数 / 查询 | 3.2 | 3 | 5 |
|
||||
| Tool 调用 / 查询 | 2.8 | 3 | 4 |
|
||||
| search_notes 调用 / 查询 | 1.0 | 1 | 1 |
|
||||
| get_file_content 调用 / 查询 | 0.4 | 0 | 1 |
|
||||
| rerank_results 调用 / 查询 | 0.9 | 1 | 1 |
|
||||
| 二次检索率 | 8% | — | — |
|
||||
|
||||
### 摄入性能
|
||||
|
||||
| 指标 | 值 |
|
||||
|:---|:---|
|
||||
| 已摄入笔记数 | 7 |
|
||||
| 平均摄入耗时 / 篇 | 4500 ms |
|
||||
| 平均 Chunk 数 / 篇 | 5.3 |
|
||||
| Chunk 截断率 | 0% |
|
||||
| LLM 标签提取耗时 | 1200 ms |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 各文件改动清单
|
||||
|
||||
| # | 文件 | 改动类型 | 行数 | 说明 |
|
||||
|:---:|:---|:---|:---:|:---|
|
||||
| 1 | `src/performance.py` | **新增** | ~80 | PerfTimer + `_trace_to_langsmith()` + 模块初始化 |
|
||||
| 2 | `src/config.py` | 修改 | +10 | 新增 `LangSmithConfig`,`AppConfig` 加 `langsmith` 字段 |
|
||||
| 3 | `config.toml` | 修改 | +5 | 新增 `[langsmith]` 配置段 |
|
||||
| 4 | `src/logger.py` | 修改 | +15 | 新增 `log_perf()` 便捷方法 |
|
||||
| 5 | `src/api/reranker_client.py` | 修改 | +8 | `PerfTimer("reranker", ...)` 包装 `rerank()` |
|
||||
| 6 | `src/ingestion/cleaner.py` | 修改 | +3 | `@PerfTimer("pipeline")` 装饰器 |
|
||||
| 7 | `src/ingestion/tagger.py` | 修改 | +3 | 同上 |
|
||||
| 8 | `scripts/rag.py` | 修改 | +3 | `@PerfTimer("pipeline")` 装饰器 |
|
||||
| 9 | `scripts/chat.py` | 修改 | +3 | 同上(装饰 `chat_loop` 中的单次问答) |
|
||||
| 10 | `scripts/perf_report.py` | **新增** | ~60 | `algonotes perf` CLI,LangSmith API + 本地日志聚合 |
|
||||
|
||||
**删减项**(相比 v1 方案):
|
||||
|
||||
| 删减内容 | 省去代码 |
|
||||
|:---|:---:|
|
||||
| `PerformanceMonitor(BaseCallbackHandler)` | ~120 行 |
|
||||
| `SessionStats` 聚合器 | ~30 行 |
|
||||
| `CallRecord` dataclass | ~15 行 |
|
||||
| `llm_client.py` 的 `with_config()` 注入 | ~4 行 |
|
||||
| `embedding_client.py` 的 `with_config()` 注入 | ~4 行 |
|
||||
| `contextvars` 线程隔离逻辑 | ~10 行 |
|
||||
|
||||
总计:**~180 行新代码 + ~50 行修改**,相比 v1 方案节省约 90 行。
|
||||
|
||||
---
|
||||
|
||||
## 🧪 验证方式
|
||||
|
||||
### 前置配置
|
||||
|
||||
```bash
|
||||
# .env 中确保 LangSmith 已配置(与现有 .env.example 一致)
|
||||
LANGSMITH_TRACING=true
|
||||
LANGSMITH_ENDPOINT=https://api.smith.langchain.com
|
||||
LANGSMITH_API_KEY=lsv2_pt_xxxxx
|
||||
LANGSMITH_PROJECT=algonotes-rag
|
||||
```
|
||||
|
||||
或在新的 `config.toml` `[langsmith]` 段中统一管理。
|
||||
|
||||
### 验证步骤
|
||||
|
||||
```bash
|
||||
# 1. 运行一次完整的 RAG 问答
|
||||
uv run algonotes query ask "什么是树状数组"
|
||||
|
||||
# 2. 在 LangSmith Web UI 查看 Trace
|
||||
# 打开 https://smith.langchain.com → 选择 algonotes-rag 项目
|
||||
# 应能看到完整调用树: Agent → search_notes → rerank_results → LLM
|
||||
|
||||
# 3. 查看本地性能日志(仅 Reranker + Pipeline 部分)
|
||||
tail -n 10 logs/app.log | grep '"logger":"algonotes.perf"'
|
||||
|
||||
# 4. 打印会话统计(聚合 LangSmith + 本地日志)
|
||||
uv run algonotes perf
|
||||
|
||||
# 5. 导出性能报告
|
||||
uv run algonotes perf --export
|
||||
# → 生成 docs/PERFORMANCE_REPORT.md
|
||||
```
|
||||
|
||||
### 预期本地日志片段
|
||||
|
||||
```text
|
||||
// Reranker
|
||||
{"timestamp":"...","level":"INFO","logger":"algonotes.perf","call_type":"reranker","latency_ms":500.12,"model":"Qwen3-Reranker-4B","doc_count_input":15,"doc_count_output":3,"score_range":{"min":0.32,"max":0.95,"mean":0.67},"success":true}
|
||||
|
||||
// Pipeline (含 Agent 行为摘要)
|
||||
{"timestamp":"...","level":"INFO","logger":"algonotes.perf","call_type":"pipeline","latency_ms":3200.80,"agent_steps":4,"tool_calls":{"search_notes":1,"get_file_content":1,"rerank_results":1},"context_truncated":false,"finish_reason":"stop","success":true}
|
||||
|
||||
// Ingest
|
||||
{"timestamp":"...","level":"INFO","logger":"algonotes.perf","call_type":"ingest","latency_ms":4500.00,"filename":"fenwick.md","file_size_bytes":3200,"chunk_count":5,"chunk_truncated":0,"tags":"树状数组, 数据结构","success":true}
|
||||
```
|
||||
|
||||
### 预期 LangSmith 交互
|
||||
|
||||
打开 LangSmith Web UI 的某条 Trace,能看到:
|
||||
|
||||
```
|
||||
📊 algonotes-rag / default
|
||||
└─ AgentExecutor (3.2s)
|
||||
├─ search_notes (45ms) → 返回 5 篇文档
|
||||
├─ get_file_content (2ms) → 读取 fenwick.md
|
||||
├─ rerank_results (500ms) → 重排序 → top 3
|
||||
└─ ChatOpenAI (2.6s) → prompt: 520 tokens, completion: 180 tokens
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
1. **LangSmith 是数据主源**:LLM / Tool / Retriever 的延迟和 Token 数据以 LangSmith Trace 为准,本地日志仅记录 Reranker 和 Pipeline 级别数据。
|
||||
2. **无 LangSmith 时的降级**:通过 `LangSmithConfig.enabled = false` 关闭 LangSmith 上报。此时本地 JSON Lines 日志仍然正常工作,`algonotes perf` 将提示"LangSmith 不可用,仅展示本地数据"。
|
||||
3. **PerfTimer 同时上报 LangSmith**:`_trace_to_langsmith()` 使用 `langsmith.run_helpers.trace()` 将手动计时的 Reranker/Pipeline 数据也同步到 LangSmith,使 Trace 树完整(可关闭)。
|
||||
4. **LangSmith 免费额度**:LangSmith 提供免费 Personal 计划(每月 3000 traces 免费),超过后按量计费。比赛使用量(开发+测试阶段)应在免费额度内。
|
||||
5. **流式 LLM**:LangSmith 对 `model.stream()` 和 `model.invoke()` 一视同仁——`on_llm_end` 在流式完成后同样能拿到完整 Token 用量。
|
||||
6. **本地日志不冗余**:LangChain 自动上报的 LLM/Tool 调用不写入本地日志(避免 10 倍日志膨胀)。如需本地完整记录,设置 `LANGSMITH_TRACING=false` 后自行添加回调。
|
||||
7. **数据一致性**:`algonotes perf --export` 生成报告时,LLM/Tool/Retriever 数据来自 `langsmith.Client.list_runs()` API;Reranker/Pipeline 数据来自本地 JSON Lines 日志。两者按时间窗口对齐合并。
|
||||
|
|
@ -1,285 +0,0 @@
|
|||
# 📊 性能测试报告
|
||||
|
||||
> **项目**:AlgoNotes RAG — 个人算法竞赛笔记智能助手
|
||||
> **测试时间**:2026-07-05 09:15 ~ 13:25(CST)
|
||||
> **测试环境**:沐曦 GPU 算力(Gitee.AI)
|
||||
> **LLM**:Qwen3-Next-80B-A3B-Instruct
|
||||
> **Embedding**:Qwen3-Embedding-4B | **Reranker**:Qwen3-Reranker-4B
|
||||
> **数据来源**:本地 `logs/perf.log`(JSON Lines) + LangSmith Trace(已启用,项目 `algonotes-rag`)
|
||||
|
||||
---
|
||||
|
||||
## 一、测试方法
|
||||
|
||||
### 1.1 测试场景
|
||||
|
||||
运行 8 次不同的 RAG 问答查询 + 1 次批量笔记摄入(5 篇),覆盖以下操作类型:
|
||||
|
||||
| 操作类型 | 采集方式 | 说明 |
|
||||
|:---|:---|:---|
|
||||
| `pipeline` | `PerfTimer` 上下文管理器 | 端到端 RAG 问答(`scripts/rag.py → ask_question()`) |
|
||||
| `tag_extraction` | `PerfTimer` 上下文管理器 | LLM 标签提取(`src/ingestion/tagger.py → extract_tags()`) |
|
||||
| `reranker` | `PerfTimer` 上下文管理器 | 重排序 API 调用(`src/api/reranker_client.py → rerank()`) |
|
||||
| `text_cleaning` | `PerfTimer` 上下文管理器 | LLM 文本清洗(`src/ingestion/cleaner.py → clean_text()`) |
|
||||
|
||||
### 1.2 查询列表
|
||||
|
||||
| # | 查询内容 | Agent 步数 |
|
||||
|:---:|:---|:---:|
|
||||
| 1 | "树状数组是什么?" | 1 |
|
||||
| 2 | "线段树和树状数组有什么区别?" | 5 |
|
||||
| 3 | "如何实现并查集的路径压缩?" | 5 |
|
||||
| 4 | "二分查找的模板怎么写?给出代码示例" | 7 |
|
||||
| 5 | "图的DFS和BFS有什么区别?分别适用于什么场景?" | 5 |
|
||||
| 6 | "什么是差分数组?树状数组如何维护差分?" | 6 |
|
||||
| 7 | "树状数组如何求逆序对?给出算法思想和复杂度分析" | 6 |
|
||||
| 8 | "带权并查集是什么?如何维护节点到根节点的距离?" | 5 |
|
||||
|
||||
### 1.3 笔记库规模
|
||||
|
||||
测试时共 5 篇笔记(7 月 5 日批量导入):
|
||||
|
||||
| ID | 笔记 | 标签数 |
|
||||
|:---:|:---|:---:|
|
||||
| 1 | 图论-图的概念存储和遍历.md | 5 |
|
||||
| 2 | 基础算法-二分模板.md | 5 |
|
||||
| 3 | 数据结构-并查集.md | 5 |
|
||||
| 4 | 数据结构-树状数组.md | 5 |
|
||||
| 5 | 数据结构-线段树(一).md | 5 |
|
||||
|
||||
---
|
||||
|
||||
## 二、延迟统计
|
||||
|
||||
### 2.1 总体汇总
|
||||
|
||||
数据来源:`algonotes perf` 聚合 `logs/perf.log`(14 条记录)。
|
||||
|
||||
| call_type | 次数 | avg | P50 | P95 | P99 | min | max | 成功率 |
|
||||
|:---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||
| **pipeline** | 9 | 31,496ms | 26,677ms | 73,382ms | 95,770ms | 8,016ms | 101,367ms | 100.0% |
|
||||
| **tag_extraction** | 5 | 1,172ms | 1,118ms | 1,514ms | 1,576ms | 949ms | 1,591ms | 100.0% |
|
||||
|
||||
> **注**:本次测试中 `reranker` 和 `text_cleaning` 无数据。Reranker 未被触发是因为 Agent 的标签检索精准匹配到 1-2 篇笔记,无需重排序;Text Cleaning 仅在对网页内容导入时触发,本次未导入网页笔记。
|
||||
|
||||
### 2.2 Pipeline 延迟分布
|
||||
|
||||
| 延迟区间 | 次数 | 占比 |
|
||||
|:---|:---:|:---:|
|
||||
| < 10s | 1 | 11% |
|
||||
| 10s - 20s | 1 | 11% |
|
||||
| 20s - 30s | 4 | 44% |
|
||||
| 30s - 40s | 2 | 22% |
|
||||
| > 100s | 1 | 11% |
|
||||
|
||||
**分析**:
|
||||
- 大部分查询(56%)在 10-30s 间完成,P50 为 26.7s
|
||||
- 最慢的查询(101s,查询 #4 "二分查找模板")Agent 执行了 7 步,多次调用 `search_by_tags` 尝试不同关键词
|
||||
- 最快的查询(8s,查询 #1 "树状数组是什么")仅 1 步即命中目标笔记
|
||||
|
||||
---
|
||||
|
||||
## 三、端到端 Pipeline 分析
|
||||
|
||||
### 3.1 Pipeline 阶段分解
|
||||
|
||||
从 `logs/app.log` 中提取一次典型 RAG 查询的执行链路(查询 #8 "带权并查集",总延迟 31,317ms):
|
||||
|
||||
| 阶段 | 操作 | 耗时(估算) | 占比 |
|
||||
|:---|:---|:---:|:---:|
|
||||
| 初始化 | Embedding model + VectorStore + SQLStore + Agent | ~2,000ms | 6% |
|
||||
| 检索 | `search_by_tags("带权并查集")` | ~1,000ms | 3% |
|
||||
| 文件读取 | `get_file_content` (9,785 chars) | ~1,000ms | 3% |
|
||||
| LLM 生成 | Qwen3-Next-80B-A3B-Instruct(含 System Prompt + 检索上下文 + 用户问题) | ~27,000ms | 86% |
|
||||
| 输出 | 流式输出到终端 | < 500ms | 2% |
|
||||
| **总计** | | **~31,500ms** | **100%** |
|
||||
|
||||
> **关键发现**:LLM 生成耗时占端到端延迟的 **86%**,是绝对瓶颈。检索和文件读取合计仅占 6%,效率令人满意。
|
||||
|
||||
### 3.2 Agent 步数统计
|
||||
|
||||
| Agent 步数 | 次数 | 占比 |
|
||||
|:---:|:---:|:---:|
|
||||
| 1 | 1 | 11% |
|
||||
| 5 | 4 | 44% |
|
||||
| 6 | 2 | 22% |
|
||||
| 7 | 1 | 11% |
|
||||
|
||||
**分析**:
|
||||
- 平均步数 5.0,中位数 5
|
||||
- 步数 5 是"标准流程":`search_by_tags` → `get_file_content` → 生成 → 质量评估
|
||||
- 步数 7(查询 #4)多出 2 次重试(Agent 先用 `search_notes` 向量检索未找到满意结果,转而用 `search_by_tags`)
|
||||
- 步数 1(查询 #1)说明标签直击目标时可跳过多次工具调用
|
||||
|
||||
### 3.3 Tool 调用模式
|
||||
|
||||
所有 8 次查询中 Agent 的工具调用模式高度一致:
|
||||
|
||||
```
|
||||
search_by_tags → get_file_content → (生成答案) → (质量评估)
|
||||
```
|
||||
|
||||
- `search_by_tags` 命中率:8/8(100%),平均每次搜索命中 1-2 篇笔记
|
||||
- `search_notes`(向量检索):仅在查询 #4 中被调用(Agent 用标签搜索失败后降级尝试)
|
||||
- `rerank_results`:未被调用(单笔记场景下无需重排序)
|
||||
- `get_file_content`:每次查询均调用,平均加载 8,000 chars
|
||||
|
||||
---
|
||||
|
||||
## 四、Token 消耗估算
|
||||
|
||||
> **说明**:Token 精确数据需从 LangSmith Trace 中提取。以下基于典型查询的估算值。
|
||||
|
||||
### 4.1 单次查询 Token 分布(估算)
|
||||
|
||||
| 组成部分 | Token 估算 | 说明 |
|
||||
|:---|:---:|:---|
|
||||
| System Prompt | ~2,000 | Agent 系统提示词(含工具定义、检索策略指导) |
|
||||
| 检索上下文 | ~2,500 | 平均加载 8,000 chars ≈ 2,500 tokens(中英文混合) |
|
||||
| 用户问题 | ~50 | 简短的中文问题 |
|
||||
| **Prompt Tokens 合计** | **~4,500** | |
|
||||
| 生成回答 | ~800 | 含引用来源和 RAG 效果评价 |
|
||||
| 工具调用中间步骤 | ~2,000 | 5 步 Agent 推理 × ~400 tokens/步 |
|
||||
| **Completion Tokens 合计** | **~2,800** | |
|
||||
|
||||
### 4.2 成本估算
|
||||
|
||||
Gitee.AI 当前为免费 Beta 阶段,以下按参考定价估算:
|
||||
|
||||
| 模型 | 输入价格 | 输出价格 | 单次查询成本 |
|
||||
|:---|:---:|:---:|:---:|
|
||||
| Qwen3-Next-80B-A3B-Instruct | ¥0.5/M tokens | ¥1.0/M tokens | ~¥0.005(4,500 × 0.5 + 2,800 × 1.0 / 1M) |
|
||||
|
||||
> **结论**:单次 RAG 查询的 Token 成本极低(< ¥0.01),主要开销在延迟而非金钱。
|
||||
|
||||
---
|
||||
|
||||
## 五、摄入阶段性能
|
||||
|
||||
### 5.1 Tag Extraction 性能
|
||||
|
||||
5 篇笔记的 LLM 标签提取(`Qwen3-Next-80B-A3B-Instruct`):
|
||||
|
||||
| 指标 | 值 |
|
||||
|:---|:---:|
|
||||
| 次数 | 5 |
|
||||
| 平均延迟 | 1,172ms |
|
||||
| P50 | 1,118ms |
|
||||
| P95 | 1,514ms |
|
||||
| 成功率 | 100% |
|
||||
|
||||
**分析**:LLM 标签提取耗时稳定在 950ms~1,600ms,远快于 RAG 问答(不含检索步骤的纯推理时间)。这是因为标签提取的 Prompt 短(仅含笔记内容和标签格式要求),Prefill 阶段计算量小。
|
||||
|
||||
### 5.2 摄入 Pipeline 全流程(估算)
|
||||
|
||||
一次完整 ingest 的端到端耗时(不含整体 PerfTimer,基于日志拼接):
|
||||
|
||||
| 阶段 | 操作 | 典型耗时 |
|
||||
|:---|:---|:---:|
|
||||
| 加载 | FileStore 保存 | < 10ms |
|
||||
| 分块 | MarkdownHeaderTextSplitter | < 50ms |
|
||||
| 标签提取 | LLM `extract_tags()` | ~1,200ms |
|
||||
| 存储 | SQL insert + Chroma embed | ~500ms(含 embedding API 调用) |
|
||||
| **总计** | | **~1,750ms / 篇** |
|
||||
|
||||
---
|
||||
|
||||
## 六、成功率与可靠性
|
||||
|
||||
### 6.1 总体成功率
|
||||
|
||||
| 操作类型 | 总次数 | 成功 | 失败 | 成功率 |
|
||||
|:---|:---:|:---:|:---:|:---:|
|
||||
| pipeline | 9 | 9 | 0 | **100.0%** |
|
||||
| tag_extraction | 5 | 5 | 0 | **100.0%** |
|
||||
| **合计** | **14** | **14** | **0** | **100.0%** |
|
||||
|
||||
### 6.2 可靠性分析
|
||||
|
||||
| 指标 | 观测值 | 状态 |
|
||||
|:---|:---|:---:|
|
||||
| 空召回率(检索返回 0 篇) | 1/8 次(查询 #6 首次 `search_by_tags("差分数组")` 返回空,Agent 自动切换为 `search_by_tags("树状数组")` 后命中) | ✅ 有自愈能力 |
|
||||
| 生成截断(`finish_reason="length"`) | 未观测到 | ✅ |
|
||||
| 异常退出 | 0 次 | ✅ |
|
||||
| Agent 步数 > 6 | 1 次(查询 #4,7 步)| ⚠️ 建议关注 |
|
||||
|
||||
> **空召回处理**:查询 #6 中 Agent 用 `search_by_tags("差分数组")` 返回空结果后,自动尝试 `search_by_tags("树状数组")` 命中——Agent 的自主纠错机制有效。
|
||||
|
||||
---
|
||||
|
||||
## 七、日志样本
|
||||
|
||||
以下为 `logs/perf.log` 中的典型性能日志条目(JSON Lines 格式):
|
||||
|
||||
```jsonl
|
||||
{"timestamp": "2026-07-05 13:21:06,694", "level": "INFO", "logger": "algonotes.perf", "message": "pipeline completed in 26676.7ms", "call_type": "pipeline", "latency_ms": 26676.71, "success": true, "agent_steps": 6}
|
||||
|
||||
{"timestamp": "2026-07-05 13:06:13,834", "level": "INFO", "logger": "algonotes.perf", "message": "tag_extraction completed in 1590.9ms", "call_type": "tag_extraction", "latency_ms": 1590.95, "model": "Qwen3-Next-80B-A3B-Instruct", "success": true}
|
||||
|
||||
{"timestamp": "2026-07-05 13:15:52,309", "level": "INFO", "logger": "algonotes.perf", "message": "pipeline completed in 31405.1ms", "call_type": "pipeline", "latency_ms": 31405.12, "success": true, "agent_steps": 5}
|
||||
```
|
||||
|
||||
相应操作日志(`logs/app.log`)样本:
|
||||
|
||||
```jsonl
|
||||
{"timestamp": "2026-07-05 13:21:24,430", "level": "INFO", "logger": "algonotes.rag.retriever", "message": "search_by_tags: keyword='差分数组'"}
|
||||
{"timestamp": "2026-07-05 13:21:24,431", "level": "INFO", "logger": "algonotes.rag.retriever", "message": "search_by_tags: found 0 files"}
|
||||
{"timestamp": "2026-07-05 13:21:24,433", "level": "INFO", "logger": "algonotes.rag.retriever", "message": "search_by_tags: keyword='树状数组'"}
|
||||
{"timestamp": "2026-07-05 13:21:24,434", "level": "INFO", "logger": "algonotes.rag.retriever", "message": "search_by_tags: found 1 files"}
|
||||
{"timestamp": "2026-07-05 13:21:25,326", "level": "INFO", "logger": "algonotes.rag.retriever", "message": "get_file_content: loaded 7399 chars"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、LangSmith Trace
|
||||
|
||||
LangSmith 已启用(项目:`algonotes-rag`,Endpoint:`https://api.smith.langchain.com`),自动追踪以下调用:
|
||||
|
||||
| 追踪对象 | 采集指标 |
|
||||
|:---|:---|
|
||||
| LLM 调用 | TTFT、TPOT、ITL、prompt_tokens、completion_tokens、finish_reason |
|
||||
| Tool 调用 | 各 Tool 延迟、输入参数、返回值 |
|
||||
| Retriever 调用 | Chroma 向量检索延迟、返回文档数、相似度分数 |
|
||||
| Embedding 调用 | 嵌入延迟、batch 大小 |
|
||||
|
||||
> **截图指引**:登录 [smith.langchain.com](https://smith.langchain.com) → 选择项目 `algonotes-rag` → 点击任意 Trace → 截图完整的 Trace 树(含 LLM / Tool / Retriever 节点和延迟标注)。
|
||||
|
||||
---
|
||||
|
||||
## 九、结论与优化建议
|
||||
|
||||
### 9.1 当前性能总结
|
||||
|
||||
| 维度 | 评估 | 说明 |
|
||||
|:---|:---:|:---|
|
||||
| 检索效率 | 🟢 优秀 | `search_by_tags` 标签搜索精准命中,平均 < 1s |
|
||||
| LLM 生成 | 🟡 可接受 | P50 26.7s,受 Gitee.AI 免费 API 并发限制影响 |
|
||||
| 可靠性 | 🟢 优秀 | 100% 成功率,空召回有自愈机制 |
|
||||
| Agent 效率 | 🟡 良好 | 平均 5 步,少数查询存在过度搜索 |
|
||||
| Token 成本 | 🟢 极低 | 单次查询 < ¥0.01 |
|
||||
|
||||
### 9.2 优化方向
|
||||
|
||||
1. **减少 Agent 步数**:当前 System Prompt 有时导致 Agent 过度验证(多轮 `get_file_content` 重读)。可在 Prompt 中增加"单次读取足够"的指引。
|
||||
|
||||
2. **Reranker 利用不足**:当前标签检索足够精准,Reranker 未被触发。随着笔记库增长(> 20 篇),`search_notes` 语义检索 + `rerank_results` 重排序将成为常规路径——届时需要补充 Reranker 性能数据。
|
||||
|
||||
3. **嵌入缓存**:摄入阶段每次 embed 调用需访问 Gitee.AI API(~500ms/篇),大规模导入时可考虑本地 batch + 去重优化。
|
||||
|
||||
4. **LLM 延迟波动**:P95(73s)与 P50(27s)差距 2.7 倍,反映 Gitee.AI 免费 API 的并发排队效应。生产环境可考虑付费 Tier 降低长尾延迟。
|
||||
|
||||
5. **补充 LangSmith 数据**:当前 `algonotes perf` 只读取本地 `perf.log`。后续可扩展 `perf_report.py` 通过 `langsmith.Client.list_runs()` 拉取 LLM/Tool/Retriever 的精确 Token 和 TTFT 数据,替代当前的手工估算。
|
||||
|
||||
### 9.3 与比赛评分对照
|
||||
|
||||
| 评分维度 | 分值 | 证据 |
|
||||
|:---|:---:|:---|
|
||||
| 国产算力集成 | 15 | Gitee.AI 三模型(LLM/Embedding/Reranker),配置分离 |
|
||||
| MCP 工具 | 30 | 9 个 MCP 工具,SSE 传输 |
|
||||
| **真实调用验证** | **30** | **本报告(延迟 P50/P95/P99 + 成功率 + 日志样本)** |
|
||||
| 开源质量 | 25 | 17 个 docs/ + README + ARCHITECTURE + 本报告 |
|
||||
|
||||
---
|
||||
|
||||
> **报告生成命令**:`uv run algonotes perf --json data/perf_report.json`
|
||||
> **原始数据**:`logs/perf.log`(14 条)、`data/perf_report.json`(结构化导出)
|
||||
|
|
@ -15,9 +15,6 @@
|
|||
| `--path` | `-i` | 本地文件或目录路径 | 与 `--url` 二选一 |
|
||||
| `--url` | `-u` | 网页 URL 地址 | 与 `--path` 二选一 |
|
||||
| `--no-tag` | - | 跳过打 tag | `False` |
|
||||
| `--filename` | `-n` | 自定义文件名 | 源文件名或 URL 派生名 |
|
||||
| `--type` | - | 笔记类型(note/solution/template) | `note` |
|
||||
| `--author` | - | 笔记作者 | 无 |
|
||||
| `--verbose` | `-v` | 详细输出 | `False` |
|
||||
| `--json` | - | 输出到 JSON 文件 | stdout(使用时须指定文件路径) |
|
||||
|
||||
|
|
@ -30,9 +27,6 @@ algonotes ingest -i ./my_notes/
|
|||
# 从 URL 导入(默认自动清洗)
|
||||
algonotes ingest -u https://cnblogs.com/xxx
|
||||
|
||||
# 自定义文件名
|
||||
algonotes ingest -i ./my_notes/fenwick.md -n "树状数组笔记.md"
|
||||
|
||||
# 跳过打 tag
|
||||
algonotes ingest -i ./my_notes/ --no-tag
|
||||
|
||||
|
|
@ -156,7 +150,7 @@ graph TD
|
|||
|
||||
## 注意事项
|
||||
|
||||
1. **文件命名**:默认使用源文件 basename 或 URL 派生名作为唯一标识;可通过 `-n/--filename` 自定义,平铺存储在 `data/files/` 下
|
||||
1. **文件命名**:导入后文件名即唯一标识,平铺存储在 `data/files/` 下
|
||||
2. **文件名冲突**:同名文件会自动添加时间戳后缀(如 `fenwick_20260616_143022.md`)
|
||||
3. **网页清洗**:从 URL 导入时,默认自动执行 LLM 清洗以去除网页噪声;本地导入默认不启用
|
||||
4. **打 tag**:需要调用 LLM,会增加处理时间,可用 `--no-tag` 跳过
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@
|
|||
| `tag` | bool | 提取标签 | `true` |
|
||||
| `type` | string | 笔记类型(note/solution/template) | `note` |
|
||||
| `author` | string \| null | 笔记作者 | 无 |
|
||||
| `filename` | string \| null | 自定义文件名 | 源文件名或 URL 派生名 |
|
||||
|
||||
## 使用示例
|
||||
|
||||
|
|
@ -33,9 +32,6 @@
|
|||
|
||||
// 跳过打 tag
|
||||
{"path": "./my_notes/", "tag": false}
|
||||
|
||||
// 自定义文件名
|
||||
{"path": "./my_notes/fenwick.md", "filename": "树状数组笔记.md"}
|
||||
```
|
||||
|
||||
## 返回格式
|
||||
|
|
@ -92,6 +88,6 @@
|
|||
## 注意事项
|
||||
|
||||
1. **互斥参数**:`path` 和 `url` 必须且只能提供一个
|
||||
2. **文件命名**:默认使用源文件 basename 或 URL 派生名作为唯一标识;可通过 `filename` 参数自定义,平铺存储在 `data/files/` 下
|
||||
2. **文件命名**:导入后文件名即唯一标识,平铺存储在 `data/files/` 下
|
||||
3. **网页清洗**:从 URL 导入时自动执行 LLM 清洗;本地导入默认不清洗
|
||||
4. **打 tag**:需要调用 LLM,会增加处理时间,可设 `tag: false` 跳过
|
||||
|
|
|
|||
810
scripts/cli.py
810
scripts/cli.py
|
|
@ -1,131 +1,78 @@
|
|||
# scripts/cli.py
|
||||
# Unified CLI entry point for `algonotes` command.
|
||||
#
|
||||
# Architecture:
|
||||
# 1. Parser builders (_add_*_parser) — define arguments only
|
||||
# 2. Handler functions (_handle_*) — validate args → delegate → output
|
||||
# 3. Dispatch tables — map command strings to handlers
|
||||
# 4. main() — build parser, parse, dispatch
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Setup
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
def _setup_cli_logging():
|
||||
"""Configure logging for CLI use.
|
||||
|
||||
def _setup_cli_logging() -> None:
|
||||
"""Configure logging before any module with loggers is imported.
|
||||
|
||||
Checks sys.argv for ``-v`` / ``--verbose`` before argparse runs,
|
||||
so the flag only needs to be *present* — not fully parsed.
|
||||
When present, enables human-readable stderr output at INFO level
|
||||
alongside the default JSON-lines file logger.
|
||||
Must be called before any module with logger calls is imported.
|
||||
Parses sys.argv to check for ``--verbose`` before argparse (which is
|
||||
fine because we only need the flag's presence, not its value).
|
||||
"""
|
||||
console = "-v" in sys.argv or "--verbose" in sys.argv
|
||||
from src.logger import setup_logger
|
||||
setup_logger("algonotes", console=console)
|
||||
|
||||
|
||||
def _ensure_utf8_stdout() -> None:
|
||||
"""Reconfigure stdout to UTF-8 on Windows (e.g. GBK/cp936 → UTF-8).
|
||||
def _ensure_utf8_stdout():
|
||||
"""Reconfigure stdout encoding to UTF-8 if running on Windows with GBK.
|
||||
|
||||
Agent responses may contain emoji or characters outside the system
|
||||
codepage. When ``reconfigure`` fails (e.g. stdout is piped to a
|
||||
non-reconfigurable stream), a warning is printed to stderr instead
|
||||
of silently swallowing the error.
|
||||
Agent responses may contain emoji or other characters not representable
|
||||
in the system's default codepage (e.g. cp936/GBK on Chinese Windows).
|
||||
"""
|
||||
try:
|
||||
if sys.stdout.encoding and sys.stdout.encoding.lower() not in (
|
||||
"utf-8", "utf8",
|
||||
):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
except (OSError, AttributeError):
|
||||
print("⚠ 无法将 stdout 重配置为 UTF-8(可能被管道占用)",
|
||||
file=sys.stderr)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Output helper
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
def main():
|
||||
_setup_cli_logging()
|
||||
_ensure_utf8_stdout()
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="algonotes",
|
||||
description="AlgoNotes RAG CLI — 算法竞赛笔记管理工具",
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
def _output(data: Any, json_path: Path | None, *,
|
||||
display: Callable[[Any], None] | None = None) -> None:
|
||||
"""Unified output: write JSON and/or call ``display(data)``.
|
||||
# ── ingest ──
|
||||
p_ingest = sub.add_parser("ingest", help="导入笔记")
|
||||
source = p_ingest.add_mutually_exclusive_group(required=True)
|
||||
source.add_argument("-i", "--path", type=Path,
|
||||
help="本地文件或目录路径")
|
||||
source.add_argument("-u", "--url",
|
||||
help="网页 URL 地址")
|
||||
p_ingest.add_argument("--no-tag", action="store_true",
|
||||
help="跳过打 tag")
|
||||
p_ingest.add_argument("--type", choices=["note", "solution", "template"],
|
||||
default="note", help="笔记类型")
|
||||
p_ingest.add_argument("--author", help="笔记作者")
|
||||
p_ingest.add_argument("-v", "--verbose", action="store_true",
|
||||
help="详细输出")
|
||||
p_ingest.add_argument("--json", type=Path, metavar="FILE",
|
||||
help="输出结果到 JSON 文件")
|
||||
|
||||
Eliminates the repeated ``if args.json: … else: print(…)`` pattern
|
||||
that was duplicated across every handler.
|
||||
|
||||
When *json_path* is given the data is written as UTF-8 JSON (parent
|
||||
directories are created automatically). When *display* is given it
|
||||
is called with *data* for console output. Both can fire together —
|
||||
they are independent; ``--json`` does not suppress console output.
|
||||
|
||||
Args:
|
||||
data: Result to output. For JSON mode this is serialised with
|
||||
``json.dumps(ensure_ascii=False, indent=2)``. For console
|
||||
mode it is passed directly to *display*.
|
||||
json_path: Path from the ``--json`` flag, or ``None`` to skip.
|
||||
display: Callable that renders *data* to stdout, or ``None``.
|
||||
If both *json_path* and *display* are ``None`` the call is
|
||||
a silent no-op.
|
||||
"""
|
||||
if json_path:
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
json_path.write_text(
|
||||
json.dumps(data, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
if display:
|
||||
display(data)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Parser builders
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def _add_ingest_parser(sub: argparse._SubParsersAction) -> None:
|
||||
"""Register the ``ingest`` subcommand and its arguments.
|
||||
|
||||
Adds ``algonotes ingest`` with mutually exclusive ``-i/--path``
|
||||
(local file or directory) and ``-u/--url`` (web page) sources,
|
||||
plus optional ``--no-tag``, ``--type``, ``--author``, ``-v``,
|
||||
and ``--json`` flags.
|
||||
"""
|
||||
p = sub.add_parser("ingest", help="导入笔记")
|
||||
src = p.add_mutually_exclusive_group(required=True)
|
||||
src.add_argument("-i", "--path", type=Path, help="本地文件或目录路径")
|
||||
src.add_argument("-u", "--url", help="网页 URL 地址")
|
||||
p.add_argument("--no-tag", action="store_true", help="跳过打 tag")
|
||||
p.add_argument("--type", choices=["note", "solution", "template"],
|
||||
default="note", help="笔记类型")
|
||||
p.add_argument("--author", help="笔记作者")
|
||||
p.add_argument("-n", "--filename", help="自定义文件名(默认使用源文件名或URL派生名)")
|
||||
p.add_argument("-v", "--verbose", action="store_true", help="详细输出")
|
||||
p.add_argument("--json", type=Path, metavar="FILE", help="输出结果到 JSON 文件")
|
||||
|
||||
|
||||
def _add_update_parser(sub: argparse._SubParsersAction) -> None:
|
||||
"""Register the ``update`` subcommand with ``content`` and ``metadata`` sub-subcommands.
|
||||
|
||||
``algonotes update content`` re-processes a note's file content
|
||||
through the full ingestion pipeline (split → tag → re-index).
|
||||
``algonotes update metadata`` edits tags/title/author/type in-place
|
||||
without re-embedding.
|
||||
"""
|
||||
p = sub.add_parser("update", help="更新笔记")
|
||||
u_sub = p.add_subparsers(dest="update_command", required=True)
|
||||
# ── update ──
|
||||
p_update = sub.add_parser("update", help="更新笔记")
|
||||
u_sub = p_update.add_subparsers(dest="update_command", required=True)
|
||||
|
||||
p_content = u_sub.add_parser("content", help="更新笔记内容(重新处理文件)")
|
||||
p_content.add_argument("filename", help="笔记文件名")
|
||||
p_content.add_argument("--file", type=Path, help="新的本地文件路径")
|
||||
p_content.add_argument("--no-tag", action="store_true", help="跳过打 tag")
|
||||
p_content.add_argument("-v", "--verbose", action="store_true", help="详细输出")
|
||||
p_content.add_argument("--json", type=Path, metavar="FILE", help="输出结果到 JSON 文件")
|
||||
p_content.add_argument("--no-tag", action="store_true",
|
||||
help="跳过打 tag")
|
||||
p_content.add_argument("-v", "--verbose", action="store_true",
|
||||
help="详细输出")
|
||||
p_content.add_argument("--json", type=Path, metavar="FILE",
|
||||
help="输出结果到 JSON 文件")
|
||||
|
||||
p_meta = u_sub.add_parser("metadata", help="更新笔记元数据(不修改内容)")
|
||||
p_meta.add_argument("filename", help="笔记文件名")
|
||||
|
|
@ -134,186 +81,139 @@ def _add_update_parser(sub: argparse._SubParsersAction) -> None:
|
|||
p_meta.add_argument("--author", help="新作者")
|
||||
p_meta.add_argument("--type", choices=["note", "solution", "template"],
|
||||
help="笔记类型")
|
||||
p_meta.add_argument("--json", type=Path, metavar="FILE", help="输出结果到 JSON 文件")
|
||||
p_meta.add_argument("--json", type=Path, metavar="FILE",
|
||||
help="输出结果到 JSON 文件")
|
||||
|
||||
# ── delete ──
|
||||
p_delete = sub.add_parser("delete", help="删除笔记")
|
||||
p_delete.add_argument("filename", help="笔记文件名")
|
||||
p_delete.add_argument("--force", action="store_true",
|
||||
help="跳过确认提示")
|
||||
p_delete.add_argument("--json", type=Path, metavar="FILE",
|
||||
help="输出结果到 JSON 文件")
|
||||
|
||||
def _add_delete_parser(sub: argparse._SubParsersAction) -> None:
|
||||
"""Register the ``delete`` subcommand and its arguments.
|
||||
|
||||
Adds ``algonotes delete <filename>`` with optional ``--force``
|
||||
(skip confirmation prompt) and ``--json`` flags.
|
||||
"""
|
||||
p = sub.add_parser("delete", help="删除笔记")
|
||||
p.add_argument("filename", help="笔记文件名")
|
||||
p.add_argument("--force", action="store_true", help="跳过确认提示")
|
||||
p.add_argument("--json", type=Path, metavar="FILE", help="输出结果到 JSON 文件")
|
||||
|
||||
|
||||
def _add_query_parser(sub: argparse._SubParsersAction) -> None:
|
||||
"""Register the ``query`` subcommand and its six sub-subcommands.
|
||||
|
||||
Adds ``algonotes query {show, list, info, search, export, ask}``
|
||||
with their respective arguments. This is the most complex parser
|
||||
group because it covers all read-oriented operations.
|
||||
"""
|
||||
p = sub.add_parser("query", help="查询笔记")
|
||||
q_sub = p.add_subparsers(dest="query_command", required=True)
|
||||
# ── query ──
|
||||
p_query = sub.add_parser("query", help="查询笔记")
|
||||
q_sub = p_query.add_subparsers(dest="query_command", required=True)
|
||||
|
||||
p_show = q_sub.add_parser("show", help="查看笔记原文")
|
||||
p_show.add_argument("filename", help="笔记文件名")
|
||||
p_show.add_argument("--lines", type=int, help="只显示前 N 行")
|
||||
p_show.add_argument("--json", type=Path, metavar="FILE", help="输出到 JSON 文件")
|
||||
p_show.add_argument("--json", type=Path, metavar="FILE",
|
||||
help="输出到 JSON 文件")
|
||||
|
||||
p_list = q_sub.add_parser("list", help="列出笔记")
|
||||
p_list.add_argument("--tag", help="按标签筛选")
|
||||
p_list.add_argument("-v", "--verbose", action="store_true", help="显示详细信息")
|
||||
p_list.add_argument("--json", type=Path, metavar="FILE", help="输出到 JSON 文件")
|
||||
p_list.add_argument("-v", "--verbose", action="store_true",
|
||||
help="显示详细信息")
|
||||
p_list.add_argument("--json", type=Path, metavar="FILE",
|
||||
help="输出到 JSON 文件")
|
||||
|
||||
p_info = q_sub.add_parser("info", help="查看笔记详情")
|
||||
p_info.add_argument("filename", nargs="?", help="笔记文件名")
|
||||
p_info.add_argument("--id", type=int, help="笔记 ID")
|
||||
p_info.add_argument("--json", type=Path, metavar="FILE", help="输出到 JSON 文件")
|
||||
p_info.add_argument("--json", type=Path, metavar="FILE",
|
||||
help="输出到 JSON 文件")
|
||||
|
||||
p_search = q_sub.add_parser("search", help="语义搜索")
|
||||
p_search.add_argument("query", help="搜索关键词")
|
||||
p_search.add_argument("--top-k", type=int, default=5, help="返回结果数量")
|
||||
p_search.add_argument("--json", type=Path, metavar="FILE", help="输出到 JSON 文件")
|
||||
p_search.add_argument("--top-k", type=int, default=5,
|
||||
help="返回结果数量")
|
||||
p_search.add_argument("--json", type=Path, metavar="FILE",
|
||||
help="输出到 JSON 文件")
|
||||
|
||||
p_export = q_sub.add_parser("export",
|
||||
help="导出笔记(YAML frontmatter,兼容 Obsidian)")
|
||||
p_export.add_argument("filename", nargs="?", help="笔记文件名")
|
||||
p_export.add_argument("--all", action="store_true", help="导出所有笔记")
|
||||
p_export.add_argument("-o", "--output", type=Path, default=Path("./export"),
|
||||
help="输出目录")
|
||||
p_export.add_argument("--json", type=Path, metavar="FILE", help="输出到 JSON 文件")
|
||||
p_export.add_argument("-o", "--output", type=Path,
|
||||
default=Path("./export"), help="输出目录")
|
||||
p_export.add_argument("--json", type=Path, metavar="FILE",
|
||||
help="输出到 JSON 文件")
|
||||
|
||||
p_ask = q_sub.add_parser("ask", help="RAG 问答(单次,直接输出答案)")
|
||||
p_ask.add_argument("question", help="问题")
|
||||
p_ask.add_argument("--top-k", type=int, default=5, help="检索结果数量(默认 5)")
|
||||
p_ask.add_argument("--stream", action="store_true", help="流式输出答案")
|
||||
p_ask.add_argument("--json", type=Path, metavar="FILE", help="输出到 JSON 文件")
|
||||
p_ask.add_argument("--top-k", type=int, default=5,
|
||||
help="检索结果数量(默认 5)")
|
||||
p_ask.add_argument("--stream", action="store_true",
|
||||
help="流式输出答案")
|
||||
p_ask.add_argument("--json", type=Path, metavar="FILE",
|
||||
help="输出到 JSON 文件")
|
||||
|
||||
# ── chat ──
|
||||
p_chat = sub.add_parser("chat", help="交互式问答")
|
||||
p_chat.add_argument("--thread-id", default="default",
|
||||
help="会话标识符,用于区分不同对话")
|
||||
|
||||
def _add_chat_parser(sub: argparse._SubParsersAction) -> None:
|
||||
"""Register the ``chat`` subcommand for interactive RAG sessions.
|
||||
# ── mcp ──
|
||||
p_mcp = sub.add_parser("mcp", help="启动 MCP 服务器")
|
||||
p_mcp.add_argument("--host", default="127.0.0.1",
|
||||
help="监听地址(默认 127.0.0.1)")
|
||||
p_mcp.add_argument("--port", type=int, default=8000,
|
||||
help="监听端口(默认 8000)")
|
||||
|
||||
Adds ``algonotes chat`` with an optional ``--thread-id`` argument
|
||||
that isolates conversation history (default: ``"default"``).
|
||||
"""
|
||||
p = sub.add_parser("chat", help="交互式问答")
|
||||
p.add_argument("--thread-id", default="default",
|
||||
help="会话标识符,用于区分不同对话")
|
||||
# ── dispatch ──
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
def _add_mcp_parser(sub: argparse._SubParsersAction) -> None:
|
||||
"""Register the ``mcp`` subcommand to start the MCP server.
|
||||
|
||||
Adds ``algonotes mcp`` with optional ``--host`` (default ``127.0.0.1``)
|
||||
and ``--port`` (default ``8000``) to control the SSE transport binding.
|
||||
"""
|
||||
p = sub.add_parser("mcp", help="启动 MCP 服务器")
|
||||
p.add_argument("--host", default="127.0.0.1", help="监听地址(默认 127.0.0.1)")
|
||||
p.add_argument("--port", type=int, default=8000, help="监听端口(默认 8000)")
|
||||
|
||||
|
||||
def _add_perf_parser(sub: argparse._SubParsersAction) -> None:
|
||||
"""Register the ``perf`` subcommand for performance reporting.
|
||||
|
||||
Adds ``algonotes perf`` with optional ``--detail`` (show per-entry
|
||||
breakdown) and ``--json`` flags.
|
||||
"""
|
||||
p = sub.add_parser("perf", help="性能报告")
|
||||
p.add_argument("--detail", action="store_true", help="显示每条记录的详细信息")
|
||||
p.add_argument("--json", type=Path, metavar="FILE", help="输出结果到 JSON 文件")
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
"""Construct the full argument parser with all subcommands.
|
||||
|
||||
Returns an ``ArgumentParser`` that is ready for ``parse_args()``.
|
||||
Each subcommand group is delegated to its own ``_add_*_parser``
|
||||
helper so parser construction stays modular.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="algonotes",
|
||||
description="AlgoNotes RAG CLI — 算法竞赛笔记管理工具",
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
_add_ingest_parser(sub)
|
||||
_add_update_parser(sub)
|
||||
_add_delete_parser(sub)
|
||||
_add_query_parser(sub)
|
||||
_add_chat_parser(sub)
|
||||
_add_mcp_parser(sub)
|
||||
_add_perf_parser(sub)
|
||||
return parser
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Handler: ingest
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def _handle_ingest(args: argparse.Namespace) -> None:
|
||||
"""Handle ``algonotes ingest`` — import notes from local files or web URLs.
|
||||
|
||||
Dispatches to:
|
||||
- ``ingest_local`` for ``-i <file>`` (single .md file)
|
||||
- ``ingest_locals`` for ``-i <dir>`` (batch, all *.md in directory)
|
||||
- ``ingest_web`` for ``-u <url>`` (web page with LLM cleaning)
|
||||
|
||||
Outputs a summary line for single imports; batch imports print
|
||||
per-file progress from within ``ingest_locals`` and stay silent here.
|
||||
Supports ``--json`` for structured output.
|
||||
"""
|
||||
if args.command == "ingest":
|
||||
_run_ingest(args)
|
||||
elif args.command == "update":
|
||||
_run_update(args)
|
||||
elif args.command == "delete":
|
||||
_run_delete(args)
|
||||
elif args.command == "query":
|
||||
_run_query(args)
|
||||
elif args.command == "chat":
|
||||
_run_chat(args)
|
||||
elif args.command == "mcp":
|
||||
_run_mcp(args)
|
||||
def _run_ingest(args):
|
||||
from scripts.ingest import ingest_local, ingest_locals, ingest_web
|
||||
|
||||
tag = not args.no_tag
|
||||
is_dir = args.path and args.path.is_dir()
|
||||
note_type = args.type
|
||||
author = args.author
|
||||
|
||||
if args.path:
|
||||
if args.path.is_file():
|
||||
result = ingest_local(args.path, tag=tag, verbose=args.verbose,
|
||||
type=args.type, author=args.author,
|
||||
filename=args.filename)
|
||||
elif is_dir:
|
||||
results = ingest_locals(args.path, tag=tag, verbose=args.verbose,
|
||||
type=args.type, author=args.author)
|
||||
result = ingest_local(args.path,
|
||||
tag=tag, verbose=args.verbose,
|
||||
type=note_type, author=author)
|
||||
elif args.path.is_dir():
|
||||
results = ingest_locals(args.path,
|
||||
tag=tag, verbose=args.verbose,
|
||||
type=note_type, author=author)
|
||||
else:
|
||||
print(f"❌ 路径不存在: {args.path}")
|
||||
sys.exit(1)
|
||||
elif args.url:
|
||||
result = ingest_web(args.url, tag=tag, verbose=args.verbose,
|
||||
type=args.type, author=args.author,
|
||||
filename=args.filename)
|
||||
result = ingest_web(args.url,
|
||||
tag=tag, verbose=args.verbose,
|
||||
type=note_type, author=author)
|
||||
|
||||
if is_dir:
|
||||
if args.path and args.path.is_dir():
|
||||
output_data = [r.__dict__ for r in results]
|
||||
else:
|
||||
output_data = result.__dict__
|
||||
|
||||
def _display(data):
|
||||
# Directory ingest is silent here — ingest_locals already
|
||||
# prints per-file progress lines during iteration.
|
||||
if not is_dir:
|
||||
print(f" ✅ {data['file_name']} ({data['chunk_count']} chunks, tags: {data['tags']})")
|
||||
|
||||
_output(output_data, args.json, display=_display)
|
||||
if args.json:
|
||||
args.json.write_text(
|
||||
json.dumps(output_data, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
elif not (args.path and args.path.is_dir()):
|
||||
print(
|
||||
f" ✅ {result.file_name} ({result.chunk_count} chunks, tags: {result.tags})")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Handlers: update
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
def _run_update(args):
|
||||
if args.update_command == "content":
|
||||
_run_update_content(args)
|
||||
elif args.update_command == "metadata":
|
||||
_run_update_metadata(args)
|
||||
|
||||
def _handle_update_content(args: argparse.Namespace) -> None:
|
||||
"""Handle ``algonotes update content <filename>`` — reprocess a note's file.
|
||||
|
||||
Validates the note exists in SQLStore and that a content source
|
||||
is available (``--file`` or stored ``source_url``), then runs the
|
||||
full ingestion pipeline: load → split → tag → re-index in both
|
||||
SQLStore and VectorStore (old chunks are deleted first).
|
||||
|
||||
Exits with code 1 if the note is not found or no source is available.
|
||||
"""
|
||||
def _run_update_content(args):
|
||||
from scripts.update import update_note
|
||||
from src.store.sql_store import get_sql_store
|
||||
|
||||
|
|
@ -329,68 +229,52 @@ def _handle_update_content(args: argparse.Namespace) -> None:
|
|||
print(f"❌ 笔记 {args.filename} 无 source_url,请使用 --file 指定新文件")
|
||||
sys.exit(1)
|
||||
|
||||
result = update_note(args.filename, file_path=args.file, tag=tag,
|
||||
verbose=args.verbose)
|
||||
result = update_note(
|
||||
args.filename,
|
||||
file_path=args.file,
|
||||
tag=tag,
|
||||
verbose=args.verbose,
|
||||
)
|
||||
|
||||
def _display(data):
|
||||
if data["success"]:
|
||||
print(f"Update success: {data['file_name']} ({data['chunk_count']} chunks)")
|
||||
else:
|
||||
print(f"Update failed: {data['file_name']}")
|
||||
if result.success:
|
||||
print(f"Update success: {result.file_name} ({result.chunk_count} chunks)")
|
||||
else:
|
||||
print(f"Update failed: {result.file_name}")
|
||||
|
||||
_output(result.__dict__, args.json, display=_display)
|
||||
if args.json:
|
||||
args.json.write_text(
|
||||
json.dumps(result.__dict__, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _handle_update_metadata(args: argparse.Namespace) -> None:
|
||||
"""Handle ``algonotes update metadata <filename>`` — edit metadata in-place.
|
||||
|
||||
Updates title, tags, author, and/or type in SQLStore without
|
||||
re-processing file content. If tags are changed the VectorStore
|
||||
chunk metadata is updated to match (no re-embedding).
|
||||
|
||||
At least one of ``--title``, ``--tags``, ``--author``, ``--type``
|
||||
should be provided; the handler itself does not enforce this —
|
||||
``update_metadata`` simply returns with ``fields_updated=[]``.
|
||||
"""
|
||||
def _run_update_metadata(args):
|
||||
from scripts.update import update_metadata
|
||||
|
||||
result = update_metadata(args.filename, title=args.title, tags=args.tags,
|
||||
author=args.author, type=args.type)
|
||||
result = update_metadata(
|
||||
args.filename,
|
||||
title=args.title,
|
||||
tags=args.tags,
|
||||
author=args.author,
|
||||
type=args.type,
|
||||
)
|
||||
|
||||
def _display(data):
|
||||
if data["success"]:
|
||||
print(f"Metadata updated: {data['filename']} "
|
||||
f"({', '.join(data['fields_updated'])})")
|
||||
else:
|
||||
print(f"Metadata update failed: {data['filename']}")
|
||||
if result.success:
|
||||
print(f"Metadata updated: {result.filename} ({', '.join(result.fields_updated)})")
|
||||
else:
|
||||
print(f"Metadata update failed: {result.filename}")
|
||||
|
||||
_output({"filename": result.filename, "success": result.success,
|
||||
"fields_updated": result.fields_updated}, args.json, display=_display)
|
||||
if args.json:
|
||||
args.json.write_text(
|
||||
json.dumps({"filename": result.filename,
|
||||
"success": result.success,
|
||||
"fields_updated": result.fields_updated},
|
||||
ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _handle_update(args: argparse.Namespace) -> None:
|
||||
"""Dispatch ``algonotes update {content, metadata}`` to the correct sub-handler.
|
||||
|
||||
``args.update_command`` is guaranteed to exist because the sub-subparser
|
||||
was created with ``required=True``.
|
||||
"""
|
||||
_UPDATE_HANDLERS[args.update_command](args)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Handler: delete
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def _handle_delete(args: argparse.Namespace) -> None:
|
||||
"""Handle ``algonotes delete <filename>`` — remove a note from all three stores.
|
||||
|
||||
Unless ``--force`` is passed, prompts the user to type the filename
|
||||
for confirmation (type ``"stop"`` to abort). Deletion order is
|
||||
VectorStore → FileStore → SQLStore (see ``scripts/delete.py``).
|
||||
|
||||
Does NOT exit with error if the file doesn't exist; prints a
|
||||
warning and returns normally.
|
||||
"""
|
||||
def _run_delete(args):
|
||||
from scripts.delete import delete_note
|
||||
|
||||
if not args.force:
|
||||
|
|
@ -400,166 +284,137 @@ def _handle_delete(args: argparse.Namespace) -> None:
|
|||
|
||||
result = delete_note(args.filename)
|
||||
|
||||
def _display(data):
|
||||
if data["success"]:
|
||||
print(f"成功删除 {data['file_name']}")
|
||||
else:
|
||||
print(f"{data['file_name']} 文件不存在,无法进行删除")
|
||||
|
||||
_output(result.__dict__, args.json, display=_display)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Handlers: query
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def _handle_query_show(args: argparse.Namespace) -> None:
|
||||
"""Handle ``algonotes query show <filename>`` — print raw note content.
|
||||
|
||||
Reads the file from FileStore and prints it to the terminal via
|
||||
``rich.console.Console``. Use ``--lines N`` to limit output to
|
||||
the first N lines. Supports ``--json`` for structured output.
|
||||
"""
|
||||
from scripts.query import show_raw, console
|
||||
|
||||
content = show_raw(args.filename, args.lines)
|
||||
if content is None:
|
||||
console.print(f"[red]笔记不存在: {args.filename}[/red]")
|
||||
return
|
||||
|
||||
def _display(data):
|
||||
console.print(data["content"], markup=False)
|
||||
|
||||
_output({"filename": args.filename, "content": content,
|
||||
"line_count": len(content.splitlines())},
|
||||
args.json, display=_display)
|
||||
|
||||
|
||||
def _handle_query_list(args: argparse.Namespace) -> None:
|
||||
"""Handle ``algonotes query list`` — list all notes as a table.
|
||||
|
||||
Optionally filter by tag with ``--tag <keyword>`` (SQL LIKE on
|
||||
the comma-separated tags field). Renders a ``rich`` table with
|
||||
columns: ID, filename, tags, ingestion date. Supports ``--json``.
|
||||
"""
|
||||
from scripts.query import list_notes, print_list, NoteInfo
|
||||
|
||||
notes = list_notes(args.tag)
|
||||
|
||||
def _display(data):
|
||||
print_list([NoteInfo.from_dict(d) for d in data])
|
||||
|
||||
_output([n.__dict__ for n in notes], args.json, display=_display)
|
||||
|
||||
|
||||
def _handle_query_info(args: argparse.Namespace) -> None:
|
||||
"""Handle ``algonotes query info`` — show detailed metadata for one note.
|
||||
|
||||
Looks up the note by ``<filename>`` or ``--id <n>`` (at least one
|
||||
required). Prints each metadata field as a key-value pair via
|
||||
``rich`` console. Supports ``--json``.
|
||||
"""
|
||||
from scripts.query import show_info, console
|
||||
from src.store.sql_store import get_sql_store
|
||||
|
||||
if not args.filename and not args.id:
|
||||
console.print("[red]须指定 filename 或 --id[/red]")
|
||||
return
|
||||
|
||||
if args.id:
|
||||
record = get_sql_store().get(args.id)
|
||||
note = show_info(record["filename"]) if record and record.get("filename") else None
|
||||
if result.success:
|
||||
print(f"成功删除 {result.file_name}")
|
||||
else:
|
||||
note = show_info(args.filename)
|
||||
print(f"{result.file_name} 文件不存在,无法进行删除")
|
||||
|
||||
if note is None:
|
||||
console.print(f"[red]笔记不存在: {args.filename or f'id={args.id}'}[/red]")
|
||||
return
|
||||
|
||||
def _display(data):
|
||||
for k, v in data.items():
|
||||
console.print(f" [cyan]{k}:[/cyan] {v}")
|
||||
|
||||
_output(note.__dict__, args.json, display=_display)
|
||||
if args.json:
|
||||
args.json.write_text(
|
||||
json.dumps(result.__dict__, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _handle_query_search(args: argparse.Namespace) -> None:
|
||||
"""Handle ``algonotes query search <query>`` — semantic vector search.
|
||||
def _run_query(args):
|
||||
from scripts.query import (show_raw, list_notes, show_info,
|
||||
semantic_search, print_list, console)
|
||||
import json as _json
|
||||
|
||||
Searches the Chroma vector store for chunks semantically similar
|
||||
to *query*. Prints top ``--top-k`` (default 5) results with source
|
||||
filename and a 120-character preview. Supports ``--json``.
|
||||
"""
|
||||
from scripts.query import semantic_search, console
|
||||
if args.query_command == "show":
|
||||
content = show_raw(args.filename, args.lines)
|
||||
if content is None:
|
||||
console.print(f"[red]笔记不存在: {args.filename}[/red]")
|
||||
elif args.json:
|
||||
line_count = len(content.splitlines())
|
||||
args.json.write_text(
|
||||
_json.dumps({"filename": args.filename, "content": content,
|
||||
"line_count": line_count},
|
||||
ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
else:
|
||||
console.print(content)
|
||||
|
||||
docs = semantic_search(args.query, args.top_k)
|
||||
if not docs:
|
||||
console.print("[yellow]无匹配结果[/yellow]")
|
||||
return
|
||||
elif args.query_command == "list":
|
||||
notes = list_notes(args.tag)
|
||||
if args.json:
|
||||
args.json.write_text(
|
||||
_json.dumps([n.__dict__ for n in notes],
|
||||
ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
else:
|
||||
print_list(notes)
|
||||
|
||||
def _display(data):
|
||||
results = data["results"]
|
||||
console.print(f"找到 {len(results)} 条相关结果:\n")
|
||||
for i, r in enumerate(results, 1):
|
||||
console.print(
|
||||
f"[cyan][{i}][/cyan] 来自: {r.get('source', '?')}")
|
||||
console.print(f" {r['content'][:120]}...\n", markup=False)
|
||||
elif args.query_command == "info":
|
||||
if not args.filename and not args.id:
|
||||
console.print("[red]须指定 filename 或 --id[/red]")
|
||||
else:
|
||||
if args.id:
|
||||
from src.store.sql_store import get_sql_store
|
||||
record = get_sql_store().get(args.id)
|
||||
if record:
|
||||
note = show_info(record["filename"]) if record.get("filename") else None
|
||||
else:
|
||||
note = None
|
||||
else:
|
||||
note = show_info(args.filename)
|
||||
if note is None:
|
||||
console.print(f"[red]笔记不存在: {args.filename or f'id={args.id}'}[/red]")
|
||||
elif args.json:
|
||||
args.json.write_text(
|
||||
_json.dumps(note.__dict__, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
else:
|
||||
for k, v in note.__dict__.items():
|
||||
console.print(f" [cyan]{k}:[/cyan] {v}")
|
||||
|
||||
_output({"query": args.query,
|
||||
"results": [{"source": d.metadata.get("source"),
|
||||
"content": d.page_content,
|
||||
"metadata": {k: v for k, v in d.metadata.items()
|
||||
if k != "source"}}
|
||||
for d in docs]},
|
||||
args.json, display=_display)
|
||||
elif args.query_command == "search":
|
||||
docs = semantic_search(args.query, args.top_k)
|
||||
if not docs:
|
||||
console.print("[yellow]无匹配结果[/yellow]")
|
||||
elif args.json:
|
||||
args.json.write_text(
|
||||
_json.dumps({"query": args.query,
|
||||
"results": [{"source": d.metadata.get("source"),
|
||||
"content": d.page_content,
|
||||
"metadata": {k: v for k, v in d.metadata.items()
|
||||
if k != "source"}}
|
||||
for d in docs]},
|
||||
ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
else:
|
||||
console.print(f"找到 {len(docs)} 条相关结果:\n")
|
||||
for i, doc in enumerate(docs, 1):
|
||||
console.print(
|
||||
f"[cyan][{i}][/cyan] 来自: {doc.metadata.get('source', '?')}")
|
||||
console.print(f" {doc.page_content[:120]}...\n")
|
||||
|
||||
elif args.query_command == "export":
|
||||
_run_query_export(args)
|
||||
elif args.query_command == "ask":
|
||||
_run_query_ask(args)
|
||||
|
||||
|
||||
def _handle_query_export(args: argparse.Namespace) -> None:
|
||||
"""Handle ``algonotes query export`` — export notes with YAML frontmatter.
|
||||
|
||||
Supports two modes:
|
||||
- ``export <filename>`` — single note
|
||||
- ``export --all`` — every note in the store
|
||||
|
||||
Output files are written to ``-o/--output`` (default ``./export``)
|
||||
and are compatible with Obsidian. Catches ``FileNotFoundError``
|
||||
for missing notes gracefully. Supports ``--json``.
|
||||
"""
|
||||
def _run_query_export(args):
|
||||
from scripts.query import export_note, export_all
|
||||
import json as _json
|
||||
|
||||
try:
|
||||
if args.all:
|
||||
results = export_all(args.output)
|
||||
|
||||
def _display(data):
|
||||
print(f"Exported {len(data)} notes to {args.output}")
|
||||
|
||||
_output(results, args.json, display=_display)
|
||||
if args.json:
|
||||
args.json.write_text(
|
||||
_json.dumps(results, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
else:
|
||||
print(f"Exported {len(results)} notes to {args.output}")
|
||||
elif args.filename:
|
||||
path = export_note(args.filename, args.output)
|
||||
|
||||
def _display(data):
|
||||
print(f"Exported {data['filename']} to {data['path']}")
|
||||
|
||||
_output({"filename": args.filename, "path": str(path)},
|
||||
args.json, display=_display)
|
||||
if args.json:
|
||||
args.json.write_text(
|
||||
_json.dumps({"filename": args.filename,
|
||||
"path": str(path)},
|
||||
ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
else:
|
||||
print(f"Exported {args.filename} to {path}")
|
||||
else:
|
||||
print("请指定 filename 或 --all")
|
||||
except FileNotFoundError as e:
|
||||
print(f"❌ 笔记不存在: {e}")
|
||||
except Exception as e:
|
||||
print(f"❌ 导出失败: {e}")
|
||||
|
||||
|
||||
def _handle_query_ask(args: argparse.Namespace) -> None:
|
||||
"""Handle ``algonotes query ask <question>`` — single-shot RAG question.
|
||||
|
||||
Runs the full RAG pipeline (retrieve → rerank → generate) via
|
||||
``scripts.rag.ask_question`` and prints the answer. Use ``--stream``
|
||||
for token-by-token output (the handler itself is silent in stream
|
||||
mode because ``ask_question`` already prints). Supports ``--json``.
|
||||
|
||||
Exits with code 1 if the RAG pipeline returns ``success=False``.
|
||||
"""
|
||||
def _run_query_ask(args):
|
||||
from scripts.rag import ask_question
|
||||
import json as _json
|
||||
|
||||
result = ask_question(args.question, stream=args.stream)
|
||||
|
||||
|
|
@ -567,125 +422,24 @@ def _handle_query_ask(args: argparse.Namespace) -> None:
|
|||
print(f"❌ 问答失败: {result.error}")
|
||||
sys.exit(1)
|
||||
|
||||
def _display(data):
|
||||
if not args.stream:
|
||||
print(data["answer"])
|
||||
|
||||
_output(result.dict, args.json, display=_display)
|
||||
if args.json:
|
||||
args.json.write_text(
|
||||
_json.dumps(result.dict, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
elif not args.stream:
|
||||
print(result.answer)
|
||||
|
||||
|
||||
def _handle_query(args: argparse.Namespace) -> None:
|
||||
"""Dispatch ``algonotes query {show, list, info, search, export, ask}``.
|
||||
|
||||
``args.query_command`` is guaranteed to exist because the sub-subparser
|
||||
was created with ``required=True``.
|
||||
"""
|
||||
_QUERY_HANDLERS[args.query_command](args)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Handlers: chat & mcp
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def _handle_chat(args: argparse.Namespace) -> None:
|
||||
"""Handle ``algonotes chat`` — start an interactive RAG session.
|
||||
|
||||
Launches a REPL loop that preserves conversation context across
|
||||
turns via LangGraph's ``InMemorySaver`` checkpointer. The
|
||||
``--thread-id`` argument isolates different conversations.
|
||||
|
||||
Type ``exit`` or ``quit`` to end the session.
|
||||
"""
|
||||
def _run_chat(args):
|
||||
from scripts.chat import chat_loop
|
||||
chat_loop(args.thread_id)
|
||||
|
||||
|
||||
def _handle_mcp(args: argparse.Namespace) -> None:
|
||||
"""Handle ``algonotes mcp`` — start the MCP server over SSE transport.
|
||||
|
||||
Binds to ``--host``:``--port`` (default ``127.0.0.1:8000``) and
|
||||
exposes nine tools (ingest, update, delete, show, list, search,
|
||||
metadata, export, ask) that MCP clients such as Claude Desktop
|
||||
can call.
|
||||
"""
|
||||
def _run_mcp(args):
|
||||
from src.mcp.server import main as mcp_main
|
||||
mcp_main(host=args.host, port=args.port)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Handler: perf
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def _handle_perf(args: argparse.Namespace) -> None:
|
||||
"""Handle ``algonotes perf`` — display performance statistics.
|
||||
|
||||
Parses ``logs/perf.log``, aggregates latency metrics by call_type,
|
||||
and prints a summary table. Use ``--detail`` for a per-entry
|
||||
breakdown. Supports ``--json`` for structured export.
|
||||
"""
|
||||
from scripts.perf_report import load_entries, print_report, report_to_dict
|
||||
|
||||
entries = load_entries()
|
||||
|
||||
if entries:
|
||||
print_report(entries, detail=args.detail)
|
||||
|
||||
_output(report_to_dict(entries), args.json)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Dispatch tables
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
#: Map ``update_command`` strings to their handler functions.
|
||||
_UPDATE_HANDLERS = {
|
||||
"content": _handle_update_content,
|
||||
"metadata": _handle_update_metadata,
|
||||
}
|
||||
|
||||
#: Map ``query_command`` strings to their handler functions.
|
||||
_QUERY_HANDLERS = {
|
||||
"show": _handle_query_show,
|
||||
"list": _handle_query_list,
|
||||
"info": _handle_query_info,
|
||||
"search": _handle_query_search,
|
||||
"export": _handle_query_export,
|
||||
"ask": _handle_query_ask,
|
||||
}
|
||||
|
||||
#: Map top-level ``command`` strings to their handler functions.
|
||||
_HANDLERS = {
|
||||
"ingest": _handle_ingest,
|
||||
"update": _handle_update,
|
||||
"delete": _handle_delete,
|
||||
"query": _handle_query,
|
||||
"chat": _handle_chat,
|
||||
"mcp": _handle_mcp,
|
||||
"perf": _handle_perf,
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Entry point
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def main() -> None:
|
||||
"""Entry point for the ``algonotes`` CLI.
|
||||
|
||||
Order of operations:
|
||||
1. Configure logging (checks for ``-v/--verbose`` in raw argv).
|
||||
2. Ensure stdout uses UTF-8 encoding.
|
||||
3. Build the full argparse parser via ``_build_parser()``.
|
||||
4. Parse arguments and dispatch to the matching handler.
|
||||
"""
|
||||
_setup_cli_logging()
|
||||
_ensure_utf8_stdout()
|
||||
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
_HANDLERS[args.command](args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
|
|||
|
|
@ -105,8 +105,7 @@ def _save_to_stores(file_name: str, filepath: str, content: str,
|
|||
|
||||
def ingest_local(file_path: Path, tag: bool = True,
|
||||
verbose: bool = False, type: str | None = None,
|
||||
author: str | None = None,
|
||||
filename: str | None = None) -> IngestResult:
|
||||
author: str | None = None) -> IngestResult:
|
||||
"""Import a single local .md file into three-layer storage.
|
||||
|
||||
Loads from filesystem, splits into chunks, extracts tags (optional),
|
||||
|
|
@ -118,13 +117,12 @@ def ingest_local(file_path: Path, tag: bool = True,
|
|||
verbose: Print processing details to stdout.
|
||||
type: Note type (note/solution/template).
|
||||
author: Note author.
|
||||
filename: Custom filename override. Defaults to the source file's basename.
|
||||
|
||||
Returns:
|
||||
An ``IngestResult`` with metadata of the imported note.
|
||||
"""
|
||||
saved_path, content = load_local(
|
||||
file_path, _file_store, stream=verbose, filename=filename)
|
||||
file_path, _file_store, stream=verbose)
|
||||
return _save_to_stores(saved_path.name, str(saved_path.resolve()),
|
||||
content, source_url=None, type=type, author=author,
|
||||
tag=tag, verbose=verbose)
|
||||
|
|
@ -165,8 +163,7 @@ def ingest_locals(dir_path: Path, tag: bool = True,
|
|||
|
||||
def ingest_web(url: str, tag: bool = True,
|
||||
verbose: bool = False, type: str | None = None,
|
||||
author: str | None = None,
|
||||
filename: str | None = None) -> IngestResult:
|
||||
author: str | None = None) -> IngestResult:
|
||||
"""Import a web page into three-layer storage.
|
||||
|
||||
Loads via WebBaseLoader, splits into chunks, extracts tags (optional),
|
||||
|
|
@ -178,7 +175,6 @@ def ingest_web(url: str, tag: bool = True,
|
|||
verbose: Print processing details to stdout.
|
||||
type: Note type (note/solution/template).
|
||||
author: Note author.
|
||||
filename: Custom filename override. Defaults to URL-derived name.
|
||||
|
||||
Returns:
|
||||
An ``IngestResult`` with metadata of the imported note.
|
||||
|
|
@ -187,7 +183,7 @@ def ingest_web(url: str, tag: bool = True,
|
|||
if verbose:
|
||||
print(" 清洗: ")
|
||||
print(" > ", end="")
|
||||
saved_path, content = load_web(url, _file_store, stream=verbose, filename=filename)
|
||||
saved_path, content = load_web(url, _file_store, stream=verbose)
|
||||
result = _save_to_stores(saved_path.name, str(saved_path.resolve()),
|
||||
content, source_url=url, type=type, author=author,
|
||||
tag=tag, verbose=verbose)
|
||||
|
|
|
|||
|
|
@ -1,222 +0,0 @@
|
|||
# scripts/perf_report.py
|
||||
# Performance report: parse logs/perf.log, aggregate by call_type,
|
||||
# and present latency statistics.
|
||||
|
||||
import json
|
||||
import statistics
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
|
||||
DEFAULT_PERF_LOG = Path("logs/perf.log")
|
||||
|
||||
|
||||
@dataclass
|
||||
class PerfEntry:
|
||||
"""A single parsed performance log entry."""
|
||||
|
||||
timestamp: str
|
||||
call_type: str
|
||||
latency_ms: float
|
||||
success: bool
|
||||
model: str | None = None
|
||||
doc_count: int | None = None
|
||||
error: str | None = None
|
||||
extra: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def load_entries(path: Path | None = None) -> list[PerfEntry]:
|
||||
"""Parse ``logs/perf.log`` into a list of :class:`PerfEntry`.
|
||||
|
||||
Args:
|
||||
path: Path to perf log file. Defaults to ``logs/perf.log``.
|
||||
|
||||
Returns:
|
||||
List of parsed entries (empty if file missing or unparseable).
|
||||
"""
|
||||
path = path or DEFAULT_PERF_LOG
|
||||
if not path.exists():
|
||||
return []
|
||||
|
||||
entries: list[PerfEntry] = []
|
||||
with path.open(encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
entries.append(PerfEntry(
|
||||
timestamp=obj.get("timestamp", ""),
|
||||
call_type=obj.get("call_type", "unknown"),
|
||||
latency_ms=float(obj.get("latency_ms", 0)),
|
||||
success=bool(obj.get("success", True)),
|
||||
model=obj.get("model"),
|
||||
doc_count=obj.get("doc_count"),
|
||||
error=obj.get("error"),
|
||||
extra={k: v for k, v in obj.items()
|
||||
if k not in ("timestamp", "level", "logger", "message",
|
||||
"call_type", "latency_ms", "success",
|
||||
"model", "doc_count", "error")},
|
||||
))
|
||||
return entries
|
||||
|
||||
|
||||
def aggregate(entries: list[PerfEntry]) -> dict[str, dict[str, Any]]:
|
||||
"""Aggregate latency statistics grouped by call_type.
|
||||
|
||||
Args:
|
||||
entries: Parsed perf log entries.
|
||||
|
||||
Returns:
|
||||
Dict keyed by call_type, each value containing:
|
||||
count, total_ms, avg_ms, min_ms, max_ms, p50_ms, p95_ms, p99_ms,
|
||||
failures, success_rate.
|
||||
"""
|
||||
groups: dict[str, list[float]] = {}
|
||||
failures: dict[str, int] = {}
|
||||
for e in entries:
|
||||
groups.setdefault(e.call_type, []).append(e.latency_ms)
|
||||
if not e.success:
|
||||
failures[e.call_type] = failures.get(e.call_type, 0) + 1
|
||||
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
for ct, lats in sorted(groups.items()):
|
||||
total = len(lats)
|
||||
fails = failures.get(ct, 0)
|
||||
sorted_lats = sorted(lats)
|
||||
result[ct] = {
|
||||
"count": total,
|
||||
"total_ms": round(sum(lats), 1),
|
||||
"avg_ms": round(statistics.mean(lats), 1),
|
||||
"min_ms": round(min(lats), 1),
|
||||
"max_ms": round(max(lats), 1),
|
||||
"p50_ms": round(_percentile(sorted_lats, 50), 1),
|
||||
"p95_ms": round(_percentile(sorted_lats, 95), 1),
|
||||
"p99_ms": round(_percentile(sorted_lats, 99), 1),
|
||||
"failures": fails,
|
||||
"success_rate": f"{(total - fails) / total * 100:.1f}%",
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def _percentile(sorted_values: list[float], pct: float) -> float:
|
||||
"""Compute the pct-th percentile of sorted values (linear interpolation)."""
|
||||
if not sorted_values:
|
||||
return 0.0
|
||||
n = len(sorted_values)
|
||||
k = (pct / 100) * (n - 1)
|
||||
f = int(k)
|
||||
c = k - f
|
||||
if f + 1 < n:
|
||||
return sorted_values[f] + c * (sorted_values[f + 1] - sorted_values[f])
|
||||
return sorted_values[f]
|
||||
|
||||
|
||||
def print_report(
|
||||
entries: list[PerfEntry],
|
||||
detail: bool = False,
|
||||
) -> None:
|
||||
"""Print a latency statistics report to the console.
|
||||
|
||||
Args:
|
||||
entries: Parsed perf log entries.
|
||||
detail: If True, also print a per-entry table.
|
||||
"""
|
||||
if not entries:
|
||||
console.print("[yellow]No performance data found.[/yellow]")
|
||||
console.print(
|
||||
f"[dim]Run some queries first — logs are stored in "
|
||||
f"{DEFAULT_PERF_LOG}[/dim]"
|
||||
)
|
||||
return
|
||||
|
||||
stats = aggregate(entries)
|
||||
|
||||
# ── Summary table ───────────────────────────────────
|
||||
table = Table(title="📈 性能统计 (Perf Log)")
|
||||
table.add_column("call_type", style="cyan")
|
||||
table.add_column("次数", justify="right")
|
||||
table.add_column("avg", justify="right")
|
||||
table.add_column("P50", justify="right")
|
||||
table.add_column("P95", justify="right")
|
||||
table.add_column("P99", justify="right")
|
||||
table.add_column("min", justify="right")
|
||||
table.add_column("max", justify="right")
|
||||
table.add_column("成功率", justify="right")
|
||||
|
||||
for ct, s in stats.items():
|
||||
table.add_row(
|
||||
ct,
|
||||
str(s["count"]),
|
||||
f"{s['avg_ms']:.0f}ms",
|
||||
f"{s['p50_ms']:.0f}ms",
|
||||
f"{s['p95_ms']:.0f}ms",
|
||||
f"{s['p99_ms']:.0f}ms",
|
||||
f"{s['min_ms']:.0f}ms",
|
||||
f"{s['max_ms']:.0f}ms",
|
||||
s["success_rate"],
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
# ── Detail table ────────────────────────────────────
|
||||
if detail:
|
||||
console.print()
|
||||
dtable = Table(title="📋 详细记录")
|
||||
dtable.add_column("timestamp", style="dim")
|
||||
dtable.add_column("call_type", style="cyan")
|
||||
dtable.add_column("latency", justify="right")
|
||||
dtable.add_column("status", justify="center")
|
||||
dtable.add_column("details")
|
||||
|
||||
for e in entries:
|
||||
status = "✅" if e.success else "❌"
|
||||
extras = []
|
||||
if e.model:
|
||||
extras.append(f"model={e.model}")
|
||||
if e.doc_count is not None:
|
||||
extras.append(f"docs={e.doc_count}")
|
||||
if e.error:
|
||||
extras.append(f"error={e.error}")
|
||||
for k, v in e.extra.items():
|
||||
if isinstance(v, (int, float, str)):
|
||||
extras.append(f"{k}={v}")
|
||||
dtable.add_row(
|
||||
e.timestamp,
|
||||
e.call_type,
|
||||
f"{e.latency_ms:.0f}ms",
|
||||
status,
|
||||
", ".join(extras) if extras else "—",
|
||||
)
|
||||
|
||||
console.print(dtable)
|
||||
|
||||
|
||||
def report_to_dict(entries: list[PerfEntry]) -> dict[str, Any]:
|
||||
"""Return aggregated stats + raw entries as a JSON-serializable dict."""
|
||||
return {
|
||||
"summary": aggregate(entries),
|
||||
"total_entries": len(entries),
|
||||
"entries": [
|
||||
{
|
||||
"timestamp": e.timestamp,
|
||||
"call_type": e.call_type,
|
||||
"latency_ms": e.latency_ms,
|
||||
"success": e.success,
|
||||
"model": e.model,
|
||||
"doc_count": e.doc_count,
|
||||
"error": e.error,
|
||||
**e.extra,
|
||||
}
|
||||
for e in entries
|
||||
],
|
||||
}
|
||||
|
|
@ -5,8 +5,6 @@ import json
|
|||
from dataclasses import dataclass, asdict
|
||||
from typing import Any
|
||||
|
||||
from src.performance import PerfTimer
|
||||
|
||||
|
||||
@dataclass
|
||||
class AskResult:
|
||||
|
|
@ -36,28 +34,24 @@ def ask_question(question: str, stream: bool = False) -> AskResult:
|
|||
from src.rag.agent import create_rag_agent
|
||||
|
||||
agent = create_rag_agent()
|
||||
cfg = {"configurable": {"thread_id": "ask"}}
|
||||
config = {"configurable": {"thread_id": "ask"}}
|
||||
|
||||
with PerfTimer("pipeline") as t:
|
||||
try:
|
||||
agent_steps = 0
|
||||
full_response = ""
|
||||
for event in agent.stream(
|
||||
{"messages": [("user", question)]},
|
||||
cfg,
|
||||
):
|
||||
agent_steps += 1
|
||||
if "model" in event:
|
||||
content = event["model"]["messages"][-1].content
|
||||
if content:
|
||||
if stream:
|
||||
print(content, flush=True, end="")
|
||||
full_response += content
|
||||
try:
|
||||
full_response = ""
|
||||
for event in agent.stream(
|
||||
{"messages": [("user", question)]},
|
||||
config,
|
||||
):
|
||||
if "model" in event:
|
||||
content = event["model"]["messages"][-1].content
|
||||
if content:
|
||||
if stream:
|
||||
print(content, flush=True, end="")
|
||||
full_response += content
|
||||
|
||||
if stream:
|
||||
print()
|
||||
if stream:
|
||||
print()
|
||||
|
||||
t.set_extra("agent_steps", agent_steps)
|
||||
return AskResult(question=question, answer=full_response, success=True)
|
||||
except Exception as e:
|
||||
return AskResult(question=question, answer="", success=False, error=str(e))
|
||||
return AskResult(question=question, answer=full_response, success=True)
|
||||
except Exception as e:
|
||||
return AskResult(question=question, answer="", success=False, error=str(e))
|
||||
|
|
|
|||
|
|
@ -1,48 +0,0 @@
|
|||
---
|
||||
name: icpc-problem-summary
|
||||
description: 为 OI/ICPC 算法竞赛题目(如洛谷、Codeforces 等)精准提炼数学化题意摘要,提取输入变量、约束条件及核心目标关系,默认输出为一句话版本(含题目来源、数据范围和核心公式),也可按需输出多句详细拆解。当用户直接粘贴大段题目原文并要求“提炼题意”、“概括题目”、“一句话总结”、“抽象成数学模型”或“快速理解这道题在求什么”时触发;也适用于用户提供平台题目链接并要求极简核心逻辑概述的场景。
|
||||
license: MIT
|
||||
metadata:
|
||||
version: 1.0
|
||||
author: ttzc@github.com
|
||||
---
|
||||
|
||||
# 算法题意提炼助手
|
||||
|
||||
本技能用于将任意 OI/ICPC 算法竞赛题目(如洛谷、Codeforces 等)的描述,精准提炼为一句话核心题意或多句话数学模型,便于快速理解题目本质与建模要点,或用于撰写题解。
|
||||
|
||||
**功能与流程**:
|
||||
1. **识别变量**:提取所有输入参数(如数组元素、数据规模、目标值等),并标注其类型、取值范围(按题目说明)。
|
||||
2. **明确目标**:确定输出变量(如最大值、最小值、总和、是否存在等),并用字母表示。
|
||||
3. **表达约束与关系**:用数学符号(`\sum`、`\max`、`\min`、`\lfloor\rfloor`、条件指示函数等)准确描述运算逻辑、判定条件和特殊细节(如“不同位置算不同”、“连续段”等)。
|
||||
4. **整合为一句或多句**:将所有已知量、未知量及关系融入一个或多个连贯的自然语句,优先使用公式替代冗长文字,所有公式用 `$$` 包裹。具体输出规模按照用户实际需求,若未指定则默认输出一句话版本。
|
||||
5. **统一输出格式**:若题目来自洛谷,前缀为 **“洛谷 Pxxxx 题目名”**;若来自 Codeforces,前缀为 **“Codeforces xxx[A/B/...] 题目名”**,其余平台格式类似。输出中务必包含所有关键范围(如 `1≤N≤10^5`)和核心目标表达式。你可以参考后面的示例。
|
||||
|
||||
**输入**:用户粘贴的完整题目原文(包含背景、输入输出格式、样例、数据范围)。
|
||||
|
||||
**输出**:一句话版本,仅输出一行符合上述格式的总结句,不附加任何额外解释或分析。多句话版本类似,参考后面示例。
|
||||
|
||||
**示例输出**(供参考格式):
|
||||
|
||||
单句版本:
|
||||
|
||||
- **洛谷 P1873 砍树**:已知 N(1≤N≤10^6)棵树的高度 h_i(h_i≤4×10^5)和所需木材总长 M(1≤M≤2×10^9,且 ∑h_i > M),求最大的整数高度 H,使得锯下的木材总量 ∑max(0, h_i−H) ≥ M。
|
||||
- **Codeforces 780B The Meeting Place Cannot Be Changed**:已知朋友数 n(2≤n≤60000)、初始位置 x_i 和最大速度 v_i(1≤x_i,v_i≤10^9),求最小时间 T,使得存在实数 X 满足 ∀i,|X−x_i|≤v_i⋅T。
|
||||
- **洛谷 P1314 聪明的质监员**:已知矿石数 $n$($1\le n\le 2\times10^5$)、区间数 $m$($1\le m\le 2\times10^5$)、标准值 $s$($0<s\le10^{12}$),每个矿石 $i$ 有重量 $w_i$ 和价值 $v_i$($0<w_i,v_i\le10^6$),以及 $m$ 个区间 $[l_i,r_i]$($1\le l_i\le r_i\le n$),定义检验值 $y=\sum_{i=1}^m \left( \sum_{j=l_i}^{r_i} [w_j\ge W] \right) \cdot \left( \sum_{j=l_i}^{r_i} [w_j\ge W]\cdot v_j \right)$,其中 $W$ 为可选参数(整数),求 $\min_{W} |s-y|$。
|
||||
|
||||
多句版本:
|
||||
|
||||
给定 $n$ 个矿石,每个有重量 $w_i$ 和价值 $v_i$,以及 $m$ 个区间 $[l_i, r_i]$。选择一个参数 $W$,定义区间 $i$ 的检验值:
|
||||
$$
|
||||
y_i = \left( \sum_{j=l_i}^{r_i} [w_j \ge W] \right) \times \left( \sum_{j=l_i}^{r_i} [w_j \ge W] \cdot v_j \right)
|
||||
$$
|
||||
总检验值 $Y(W) = \sum_{i=1}^m y_i$。给定标准值 $s$,求 $\min_{W \in \mathbb{Z}} |Y(W) - s|$。
|
||||
数据范围:$1 \le n,m \le 2\times 10^5$,$0 < w_i, v_i \le 10^6$,$0 < s \le 10^{12}$,$1 \le l_i \le r_i \le n$。
|
||||
|
||||
|
||||
**适用场景**:
|
||||
- 快速理解新题的数学模型;
|
||||
- 为编写题解、设计算法或生成测试用例提供精确的题意摘要;
|
||||
- 作为后续分类、检索或比对相似题目的基础元数据。
|
||||
|
||||
**注意**:本技能仅提炼题意,不直接解答或编写代码。
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
---
|
||||
name: icpc-solution-writer
|
||||
description: 为 ICPC/OI 竞赛题目生成高质量、结构化的题解(Solution Editorial),包含试题元数据、暴力推导、核心算法、正确性证明、复杂度分析、坑点及参考代码。当用户要求“写题解”、“生成解题报告”、“详细解释算法问题并写成题解”或提供比赛题目链接和解题思路时触发。
|
||||
license: MIT
|
||||
metadata:
|
||||
version: 1.0
|
||||
author: ttzc@github.com
|
||||
|
||||
user-invocable: true
|
||||
context:
|
||||
- *.cpp
|
||||
- *.md
|
||||
---
|
||||
|
||||
# ICPC/OI 题目题解生成专家
|
||||
|
||||
你现在是顶级的 ICPC 金牌选手兼题解作者。当用户给出题目(文字描述或链接)时,请严格按照以下 9 个部分输出高质量的完整题解。必须确保逻辑严谨、代码正确、新手友好。
|
||||
|
||||
## 输出结构(严格按此顺序)
|
||||
|
||||
### 1. 元数据 (Metadata)
|
||||
- **题目链接**:提供试题提交的直达链接,或者原题链接
|
||||
- **时间限制**:例如 2 秒。
|
||||
- **内存限制**:例如 256 MB。
|
||||
- **如果你不知道任何一项信息,可以写占位符待用户补充,但是不要胡编乱造**
|
||||
|
||||
### 2. 题意简述 (Problem Summary)
|
||||
- 用 2-3 句话剥离故事背景,直击核心。
|
||||
- 请遵循以下步骤,总结核心题意:
|
||||
1. **识别输入变量**:找出所有输入参数(如数据规模 `N`、目标值 `M` 或 `C`,以及数组元素 `a_i` 等),为每个变量赋予标准字母,并从题目说明中提取其取值范围(包括上下界和类型)。
|
||||
2. **识别输出变量**:明确题目要求计算的最终结果(如最大高度 `H`、满足条件的数对个数等),同样用字母表示。
|
||||
3. **提取核心约束与目标**:用数学表达式(如求和 `\sum`、最大值/最小值、等式/不等式、条件判断等)准确描述变量之间的运算关系或判定条件。注意特殊细节(如“不同位置算不同”等)需体现在条件中。
|
||||
4. **整合成一句陈述**:将所有已知量、未知量及关系糅合为一个连贯的自然语句,优先使用公式符号(如 `≤`、`∑`、`max`)替代冗长文字,变量范围用 `1≤N≤10^6` 等标准格式注明。
|
||||
5. **检查公式格式**:通常情况下,用 $ 包裹所有数学公式和符号,以保证公式的正常显示。如果用户或使用的编辑工具对公式的输出有特殊要求,则服从特殊要求
|
||||
|
||||
### 3. 朴素解法 (Brute-Force)
|
||||
- 描述最简单的思路(如 O(N^2) 或 O(2^N))。
|
||||
- 说明其瓶颈,并指出在哪些数据范围下会超时。
|
||||
- 通常情况下用户会提供相关思路,若未提供则简述问题空间、枚举方法即可。
|
||||
|
||||
### 4. 核心解法 (Main Solution)
|
||||
- **特殊性质**:说明利用了问题的何种特殊性质(如单调性、交换律结合律、可二分性、动态规划的最优子结构性、等等)。
|
||||
- **关键突破**:从朴素解法的瓶颈出发,结合问题性质,引出核心优化思路(算法或数据结构)。
|
||||
- **推导过程**:利用性质分步骤写出递推公式或核心逻辑,使用 LaTeX 公式(如 `$dp[i] = \max(dp[i-1], ...)$`)。
|
||||
|
||||
### 5. 正确性证明 (Proof of Correctness)
|
||||
- **贪心**:使用交换论证或归纳法。
|
||||
- **DP**:证明最优子结构和无后效性。
|
||||
- **二分**:证明判定函数的二段性。
|
||||
- 其他问题类似,保持简洁但逻辑闭环。
|
||||
|
||||
### 6. 复杂度分析 (Complexity)
|
||||
- **时间复杂度**:给出 Big-O 并简要说明为何能通过本题。
|
||||
- **空间复杂度**:注明是否使用了滚动数组等优化。
|
||||
|
||||
### 7. 实现细节与避坑指南 (Implementation Details)
|
||||
列出关键陷阱:
|
||||
- 整数溢出(提醒用 `long long` 或 `__int128`)。
|
||||
- 初始化值(INF 的大小、多组数据的清空)。
|
||||
- 边界条件(N=0, N=1, 空串, 图不连通等)。
|
||||
|
||||
### 8. 参考代码 (Reference Code)
|
||||
- 提供 **C++14+** 或 **Python 3** 的可直接提交的完整代码,或者修改用户提供的代码。
|
||||
- 使用有意义的变量名,关键逻辑处添加注释(不要逐行注释语法)。
|
||||
- 代码必须规范化(使用代码格式化工具处理后的结果),竞赛化(单文件,简洁)。
|
||||
|
||||
### 9. 补充说明 (Additional Notes - 可选)
|
||||
- 提及该题是哪个经典问题的变种。
|
||||
- 提供其他的优化方向(如换用更高级的数据结构)。
|
||||
- 码风是否可以更简洁易读。
|
||||
|
||||
## 写作风格约束
|
||||
- 语言:清晰、简洁,像在给朋友耐心讲解。
|
||||
- 严禁闲聊:禁止输出“让我们来思考”、“首先”等寒暄词汇,直接输出题解正文。
|
||||
- 公式:全部使用 Markdown + LaTeX 渲染。
|
||||
|
||||
## 最终自检清单(在输出前内心确认)
|
||||
1. 算法是否能真正 AC 所有数据?
|
||||
2. 复杂度计算是否准确无误?
|
||||
3. 代码是否无编译错误并能处理极端边界?
|
||||
4. 新手看完整篇是否理解了来龙去脉?
|
||||
|
||||
现在,请直接开始处理用户提供的题目,不要输出任何多余的开场白。
|
||||
|
|
@ -14,7 +14,6 @@ from dataclasses import dataclass
|
|||
import httpx
|
||||
|
||||
from src.config import config
|
||||
from src.performance import PerfTimer
|
||||
|
||||
logger = logging.getLogger("algonotes.api.reranker_client")
|
||||
|
||||
|
|
@ -117,45 +116,30 @@ class GiteeAIReranker(BaseReranker):
|
|||
# "Content-Type": "application/json",
|
||||
}
|
||||
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug(
|
||||
f"Rerank payload: query='{query[:100]}...', "
|
||||
f"docs_count={len(documents)}, "
|
||||
f"total_chars={sum(len(d) for d in documents)}"
|
||||
)
|
||||
try:
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
logger.info(f"POST {url}")
|
||||
# logger.info(f"headers: {headers}")
|
||||
logger.info(f"body: {payload}")
|
||||
resp = client.post(url, json=payload, headers=headers)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(
|
||||
f"Rerank API error: {e.response.status_code} {e.response.text[:200]}")
|
||||
# logger.error(e.response)
|
||||
raise
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Rerank request failed: {e}")
|
||||
raise
|
||||
|
||||
with PerfTimer("reranker", model=self._model, doc_count=len(documents),
|
||||
extra={"top_n": top_n}) as t:
|
||||
try:
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
resp = client.post(url, json=payload, headers=headers)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(
|
||||
f"Rerank API error: {e.response.status_code} "
|
||||
f"{e.response.text[:200]}"
|
||||
)
|
||||
raise
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Rerank request failed: {e}")
|
||||
raise
|
||||
|
||||
results = []
|
||||
for item in data.get("results", []):
|
||||
results.append(RerankResult(
|
||||
index=item["index"],
|
||||
text=item["document"]["text"],
|
||||
relevance_score=item["relevance_score"],
|
||||
))
|
||||
|
||||
if results:
|
||||
scores = [r.relevance_score for r in results]
|
||||
t.set_extra("score_range", {
|
||||
"min": round(min(scores), 4),
|
||||
"max": round(max(scores), 4),
|
||||
"mean": round(sum(scores) / len(scores), 4),
|
||||
})
|
||||
results = []
|
||||
for item in data.get("results", []):
|
||||
results.append(RerankResult(
|
||||
index=item["index"],
|
||||
text=item["document"]["text"],
|
||||
relevance_score=item["relevance_score"],
|
||||
))
|
||||
|
||||
logger.info(f"Rerank returned {len(results)} results")
|
||||
return results
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@
|
|||
#
|
||||
# Usage:
|
||||
# from src.config import config
|
||||
# config.llm.model # → "Qwen3-Next-80B-A3B-Instruct" (取决于 config.toml)
|
||||
# config.embedding.model # → "Qwen3-Embedding-4B" (取决于 config.toml)
|
||||
# config.llm.model # → "DeepSeek-R1"
|
||||
# config.embedding.model # → "Qwen/Qwen3-Embedding-8B"
|
||||
|
||||
import os
|
||||
import re
|
||||
|
|
@ -75,9 +75,9 @@ def _expand_dict(d: dict) -> dict:
|
|||
class LLMConfig(BaseModel):
|
||||
"""Configuration for the chat model (OpenAI-compatible API)."""
|
||||
|
||||
model: str = "Qwen3-Next-80B-A3B-Instruct"
|
||||
model: str = "DeepSeek-R1"
|
||||
base_url: str = "https://ai.gitee.com/v1"
|
||||
api_key: str = "${GITEE_API_KEY}"
|
||||
api_key: str = "${OPENAI_API_KEY}"
|
||||
temperature: float = 0.7
|
||||
max_tokens: int = 4096
|
||||
timeout: float = 60.0
|
||||
|
|
@ -86,9 +86,9 @@ class LLMConfig(BaseModel):
|
|||
class EmbeddingConfig(BaseModel):
|
||||
"""Configuration for the embedding model (OpenAI-compatible API)."""
|
||||
|
||||
model: str = "Qwen3-Embedding-4B"
|
||||
model: str = "Qwen/Qwen3-Embedding-8B"
|
||||
base_url: str = "https://ai.gitee.com/v1"
|
||||
api_key: str = "${GITEE_API_KEY}"
|
||||
api_key: str = "${OPENAI_API_KEY}"
|
||||
|
||||
|
||||
class LoggingConfig(BaseModel):
|
||||
|
|
@ -100,28 +100,6 @@ class LoggingConfig(BaseModel):
|
|||
backup_count: int = 5
|
||||
|
||||
|
||||
class PerfLoggingConfig(BaseModel):
|
||||
"""Configuration for the performance logging channel (logs/perf.log)."""
|
||||
|
||||
enabled: bool = True
|
||||
file: str = "logs/perf.log"
|
||||
max_bytes: int = 10 * 1024 * 1024 # 10MB
|
||||
backup_count: int = 3
|
||||
|
||||
|
||||
class LangSmithConfig(BaseModel):
|
||||
"""Configuration for LangSmith tracing.
|
||||
|
||||
api_key must use ${LANGSMITH_API_KEY} to reference the .env file —
|
||||
never hardcode the key in config.toml.
|
||||
"""
|
||||
|
||||
enabled: bool = True
|
||||
api_key: str = "${LANGSMITH_API_KEY}"
|
||||
project: str = "algonotes-rag"
|
||||
endpoint: str = "https://api.smith.langchain.com"
|
||||
|
||||
|
||||
class StoreConfig(BaseModel):
|
||||
"""Configuration for the three-layer data storage paths."""
|
||||
|
||||
|
|
@ -135,7 +113,7 @@ class RerankerConfig(BaseModel):
|
|||
|
||||
model: str = "Qwen3-Reranker-4B"
|
||||
base_url: str = "https://ai.gitee.com/v1"
|
||||
api_key: str = "${GITEE_API_KEY}"
|
||||
api_key: str = "${OPENAI_API_KEY}"
|
||||
top_n: int = 3
|
||||
|
||||
|
||||
|
|
@ -153,8 +131,6 @@ class AppConfig(BaseModel):
|
|||
embedding: EmbeddingConfig = EmbeddingConfig()
|
||||
reranker: RerankerConfig = RerankerConfig()
|
||||
logging: LoggingConfig = LoggingConfig()
|
||||
perf_logging: PerfLoggingConfig = PerfLoggingConfig()
|
||||
langsmith: LangSmithConfig = LangSmithConfig()
|
||||
store: StoreConfig = StoreConfig()
|
||||
cp_graph: CPGraphConfig = CPGraphConfig()
|
||||
|
||||
|
|
@ -180,24 +156,3 @@ def _load() -> AppConfig:
|
|||
|
||||
|
||||
config: AppConfig = _load()
|
||||
|
||||
|
||||
def _sync_langsmith_env() -> None:
|
||||
"""Sync LangSmith config back to environment variables.
|
||||
|
||||
LangChain reads ``LANGSMITH_TRACING`` / ``LANGSMITH_API_KEY`` / etc.
|
||||
directly from ``os.environ`` at runtime — it does NOT look at our config
|
||||
models. This function ensures that ``config.toml`` ``[langsmith]`` is the
|
||||
single source of truth: what you set there actually controls LangSmith
|
||||
behaviour.
|
||||
|
||||
Must be called at module-load time, *before* any ``langchain*`` import.
|
||||
"""
|
||||
cfg = config.langsmith
|
||||
os.environ["LANGSMITH_TRACING"] = "true" if cfg.enabled else "false"
|
||||
os.environ["LANGSMITH_API_KEY"] = cfg.api_key
|
||||
os.environ["LANGSMITH_PROJECT"] = cfg.project
|
||||
os.environ["LANGSMITH_ENDPOINT"] = cfg.endpoint
|
||||
|
||||
|
||||
_sync_langsmith_env()
|
||||
|
|
|
|||
|
|
@ -11,8 +11,6 @@ from pathlib import Path
|
|||
from langchain_core.prompts import PromptTemplate
|
||||
|
||||
from src.api.llm_client import get_chat_model
|
||||
from src.config import config
|
||||
from src.performance import PerfTimer
|
||||
|
||||
logger = logging.getLogger("algonotes.ingestion.cleaner")
|
||||
|
||||
|
|
@ -57,13 +55,11 @@ def clean_text(raw_text: str, stream: bool = False, max_retries: int = 3) -> str
|
|||
try:
|
||||
prompt = CLEAN_PROMPT.format(text=raw_text)
|
||||
|
||||
with PerfTimer("text_cleaning", model=config.llm.model,
|
||||
token_count=len(raw_text)):
|
||||
cleaned = ""
|
||||
for chunk in model.stream(prompt):
|
||||
cleaned += chunk.content
|
||||
if stream:
|
||||
print(chunk.content, end="", flush=True)
|
||||
cleaned = ""
|
||||
for chunk in model.stream(prompt):
|
||||
cleaned += chunk.content
|
||||
if stream:
|
||||
print(chunk.content, end="", flush=True)
|
||||
|
||||
logger.info(f"Text cleaned successfully (length: {len(cleaned)})")
|
||||
return _strip_code_block_wrapper(cleaned)
|
||||
|
|
|
|||
|
|
@ -52,15 +52,13 @@ def load_text(content: str, file_name: str, file_store: FileStore, clean: bool =
|
|||
return Path(saved_path), content
|
||||
|
||||
|
||||
def load_local(filepath: str | Path, file_store: FileStore, stream: bool = False,
|
||||
filename: str | None = None) -> tuple[Path, str]:
|
||||
def load_local(filepath: str | Path, file_store: FileStore, stream: bool = False) -> tuple[Path, str]:
|
||||
"""Load a local .md file and save to FileStore.
|
||||
|
||||
Args:
|
||||
filepath: Path to the local markdown file.
|
||||
file_store: FileStore instance for saving.
|
||||
stream: Stream cleaning output to stdout.
|
||||
filename: Custom filename override. Defaults to the source file's basename.
|
||||
|
||||
Returns:
|
||||
Tuple of (saved_path, content).
|
||||
|
|
@ -69,7 +67,7 @@ def load_local(filepath: str | Path, file_store: FileStore, stream: bool = False
|
|||
if not path.exists():
|
||||
raise FileNotFoundError(f"File not found: {filepath}")
|
||||
|
||||
filename = filename or path.name
|
||||
filename = path.name
|
||||
content = path.read_text(encoding="utf-8")
|
||||
saved_path, content = load_text(
|
||||
content, filename, file_store, False, stream)
|
||||
|
|
@ -78,8 +76,7 @@ def load_local(filepath: str | Path, file_store: FileStore, stream: bool = False
|
|||
return saved_path, content
|
||||
|
||||
|
||||
def load_web(url: str, file_store: FileStore, stream: bool = False,
|
||||
filename: str | None = None) -> tuple[Path, str]:
|
||||
def load_web(url: str, file_store: FileStore, stream: bool = False) -> tuple[Path, str]:
|
||||
"""Load a web page, optionally clean it, and save to FileStore.
|
||||
|
||||
Uses a Chrome 120 browser header template to avoid blocking.
|
||||
|
|
@ -88,7 +85,6 @@ def load_web(url: str, file_store: FileStore, stream: bool = False,
|
|||
url: The URL of the web page.
|
||||
file_store: FileStore instance for saving.
|
||||
stream: Stream cleaning output to stdout.
|
||||
filename: Custom filename override. Defaults to URL-derived name.
|
||||
|
||||
Returns:
|
||||
Tuple of (saved_path, content).
|
||||
|
|
@ -104,7 +100,7 @@ def load_web(url: str, file_store: FileStore, stream: bool = False,
|
|||
raw_text = docs[0].page_content
|
||||
logger.info(f"Loaded {len(raw_text)} chars from web page")
|
||||
|
||||
filename = filename or _url_to_filename(url)
|
||||
filename = _url_to_filename(url)
|
||||
|
||||
saved_path, content = load_text(raw_text, filename, file_store, True, stream)
|
||||
logger.info(f"Saved web content: {filename} -> {saved_path}")
|
||||
|
|
|
|||
|
|
@ -11,8 +11,6 @@ from pathlib import Path
|
|||
from langchain_core.prompts import PromptTemplate
|
||||
|
||||
from src.api.llm_client import get_chat_model
|
||||
from src.config import config
|
||||
from src.performance import PerfTimer
|
||||
from src.store.file_store import FileStore
|
||||
|
||||
logger = logging.getLogger("algonotes.ingestion.tagger")
|
||||
|
|
@ -45,8 +43,7 @@ def extract_tags(filename: str, file_store: FileStore | None = None) -> str:
|
|||
|
||||
logger.info(f"Extracting tags for '{filename}'")
|
||||
try:
|
||||
with PerfTimer("tag_extraction", model=config.llm.model):
|
||||
response = model.invoke(prompt)
|
||||
response = model.invoke(prompt)
|
||||
tags = response.content.strip()
|
||||
tags = re.sub(r"```[\w]*\n?|```", "", tags).strip()
|
||||
logger.info(f"Tags for '{filename}': {tags}")
|
||||
|
|
|
|||
299
src/logger.py
299
src/logger.py
|
|
@ -1,312 +1,79 @@
|
|||
# src/logger.py
|
||||
# JSON Lines logging system for AlgoNotes RAG.
|
||||
#
|
||||
# Dual-channel architecture:
|
||||
# 1. Operational logs → logs/app.log (OpJsonFormatter)
|
||||
# 2. Performance logs → logs/perf.log (PerfJsonFormatter + trace_id injection)
|
||||
#
|
||||
# Pass ``console=True`` to also emit human-readable plain text to stderr.
|
||||
# By default logs only to file. Pass ``console=True`` to also emit
|
||||
# human-readable plain text to stderr (used by CLI ``--verbose``).
|
||||
|
||||
import contextvars
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
from src.config import config
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# contextvars — trace_id injection for LangSmith correlation
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
_trace_id: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
||||
"trace_id", default=None
|
||||
)
|
||||
class JsonFormatter(logging.Formatter):
|
||||
"""JSON Lines formatter: output each log record as a single JSON line."""
|
||||
|
||||
|
||||
def set_trace_id(trace_id: str) -> None:
|
||||
"""Manually set the current LangSmith trace / run ID.
|
||||
|
||||
Call this at the start of a request when operating outside
|
||||
LangChain's automatic context (e.g. raw CLI invocations).
|
||||
"""
|
||||
_trace_id.set(trace_id)
|
||||
|
||||
|
||||
def _get_current_trace_id() -> str | None:
|
||||
"""Read the LangSmith run ID from contextvars or the langsmith SDK.
|
||||
|
||||
Priority:
|
||||
1. Explicitly-set ``_trace_id`` contextvar (via :func:`set_trace_id`)
|
||||
2. LangSmith's own ``get_current_run_tree()`` contextvar
|
||||
|
||||
Returns ``None`` when no trace context is active.
|
||||
"""
|
||||
tid = _trace_id.get()
|
||||
if tid:
|
||||
return tid
|
||||
try:
|
||||
# langsmith is a transitive dependency of langchain
|
||||
from langsmith.run_helpers import get_current_run_tree
|
||||
|
||||
tree = get_current_run_tree()
|
||||
if tree is not None:
|
||||
return str(tree.id)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Build exclusion set for standard LogRecord attributes
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def _build_standard_keys() -> set[str]:
|
||||
"""Return the set of attribute names that belong to a plain LogRecord.
|
||||
|
||||
Used by :class:`OpJsonFormatter` to distinguish between built-in
|
||||
LogRecord fields and user-supplied ``extra`` fields.
|
||||
"""
|
||||
ref = logging.makeLogRecord({
|
||||
"msg": "", "args": (), "name": "r",
|
||||
"levelno": 0, "levelname": "",
|
||||
"pathname": "", "filename": "", "module": "", "lineno": 0,
|
||||
"funcName": "", "created": 0, "msecs": 0, "relativeCreated": 0,
|
||||
"thread": 0, "threadName": "", "process": 0, "processName": "",
|
||||
"taskName": "", "exc_info": None, "exc_text": None, "stack_info": None,
|
||||
})
|
||||
# instance attributes + class-level attributes (methods, descriptors, …)
|
||||
return set(ref.__dict__.keys()) | {
|
||||
k for k in logging.LogRecord.__dict__ if not k.startswith("__")
|
||||
}
|
||||
|
||||
|
||||
_STANDARD_KEYS: set[str] = _build_standard_keys()
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Formatters
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class OpJsonFormatter(logging.Formatter):
|
||||
"""Operational log JSON Lines formatter.
|
||||
|
||||
Output::
|
||||
|
||||
{"timestamp": "…", "level": "INFO", "logger": "…", "message": "…", …extra…}
|
||||
|
||||
Fixed fields (always present): ``timestamp``, ``level``, ``logger``, ``message``.
|
||||
Extra fields passed via ``logger.info("msg", extra={…})`` are **auto-expanded**
|
||||
into the JSON object — no need to modify the formatter when adding new fields.
|
||||
"""
|
||||
|
||||
_FIXED_FIELDS = {"timestamp", "level", "logger", "message"}
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
entry: dict[str, Any] = {
|
||||
def format(self, record):
|
||||
log_entry = {
|
||||
"timestamp": self.formatTime(record),
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"message": record.getMessage(),
|
||||
"latency_ms": getattr(record, "latency_ms", None),
|
||||
"tokens": getattr(record, "tokens", None),
|
||||
}
|
||||
# Auto-expand extra fields — keep any attribute that is NOT a
|
||||
# standard LogRecord field and NOT one of the fixed keys above.
|
||||
for key in record.__dict__:
|
||||
if key in self._FIXED_FIELDS:
|
||||
continue
|
||||
if key.startswith("_"):
|
||||
continue
|
||||
if key in _STANDARD_KEYS:
|
||||
continue
|
||||
entry[key] = record.__dict__[key]
|
||||
return json.dumps(entry, ensure_ascii=False, default=str)
|
||||
return json.dumps(log_entry, ensure_ascii=False)
|
||||
|
||||
|
||||
class PerfJsonFormatter(OpJsonFormatter):
|
||||
"""Performance log JSON Lines formatter.
|
||||
def setup_logger(name: str = "algonotes", *, console: bool = False) -> logging.Logger:
|
||||
"""Set up and return a configured logger.
|
||||
|
||||
Extends :class:`OpJsonFormatter` by injecting ``trace_id`` from
|
||||
contextvars (LangSmith run ID) onto every record before formatting.
|
||||
Always creates a file handler (JSON Lines, DEBUG level, with rotation).
|
||||
Optionally creates a stderr handler (human-readable plain text, INFO level).
|
||||
|
||||
If the record already has a ``trace_id`` (passed explicitly via
|
||||
``extra``), it is left untouched — the caller's value wins.
|
||||
"""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
# Only inject if the caller didn't already provide one via extra=
|
||||
if "trace_id" not in record.__dict__ or record.__dict__.get("trace_id") is None:
|
||||
tid = _get_current_trace_id()
|
||||
if tid:
|
||||
record.__dict__["trace_id"] = tid
|
||||
return super().format(record)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Performance logging helper
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def log_perf(
|
||||
call_type: str,
|
||||
latency_ms: float,
|
||||
*,
|
||||
model: str | None = None,
|
||||
doc_count: int | None = None,
|
||||
chunk_count: int | None = None,
|
||||
token_count: int | None = None,
|
||||
success: bool = True,
|
||||
error: str | None = None,
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Write a performance log entry to ``logs/perf.log``.
|
||||
|
||||
All keyword arguments except ``extra`` are type-checked — this
|
||||
prevents typos in field names that a raw ``logger.info(…, extra={…})``
|
||||
would silently accept.
|
||||
Configures the root ``algonotes`` logger — safe to call multiple times;
|
||||
subsequent calls are no-ops.
|
||||
|
||||
Args:
|
||||
call_type: Logical operation name (``"reranker"``, ``"pipeline"``, …).
|
||||
latency_ms: Elapsed wall-clock time in milliseconds.
|
||||
model: Optional model name / identifier.
|
||||
doc_count: Number of documents processed.
|
||||
chunk_count: Number of chunks produced or consumed.
|
||||
token_count: Total tokens consumed (prompt + completion).
|
||||
success: ``False`` when the call raised an exception.
|
||||
error: Exception message when ``success=False``.
|
||||
extra: Arbitrary additional fields merged into the log entry.
|
||||
"""
|
||||
perf_logger = logging.getLogger("algonotes.perf")
|
||||
|
||||
perf_data: dict[str, Any] = {
|
||||
"call_type": call_type,
|
||||
"latency_ms": round(latency_ms, 2),
|
||||
"model": model,
|
||||
"doc_count": doc_count,
|
||||
"chunk_count": chunk_count,
|
||||
"token_count": token_count,
|
||||
"success": success,
|
||||
"error": error,
|
||||
}
|
||||
if extra:
|
||||
perf_data.update(extra)
|
||||
|
||||
# Strip None values to keep log lines compact
|
||||
perf_data = {k: v for k, v in perf_data.items() if v is not None}
|
||||
|
||||
perf_logger.info(
|
||||
f"{call_type} completed in {latency_ms:.1f}ms", extra=perf_data
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Logger setup — dual-channel
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def setup_logger(
|
||||
name: str = "algonotes", *, console: bool = False
|
||||
) -> logging.Logger:
|
||||
"""Initialize the dual-channel logging system.
|
||||
|
||||
**Channel isolation** (the key design decision)::
|
||||
|
||||
algonotes (root) ── op_handler ──→ logs/app.log
|
||||
│
|
||||
├── algonotes.api.* (propagates ↑ to root → app.log)
|
||||
├── algonotes.ingestion.* (propagates ↑ to root → app.log)
|
||||
├── …
|
||||
│
|
||||
└── algonotes.perf ── perf_handler ──→ logs/perf.log
|
||||
(propagate=False — isolated from root, never reaches app.log)
|
||||
|
||||
Because ``algonotes.perf`` sets ``propagate = False``, operational logs
|
||||
never leak into ``perf.log``, and perf logs never leak into ``app.log``.
|
||||
|
||||
Handlers created:
|
||||
|
||||
1. **Operational log handler** → ``logs/app.log``
|
||||
- Attached to root ``algonotes``
|
||||
- Formatter: :class:`OpJsonFormatter`
|
||||
- Level: ``DEBUG`` (filtered by ``[logging].level``)
|
||||
|
||||
2. **Performance log handler** → ``logs/perf.log``
|
||||
- Attached to ``algonotes.perf`` (isolated, ``propagate = False``)
|
||||
- Formatter: :class:`PerfJsonFormatter` (auto-injects ``trace_id``)
|
||||
- Level: ``INFO``
|
||||
- Skipped when ``perf_logging.enabled = false``
|
||||
|
||||
3. **Console handler** (optional) → stderr
|
||||
- Attached to root ``algonotes``
|
||||
- Formatter: plain text
|
||||
- Created only when ``console=True``
|
||||
|
||||
Safe to call multiple times — subsequent calls are no-ops.
|
||||
|
||||
Args:
|
||||
name: Logger name to return (child of root ``algonotes``).
|
||||
console: When ``True``, also emit plain-text logs to stderr.
|
||||
name: Logger name, defaults to ``algonotes``.
|
||||
console: When True, additionally emit human-readable logs to stderr.
|
||||
|
||||
Returns:
|
||||
The named child logger.
|
||||
The named child logger of the ``algonotes`` root.
|
||||
"""
|
||||
root = logging.getLogger("algonotes")
|
||||
if root.handlers:
|
||||
return logging.getLogger(name)
|
||||
|
||||
log_cfg = config.logging
|
||||
|
||||
root.setLevel(getattr(logging, log_cfg.level.upper(), logging.INFO))
|
||||
|
||||
# ── Handler 1: Operational logs (root) ──────────────
|
||||
op_path = Path(log_cfg.file)
|
||||
op_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# ensure log directory exists
|
||||
log_path = Path(log_cfg.file)
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
op_handler = RotatingFileHandler(
|
||||
# Handler 1: file (machine-readable, JSON Lines)
|
||||
file_handler = RotatingFileHandler(
|
||||
log_cfg.file,
|
||||
maxBytes=log_cfg.max_bytes,
|
||||
backupCount=log_cfg.backup_count,
|
||||
encoding="utf-8",
|
||||
)
|
||||
op_handler.setLevel(logging.DEBUG)
|
||||
op_handler.setFormatter(OpJsonFormatter())
|
||||
root.addHandler(op_handler)
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
file_handler.setFormatter(JsonFormatter())
|
||||
root.addHandler(file_handler)
|
||||
|
||||
# ── Handler 2: Performance logs (isolated) ──────────
|
||||
# Attached to algonotes.perf, NOT the root — this prevents
|
||||
# operational logs from polluting perf.log and vice versa.
|
||||
perf_cfg = config.perf_logging
|
||||
if perf_cfg.enabled:
|
||||
perf_path = Path(perf_cfg.file)
|
||||
perf_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
perf_handler = RotatingFileHandler(
|
||||
perf_cfg.file,
|
||||
maxBytes=perf_cfg.max_bytes,
|
||||
backupCount=perf_cfg.backup_count,
|
||||
encoding="utf-8",
|
||||
)
|
||||
perf_handler.setLevel(logging.INFO)
|
||||
perf_handler.setFormatter(PerfJsonFormatter())
|
||||
|
||||
perf_logger = logging.getLogger("algonotes.perf")
|
||||
perf_logger.propagate = False # isolate: no leakage to root (app.log)
|
||||
perf_logger.addHandler(perf_handler)
|
||||
|
||||
# ── Handler 3: Console (stderr, plain text) ─────────
|
||||
# Handler 2: stderr (human-friendly, plain text) — only when requested
|
||||
if console:
|
||||
stderr = logging.StreamHandler(sys.stderr)
|
||||
stderr.setLevel(logging.INFO)
|
||||
stderr.setFormatter(
|
||||
logging.Formatter(
|
||||
"%(asctime)s - %(levelname)s - %(name)s - %(message)s"
|
||||
)
|
||||
)
|
||||
stderr.setFormatter(logging.Formatter(
|
||||
"%(asctime)s - %(levelname)s - %(name)s - %(message)s"
|
||||
))
|
||||
root.addHandler(stderr)
|
||||
|
||||
# ── Silence noisy third-party loggers ───────────────
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("chromadb").setLevel(logging.WARNING)
|
||||
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
||||
|
||||
return logging.getLogger(name)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# src/mcp/server.py
|
||||
# MCP server for AlgoNotes RAG.
|
||||
#
|
||||
# Exposes ingest/update/delete/search/ask tools via SSE transport.
|
||||
# Exposes ingest/update/delete/search tools via SSE transport.
|
||||
# Usage: algonotes mcp --port 8000
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
|
@ -18,18 +18,32 @@ from src.mcp.tools import (
|
|||
update_tool,
|
||||
)
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Tool wrapper functions (plain — decorated at server creation)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
_MCP_INSTANCE: FastMCP | None = None
|
||||
|
||||
|
||||
def _ingest(
|
||||
def get_mcp(host: str = "127.0.0.1", port: int = 8000) -> FastMCP:
|
||||
"""Return the singleton FastMCP instance, creating it if needed."""
|
||||
global _MCP_INSTANCE
|
||||
if _MCP_INSTANCE is None:
|
||||
_MCP_INSTANCE = FastMCP(
|
||||
"algonotes",
|
||||
instructions=(
|
||||
"AlgoNotes RAG MCP server. Manage algorithm competition notes "
|
||||
"(OI/ICPC) via ingest, update, delete, and search tools."
|
||||
),
|
||||
host=host,
|
||||
port=port,
|
||||
)
|
||||
return _MCP_INSTANCE
|
||||
|
||||
|
||||
@get_mcp().tool()
|
||||
def ingest(
|
||||
path: str | None = None,
|
||||
url: str | None = None,
|
||||
tag: bool = True,
|
||||
type: str = "note",
|
||||
author: str | None = None,
|
||||
filename: str | None = None,
|
||||
) -> dict:
|
||||
"""Import notes into three-layer storage.
|
||||
|
||||
|
|
@ -42,13 +56,13 @@ def _ingest(
|
|||
tag: Extract tags via LLM (default: true).
|
||||
type: Note type (note/solution/template, default: note).
|
||||
author: Note author.
|
||||
filename: Custom filename for the imported note (optional).
|
||||
"""
|
||||
return ingest_tool(path=path, url=url, tag=tag,
|
||||
type=type, author=author, filename=filename)
|
||||
type=type, author=author)
|
||||
|
||||
|
||||
def _update(
|
||||
@get_mcp().tool()
|
||||
def update(
|
||||
filename: str,
|
||||
file_path: str | None = None,
|
||||
tag: bool = True,
|
||||
|
|
@ -66,7 +80,8 @@ def _update(
|
|||
return update_tool(filename=filename, file_path=file_path, tag=tag)
|
||||
|
||||
|
||||
def _delete(filename: str) -> dict:
|
||||
@get_mcp().tool()
|
||||
def delete(filename: str) -> dict:
|
||||
"""Delete a note from all three storage layers.
|
||||
|
||||
Deletes in order: VectorStore, FileStore, SQLStore.
|
||||
|
|
@ -77,7 +92,8 @@ def _delete(filename: str) -> dict:
|
|||
return delete_tool(filename=filename)
|
||||
|
||||
|
||||
def _show(filename: str, lines: int | None = None) -> dict:
|
||||
@get_mcp().tool()
|
||||
def show(filename: str, lines: int | None = None) -> dict:
|
||||
"""Read the raw content of a note file.
|
||||
|
||||
Args:
|
||||
|
|
@ -87,7 +103,8 @@ def _show(filename: str, lines: int | None = None) -> dict:
|
|||
return show_tool(filename=filename, lines=lines)
|
||||
|
||||
|
||||
def _list(tag: str | None = None) -> dict:
|
||||
@get_mcp().tool()
|
||||
def list(tag: str | None = None) -> dict:
|
||||
"""List all notes, optionally filtered by tag.
|
||||
|
||||
Args:
|
||||
|
|
@ -96,7 +113,8 @@ def _list(tag: str | None = None) -> dict:
|
|||
return list_tool(tag=tag)
|
||||
|
||||
|
||||
def _search(query: str, top_k: int = 5) -> dict:
|
||||
@get_mcp().tool()
|
||||
def search(query: str, top_k: int = 5) -> dict:
|
||||
"""Semantic search across all notes.
|
||||
|
||||
Args:
|
||||
|
|
@ -106,7 +124,8 @@ def _search(query: str, top_k: int = 5) -> dict:
|
|||
return search_tool(query=query, top_k=top_k)
|
||||
|
||||
|
||||
def _metadata(
|
||||
@get_mcp().tool()
|
||||
def metadata(
|
||||
filename: str,
|
||||
title: str | None = None,
|
||||
tags: str | None = None,
|
||||
|
|
@ -126,7 +145,8 @@ def _metadata(
|
|||
author=author, type=type)
|
||||
|
||||
|
||||
def _export(
|
||||
@get_mcp().tool()
|
||||
def export(
|
||||
filename: str | None = None,
|
||||
all: bool = False,
|
||||
output_dir: str = "./export",
|
||||
|
|
@ -143,7 +163,8 @@ def _export(
|
|||
return export_tool(filename=filename, all=all, output_dir=output_dir)
|
||||
|
||||
|
||||
def _ask(
|
||||
@get_mcp().tool()
|
||||
def ask(
|
||||
question: str,
|
||||
stream: bool = False,
|
||||
) -> dict:
|
||||
|
|
@ -158,40 +179,6 @@ def _ask(
|
|||
return ask_tool(question=question, stream=stream)
|
||||
|
||||
|
||||
#: All tool wrapper functions, registered in order when the server is created.
|
||||
_TOOLS = [
|
||||
_ingest,
|
||||
_update,
|
||||
_delete,
|
||||
_show,
|
||||
_list,
|
||||
_search,
|
||||
_metadata,
|
||||
_export,
|
||||
_ask,
|
||||
]
|
||||
|
||||
|
||||
def get_mcp(host: str = "127.0.0.1", port: int = 8000) -> FastMCP:
|
||||
"""Create a FastMCP instance bound to *host*:*port* with all tools registered.
|
||||
|
||||
Unlike the previous singleton, every call creates a fresh instance so
|
||||
``--host`` / ``--port`` are always honoured.
|
||||
"""
|
||||
mcp = FastMCP(
|
||||
"algonotes",
|
||||
instructions=(
|
||||
"AlgoNotes RAG MCP server. Manage algorithm competition notes "
|
||||
"(OI/ICPC) via ingest, update, delete, and search tools."
|
||||
),
|
||||
host=host,
|
||||
port=port,
|
||||
)
|
||||
for tool_func in _TOOLS:
|
||||
mcp.tool()(tool_func)
|
||||
return mcp
|
||||
|
||||
|
||||
def main(host: str = "127.0.0.1", port: int = 8000):
|
||||
"""Run the MCP server with SSE transport (HTTP).
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ def ingest_tool(
|
|||
tag: bool = True,
|
||||
type: str = "note",
|
||||
author: str | None = None,
|
||||
filename: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if not path and not url:
|
||||
return {"success": False, "error": "Must provide either path or url"}
|
||||
|
|
@ -28,7 +27,7 @@ def ingest_tool(
|
|||
try:
|
||||
if url:
|
||||
result = ingest_web(url, tag=tag, verbose=False,
|
||||
type=type, author=author, filename=filename)
|
||||
type=type, author=author)
|
||||
return {
|
||||
"file_name": result.file_name,
|
||||
"chunk_count": result.chunk_count,
|
||||
|
|
@ -40,7 +39,7 @@ def ingest_tool(
|
|||
file_path = Path(path)
|
||||
if file_path.is_file():
|
||||
result = ingest_local(file_path, tag=tag, verbose=False,
|
||||
type=type, author=author, filename=filename)
|
||||
type=type, author=author)
|
||||
return {
|
||||
"file_name": result.file_name,
|
||||
"chunk_count": result.chunk_count,
|
||||
|
|
|
|||
|
|
@ -1,112 +0,0 @@
|
|||
# src/performance.py
|
||||
# Performance timer for AlgoNotes RAG.
|
||||
#
|
||||
# Provides :class:`PerfTimer` — a context manager + decorator that measures
|
||||
# elapsed wall-clock time and writes a structured performance log entry via
|
||||
# :func:`~src.logger.log_perf` on exit.
|
||||
#
|
||||
# Usage (context manager)::
|
||||
#
|
||||
# with PerfTimer("reranker", model="Qwen3-Reranker-4B", doc_count=10) as t:
|
||||
# results = reranker.rerank(query, docs)
|
||||
# t.set_extra("score_range", {"min": 0.32, "max": 0.95})
|
||||
#
|
||||
# Usage (decorator)::
|
||||
#
|
||||
# @PerfTimer("pipeline")
|
||||
# def ask_question(question: str) -> str:
|
||||
# ...
|
||||
|
||||
import time
|
||||
from contextlib import ContextDecorator
|
||||
from typing import Any, Literal
|
||||
|
||||
from src.logger import log_perf
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# CallType — constrained literal for call_type values
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
CallType = Literal[
|
||||
"reranker", # Gitee.AI /v1/rerank call (raw httpx)
|
||||
"pipeline", # end-to-end RAG ask / ingest pipeline
|
||||
"ingest", # single-note ingestion (load → split → tag → store)
|
||||
"tag_extraction", # LLM tag extraction (tagger.py)
|
||||
"text_cleaning", # LLM text cleaning (cleaner.py)
|
||||
]
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# PerfTimer
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class PerfTimer(ContextDecorator):
|
||||
"""Context manager + decorator for performance measurement.
|
||||
|
||||
On exit, automatically calls :func:`log_perf` with the elapsed time
|
||||
and all metadata supplied at construction time (plus any fields added
|
||||
via :meth:`set_extra` during the block).
|
||||
|
||||
Does **not** swallow exceptions — they propagate normally after the
|
||||
performance entry is written with ``success=False``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
call_type: CallType,
|
||||
*,
|
||||
model: str | None = None,
|
||||
doc_count: int | None = None,
|
||||
chunk_count: int | None = None,
|
||||
token_count: int | None = None,
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
self.call_type: str = call_type
|
||||
self.model: str | None = model
|
||||
self.doc_count: int | None = doc_count
|
||||
self.chunk_count: int | None = chunk_count
|
||||
self.token_count: int | None = token_count
|
||||
self.extra: dict[str, Any] = extra or {}
|
||||
self._start: float | None = None
|
||||
|
||||
# ── public API ──────────────────────────────────────
|
||||
|
||||
def set_extra(self, key: str, value: Any) -> None:
|
||||
"""Append a metric inside the context block.
|
||||
|
||||
Useful for data that is only known after the operation completes,
|
||||
e.g. a reranker's score distribution or a pipeline's finish_reason.
|
||||
"""
|
||||
self.extra[key] = value
|
||||
|
||||
@property
|
||||
def elapsed_ms(self) -> float:
|
||||
"""Elapsed time in milliseconds (readable inside the context block)."""
|
||||
if self._start is None:
|
||||
return 0.0
|
||||
return (time.perf_counter() - self._start) * 1000
|
||||
|
||||
# ── context-manager protocol ────────────────────────
|
||||
|
||||
def __enter__(self) -> "PerfTimer":
|
||||
self._start = time.perf_counter()
|
||||
return self # allows "with PerfTimer(...) as t: t.set_extra(...)"
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
|
||||
elapsed = self.elapsed_ms
|
||||
success = exc_type is None
|
||||
|
||||
log_perf(
|
||||
call_type=self.call_type,
|
||||
latency_ms=elapsed,
|
||||
model=self.model,
|
||||
doc_count=self.doc_count,
|
||||
chunk_count=self.chunk_count,
|
||||
token_count=self.token_count,
|
||||
success=success,
|
||||
error=str(exc_val) if exc_val else None,
|
||||
extra=self.extra if self.extra else None,
|
||||
)
|
||||
|
||||
return False # don't swallow exceptions — let them propagate
|
||||
Loading…
Reference in New Issue