Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 174 additions & 0 deletions api/ops/intent_router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
"""Ops Chat LLM intent router(P1-4)。

基于轻量 LLM JSON 输出的混合意图分类器:
- 环境变量 `OPS_CHAT_LLM_ROUTER=1` 时优先调用 LLM router。
- LLM 输出必须含 `intent`、`slots`、`confidence`(0~1)。
- 低置信度或非法 JSON / LLM 异常时降级为规则分类器,并记录
`intent_router.fallback` 事件到 `ops_run_events`。
- 默认未开启时行为与原有规则分类器完全一致。
"""

from __future__ import annotations

import json
import logging
import os
import re
import time
from collections.abc import Callable
from typing import Any

from api.ops.llm import chat_completion

logger = logging.getLogger(__name__)

# 与 api.ops.orchestrator.core.Intent 保持一致(避免循环导入)
_ALLOWED_INTENTS = {
"metrics_trend",
"issue_list",
"pr_list",
"issue_contribution",
"graph_module",
"scan_status",
"demo",
"fallback",
}

_MIN_CONFIDENCE = float(os.getenv("OPS_CHAT_LLM_ROUTER_MIN_CONFIDENCE") or "0.7")


def _is_enabled() -> bool:
return os.getenv("OPS_CHAT_LLM_ROUTER", "") == "1"


def _build_prompt(message: str) -> str:
return (
"你是 Ops Chat 意图分类助手。根据用户输入,输出严格 JSON(不要 markdown 代码块,不要解释):\n"
"{\n"
' "intent": "以下之一:metrics_trend, issue_list, pr_list, issue_contribution, '
"graph_module, scan_status, demo, fallback\",\n"
' "slots": {},\n'
' "confidence": 0.0\n'
"}\n"
"slots 可包含 issue_number、days、metric 等键;没有时填 {}。\n"
"confidence 为 0~1 的浮点数,表示分类置信度。\n\n"
f"用户输入:{message}"
)


def _extract_json_obj(content: str) -> dict[str, Any]:
"""从 LLM 输出中提取 JSON 对象;失败时抛出异常。"""
try:
data = json.loads(content)
except json.JSONDecodeError:
match = re.search(r"\{.*\}", content, re.S)
if not match:
raise ValueError("No JSON object found in LLM response") from None
data = json.loads(match.group(0))
if not isinstance(data, dict):
raise ValueError("LLM response JSON is not an object")
return data


def _normalize_intent(value: Any) -> str:
"""规范化 intent 为允许值之一,未知则 fallback。"""
if not isinstance(value, str):
return "fallback"
intent = value.strip().lower()
if intent in _ALLOWED_INTENTS:
return intent
return "fallback"


def _clamp_confidence(value: Any) -> float:
"""将 confidence 限制在 [0, 1]。"""
try:
confidence = float(value) if value is not None else 0.0
except (TypeError, ValueError):
confidence = 0.0
if not 0.0 <= confidence <= 1.0:
confidence = 0.0
return confidence


def _record_fallback_event(
run_id: str | None,
store: Any,
reason: str,
detail: str,
) -> None:
"""记录 intent_router.fallback 事件;store 未提供时仅记录日志。"""
if not run_id:
return
try:
from api.ops.store.runs import append_event

append_event(
run_id,
"intent_router.fallback",
{"reason": reason, "detail": detail},
store=store,
)
except Exception: # pragma: no cover - 防御性降级
logger.exception("intent_router.fallback event write failed")


def llm_classify_intent(message: str) -> tuple[str, dict[str, Any], float]:
"""直接调用 LLM 分类意图。

返回 (intent, slots, confidence)。失败时抛出异常,由调用方降级。
"""
prompt = _build_prompt(message)
start = time.perf_counter()
try:
result = chat_completion(
[{"role": "user", "content": prompt}],
step="intent_router",
temperature=0.1,
)
except Exception as exc:
latency_ms = (time.perf_counter() - start) * 1000
logger.warning("router.latency: %.2f ms; LLM failed: %s", latency_ms, exc)
raise

latency_ms = (time.perf_counter() - start) * 1000
logger.info("router.latency: %.2f ms", latency_ms)

data = _extract_json_obj(result.content)
intent = _normalize_intent(data.get("intent"))
slots = data.get("slots") or {}
if not isinstance(slots, dict):
slots = {}
confidence = _clamp_confidence(data.get("confidence"))
return intent, slots, confidence


def classify_intent_with_llm(
message: str,
fallback_fn: Callable[[str], tuple[str, dict[str, Any]]],
*,
run_id: str | None = None,
store: Any = None,
) -> tuple[str, dict[str, Any]]:
"""混合意图分类器。

- 当 `OPS_CHAT_LLM_ROUTER=1` 时优先调用 LLM router。
- LLM 低置信度或异常时调用 `fallback_fn` 并记录事件。
- 默认关闭时直接返回 `fallback_fn(message)`,保持向后兼容。
"""
if not _is_enabled():
return fallback_fn(message)

try:
intent, slots, confidence = llm_classify_intent(message)
except Exception as exc:
result = fallback_fn(message)
_record_fallback_event(run_id, store, "llm_error", str(exc))
return result

if confidence < _MIN_CONFIDENCE:
result = fallback_fn(message)
_record_fallback_event(run_id, store, "low_confidence", f"confidence={confidence:.3f}")
return result

return intent, slots
21 changes: 20 additions & 1 deletion api/ops/orchestrator/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import re
from typing import Any

from api.ops import intent_router as _intent_router
from api.ops.agents.graph_analyst import analyze_graph
from api.ops.agents.issue_analyst import analyze_issue
from api.ops.agents.scan_analyst import analyze_scan
Expand All @@ -31,7 +32,7 @@ class Intent:
FALLBACK = "fallback"


def classify_intent(message: str) -> tuple[str, dict[str, Any]]:
def _rule_classify_intent(message: str) -> tuple[str, dict[str, Any]]:
"""基于规则快速分类;返回 (intent, slots)。"""
msg = message.lower().strip()
slots: dict[str, Any] = {}
Expand Down Expand Up @@ -114,6 +115,24 @@ def classify_intent(message: str) -> tuple[str, dict[str, Any]]:
return Intent.FALLBACK, {}


def classify_intent(
message: str,
run_id: str | None = None,
store: Any | None = None,
) -> tuple[str, dict[str, Any]]:
"""混合意图分类:默认规则;`OPS_CHAT_LLM_ROUTER=1` 时优先 LLM router。

当 LLM router 低置信度或异常时,自动降级为 `_rule_classify_intent`,
并通过 `append_event` 记录 `intent_router.fallback` 事件(提供 run_id/store 时)。
"""
return _intent_router.classify_intent_with_llm(
message,
_rule_classify_intent,
run_id=run_id,
store=store,
)


def is_fast_intent(intent: str) -> bool:
return intent in (Intent.METRICS_TREND, Intent.ISSUE_LIST, Intent.PR_LIST, Intent.DEMO)

Expand Down
1 change: 1 addition & 0 deletions docs/_tech_graph/02_version.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,5 +56,6 @@ timeline
2026-07-07 : dd89b870 auto: api/agently_lab/__init__.py
2026-07-08 : 4bf5782c auto: api/ops/orchestrator/__init__.py
2026-07-09 : db09fd40 auto: api/ops/events_schema.py
2026-07-10 : 13553deb auto: api/ops/intent_router.py
```

Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Invoke Snapshot · 30-execute-code · ops-chat-session-sink-p0-p1 · P1-4

| 字段 | 值 |
| --- | --- |
| **hat** | 30-execute-code |
| **task** | docs/harness/tasks/active/task_ops_chat_session_sink_p0_p1_v1.md |
| **subproject** | ai-ink-brain-api-python |
| **branch** | task/ops-chat-session-sink-p0-p1 |
| **scope** | P1-4 LLM Router:`OPS_CHAT_LLM_ROUTER` · JSON intent · 规则 fallback |
| **timestamp** | 2026-07-10 09:29 |
| **verify_command** | `pytest tests/ops tests/ops_desk -m "not intent_eval and not intent_benchmark" -q && ruff check api/ops` |
| **human_gate** | `HG-TASK-DRAFT`: approved (task §行为变更 · human_gate) · `HG-AUDIT-R1`: approved (task §行为变更 · human_gate) |

## 用户消息快照

```text
你正在扮演工作区 Harness「30-execute-code · 执行编码帽」,严格遵循 docs/harness/prompts/30-execute-code.md。

**输入(已替换占位符)**
- 主 task 路径(相对 Projects/):`docs/harness/tasks/active/task_ops_chat_session_sink_p0_p1_v1.md`
- 逻辑子仓(相对 Projects/):`ai-ink-brain-api-python`
- Worktree 研发目录(所有 git/pytest/ruff 默认 cwd):`ai-ink-brain-api-python`
- 当前分支:`task/ops-chat-session-sink-p0-p1`(已基于 main fast-forward,包含 P1-3 merge)
- 合并前须跑通的验证命令:
```bash
pytest tests/ops tests/ops_desk -m "not intent_eval and not intent_benchmark" -q && ruff check api/ops
```
- 关联任务审核书面结论路径:`ai-ink-brain-api-python/docs/harness/reviews/task_ops_chat_session_sink_p0_p1_v1_audit_R2_20260708.md`
- 关联 PLAN / 总规:`docs/harness/guides/PLAN_ops_chat_session_sink_p0_p1_v1_zh.md`
- 关联结构化输出缺口矩阵:`docs/harness/guides/RUNTIME_structured_output_gap_matrix_v0_zh.md`

**本棒目标:P1-4 LLM Router:`OPS_CHAT_LLM_ROUTER` · JSON intent · 规则 fallback**

P1-4 具体要求(来自 PLAN §2、§3.1 D9、task §失败路径、§实现备忘):
- 新增 `api/ops/intent_router.py`:实现基于 LLM 的轻量 JSON intent 路由器。
- 当环境变量 `OPS_CHAT_LLM_ROUTER=1` 时,`api/ops/orchestrator/core.py` 中的 `classify_intent` 优先调用 LLM router。
- LLM router 输出 JSON:至少含 `intent`(字符串)、`slots`(对象)、`confidence`(float,0~1)。
- 低置信度或非法 JSON 时降级为原有规则 `classify_intent`(即 task §失败路径的 `intent_router.fallback` event / `router.latency` 日志)。
- 默认 `OPS_CHAT_LLM_ROUTER` 未开启或未设置时,行为与之前完全一致(向后兼容)。
- 记录 `intent_router.fallback` event 到 `ops_run_events`(可复用 P0-2 `append_event`)。

**范围限制**
- 只做 P1-4;不改 P1-1 artifact、P1-2 checkpoint、P1-3 clarify 已交付行为
- 不改 `harness_runtime` 生产图
- 不改 Agently lab
- 不改前端代码

**test_strategy: required**
- 先写/调整可失败的自动化测试,再改实现
- 新增 `tests/ops/test_intent_router.py` 覆盖:
- `OPS_CHAT_LLM_ROUTER=1` 时 LLM router 返回合法 JSON intent
- 低置信度时降级规则 fallback
- LLM 超时/非法 JSON 时降级规则 fallback(对应 task §失败路径)
- 默认未开启时走原有规则
- 1 个集成测:通过 `classify_intent` 走 LLM router
- 最终验证命令必须绿

**失败路径硬性检查**
- task §失败路径已列 `LLM router 超时/非法 JSON`:行为 = 降级 `classify_intent` 规则;可观测 = `intent_router.fallback` event / `router.latency` 日志;可重试 = 否;验证命令 = `pytest tests/ops/test_intent_router.py -k fallback`

**你必须完成**
0. **Invoke 快照(开帽起点)**:将本用户消息全文落盘到 `ai-ink-brain-api-python/docs/harness/invokes/by-task/ops-chat-session-sink-p0-p1/invoke_YYYYMMDD_HHMM_30_ops_chat_session_sink_p0_p1_P1-4.md`(含元数据表 + 快照 fenced code)。同一会话内追问 **不** 再新增快照文件。
0b. **人工闸**:扫描 task / 关联 reviews 的 human_gate。若任一对本帽(30)为 pending → 仅输出须人改的 gate_id 与路径,拒开工;禁止代填 approved。
1. 通读 task 全文:头部 gates_before_code、audit_profile、orchestration、chain_prompt、test_strategy / test_strategy_note、failure_paths、验收标准、必读列表、非范围。
2. 阅读 PLAN §2、§3.1、`RUNTIME_structured_output_gap_matrix_v0_zh.md` 与关联 SNAPSHOT。
3. 先读现有代码:`api/ops/orchestrator/core.py`(`classify_intent` 与 `Intent` 枚举)、`api/ops/events_schema.py`、`api/ops/chat_service.py`。
4. 先写失败可复现的测试(`tests/ops/test_intent_router.py`),再实现 `api/ops/intent_router.py` 与 `classify_intent` 改造。
5. 执行验证命令,保留可核对输出要点;修复直至通过。
6. 按 40-self-check.md 将结论与命令摘要回填至 task 正文「### 自检结论(执行者)· P1-4」小节(不要覆盖 P0、P1-1、P1-2、P1-3 已有结论)。
7. 对话回复:生成可以完整复制的 Prompt,用于直接交给下一棒 40 自检执行。
8. **自动 commit**:在输出下一棒 Prompt 且本轮代码/测试/task 自检回填已落盘后,按 HANDOFF_AUTO_COMMIT.md 在 ai-ink-brain-api-python/ commit(仅本轮路径;禁止 git add -A;对话报 short-hash)。
9. **禁止**自行 push;由 Lead 合并。

**输出要求**
- 若拒开工:仅 Markdown 阻塞清单
- 若执行:diff 摘要、验证命令输出、commit short-hash、下一棒 40 Prompt

**Judgment(本帽 · 对话末尾必填)**:experience_capture / gate/risk / hat_self
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
---
hat: "40-self-check"
task: "ops-chat-session-sink-p0-p1"
phase: "P1-4"
subproject: "ai-ink-brain-api-python"
branch: "task/ops-chat-session-sink-p0-p1"
worktree_root: "ai-ink-brain-api-python/"
date: "2026-07-10"
time: "09:30"
---

| 字段 | 值 |
| --- | --- |
| **hat** | 40-self-check |
| **task** | ops-chat-session-sink-p0-p1 |
| **phase** | P1-4 LLM Router |
| **subproject** | ai-ink-brain-api-python |
| **branch** | task/ops-chat-session-sink-p0-p1 |
| **worktree_root** | ai-ink-brain-api-python/ |
| **date** | 2026-07-10 |
| **time** | 09:30 |

## 用户消息快照

```text
你正在扮演工作区 Harness「40-self-check · 执行者自检帽」,严格遵循 docs/harness/prompts/40-self-check.md。

**输入(已替换占位符)**
- 主 task 路径(相对 Projects/):`docs/harness/tasks/active/task_ops_chat_session_sink_p0_p1_v1.md`
- 逻辑子仓(相对 Projects/):`ai-ink-brain-api-python`
- Worktree 研发目录(所有 git/pytest/ruff 默认 cwd):`ai-ink-brain-api-python`
- 当前分支:`task/ops-chat-session-sink-p0-p1`(已基于 main fast-forward,包含 P1-3 merge)
- 合并前须跑通的验证命令:
```bash
pytest tests/ops tests/ops_desk -m "not intent_eval and not intent_benchmark" -q && ruff check api/ops
```
- 失败路径验证命令:
```bash
pytest tests/ops/test_intent_router.py -k fallback -q
```
- 上一棒 30 commit:`ai-ink-brain-api-python@760179a5`
- 关联任务审核书面结论路径:`ai-ink-brain-api-python/docs/harness/reviews/task_ops_chat_session_sink_p0_p1_v1_audit_R2_20260708.md`
- 关联 PLAN / 总规:`docs/harness/guides/PLAN_ops_chat_session_sink_p0_p1_v1_zh.md`
- 关联结构化输出缺口矩阵:`docs/harness/guides/RUNTIME_structured_output_gap_matrix_v0_zh.md`

**本棒目标:P1-4 自检复核**

你必须完成:
0. **Invoke 快照(开帽起点)**:将本用户消息全文落盘到 `ai-ink-brain-api-python/docs/harness/invokes/by-task/ops-chat-session-sink-p0-p1/invoke_YYYYMMDD_HHMM_40_ops_chat_session_sink_p0_p1_P1-4.md`(含元数据表 + 快照 fenced code)。同一会话内追问 **不** 再新增快照文件。
0b. **人工闸**:扫描 task / 关联 reviews 的 human_gate。若任一对本帽(40)为 pending → 仅输出须人改的 gate_id 与路径,拒开工;禁止代填 approved。
1. 独立阅读 task 正文「### 自检结论(执行者)· P1-4」小节与上一棒 30 invoke 快照 `ai-ink-brain-api-python/docs/harness/invokes/by-task/ops-chat-session-sink-p0-p1/invoke_20260710_0929_30_ops_chat_session_sink_p0_p1_P1-4.md`。
2. 独立阅读本轮 P1-4 改动代码:
- `api/ops/intent_router.py`
- `api/ops/orchestrator/core.py`(`classify_intent` / `_rule_classify_intent`)
- `tests/ops/test_intent_router.py`
3. 在 `ai-ink-brain-api-python/` 内完整执行 30 声明的验证命令:
```bash
pytest tests/ops tests/ops_desk -m "not intent_eval and not intent_benchmark" -q && ruff check api/ops
```
并单独执行失败路径验证命令:
```bash
pytest tests/ops/test_intent_router.py -k fallback -q
```
4. 通过 `git diff origin/main...HEAD --stat`(在 `ai-ink-brain-api-python` 内)核对全量变更路径,确认未扩 scope 到 P1-1 artifact、P1-2 checkpoint、P1-3 clarify、`harness_runtime` 生产图、Agently lab、前端代码。
5. 按 40-self-check.md 将结论与命令摘要回填至 task 正文「### 自检结论(40 复核)· P1-4」小节(不要覆盖 P0、P1-1、P1-2、P1-3 或 30 已有结论)。
6. 对话回复:生成可以完整复制的 Prompt,用于直接交给下一棒 50 独立复检执行。
7. 自动 commit:在输出下一棒 Prompt 且本轮 task 自检回填已落盘后,按 HANDOFF_AUTO_COMMIT.md 在 `ai-ink-brain-api-python/` commit(仅本轮路径;禁止 git add -A;对话报 short-hash)。
8. **禁止**自行 push;由 Lead 合并。

**输出要求**
- 若拒开工:仅 Markdown 阻塞清单
- 若执行:diff 摘要、验证命令输出、commit short-hash、下一棒 50 Prompt

**Judgment(本帽 · 对话末尾必填)**:experience_capture / gate/risk / hat_self
```
Loading
Loading