|
8 | 8 |
|
9 | 9 | import json |
10 | 10 | import logging |
| 11 | +import re |
11 | 12 | import time |
12 | 13 | from collections.abc import AsyncIterator |
13 | 14 | from typing import Any |
|
44 | 45 | # 向后兼容别名 |
45 | 46 | BackendResponse = VendorResponse |
46 | 47 | NoCompatibleBackendError = NoCompatibleVendorError |
47 | | -from ..compat.canonical import CompatibilityStatus, build_canonical_request |
| 48 | +from ..compat.canonical import ( |
| 49 | + CanonicalPartType, |
| 50 | + CompatibilityStatus, |
| 51 | + build_canonical_request, |
| 52 | +) |
| 53 | +from ..model.compat import CanonicalRequest |
48 | 54 |
|
49 | 55 | logger = logging.getLogger(__name__) |
50 | 56 |
|
| 57 | +_SESSION_TITLE_MAX_LEN = 30 |
| 58 | + |
| 59 | +# Claude Code 注入的"噪声"标签 — 系统级上下文,不应进入 Session 标题。 |
| 60 | +# 这些标签由 CC harness 在首个 user 消息 content 中拼接,高度同质, |
| 61 | +# 直接用作标题会导致跨会话标题无差异化,丧失辨识度。 |
| 62 | +_NOISE_TAG_PATTERN = re.compile( |
| 63 | + r"<(?P<tag>system-reminder|user-preferences|" |
| 64 | + r"local-command-stdout|local-command-stderr|" |
| 65 | + r"bash-input|bash-stdout|bash-stderr|" |
| 66 | + r"ide_selection|stdin|system_instruction)\b[^>]*>" |
| 67 | + r".*?</(?P=tag)>", |
| 68 | + flags=re.DOTALL | re.IGNORECASE, |
| 69 | +) |
| 70 | + |
| 71 | +# Slash command 子标签:用于识别 /commit、/review 等命令式调用, |
| 72 | +# 合成"命令 + 参数"式标题。 |
| 73 | +_CMD_NAME_PATTERN = re.compile(r"<command-name>(.*?)</command-name>", flags=re.DOTALL) |
| 74 | +_CMD_ARGS_PATTERN = re.compile(r"<command-args>(.*?)</command-args>", flags=re.DOTALL) |
| 75 | +# 残留 command-* 包裹标签清除(command-message/command-stdout 等次要标签)。 |
| 76 | +_CMD_WRAPPER_PATTERN = re.compile( |
| 77 | + r"<command-[\w-]+>.*?</command-[\w-]+>", flags=re.DOTALL |
| 78 | +) |
| 79 | + |
| 80 | + |
| 81 | +def _sanitize_user_text(raw: str) -> str: |
| 82 | + """剔除 Claude Code 注入的系统级 XML 块,还原真实用户输入。 |
| 83 | +
|
| 84 | + 处理顺序: |
| 85 | + 1. Slash command 优先识别 — 若检测到 <command-name>,合成"命令 + 参数" |
| 86 | + 式标题(因为残留文本通常为空,直接取标签内容更有意义)。 |
| 87 | + 2. 通用噪声剥离 — 移除已知白名单内的 system-reminder 等标签。 |
| 88 | + 3. 残留 command-* 包裹清除 — 兜底去除 command-message 等次要标签。 |
| 89 | + 4. 前后空白归一化 — 折叠连续空白为单空格,便于 30 字截断。 |
| 90 | + """ |
| 91 | + if not raw: |
| 92 | + return "" |
| 93 | + |
| 94 | + # 阶段一: slash command 短路 |
| 95 | + cmd = _CMD_NAME_PATTERN.search(raw) |
| 96 | + if cmd: |
| 97 | + name = cmd.group(1).strip() |
| 98 | + args_match = _CMD_ARGS_PATTERN.search(raw) |
| 99 | + args = args_match.group(1).strip() if args_match else "" |
| 100 | + composed = f"{name} {args}".strip() if args else name |
| 101 | + if composed: |
| 102 | + return composed |
| 103 | + |
| 104 | + # 阶段二: 通用噪声剥离 |
| 105 | + cleaned = _NOISE_TAG_PATTERN.sub("", raw) |
| 106 | + cleaned = _CMD_WRAPPER_PATTERN.sub("", cleaned) |
| 107 | + |
| 108 | + # 阶段三: 空白折叠 |
| 109 | + return re.sub(r"\s+", " ", cleaned).strip() |
| 110 | + |
| 111 | + |
| 112 | +def _extract_session_title(request: CanonicalRequest) -> str: |
| 113 | + """从规范化请求中提取首个用户消息文本作为 session 标题。 |
| 114 | +
|
| 115 | + 跳过 Claude Code 注入的系统级 XML 块(system-reminder、user-preferences 等), |
| 116 | + 确保标题反映用户真实输入而非高同质化的系统模板。 |
| 117 | + """ |
| 118 | + for part in request.messages: |
| 119 | + if part.role != "user" or part.type != CanonicalPartType.TEXT: |
| 120 | + continue |
| 121 | + cleaned = _sanitize_user_text(part.text) |
| 122 | + if cleaned: |
| 123 | + return cleaned[:_SESSION_TITLE_MAX_LEN] |
| 124 | + return "" |
| 125 | + |
| 126 | + |
| 127 | +def _build_semantic_rejection_diagnostic(body: dict[str, Any]) -> str: |
| 128 | + """构建语义拒绝的请求体诊断上下文. |
| 129 | +
|
| 130 | + 在 semantic rejection 日志中附加请求体的可疑参数快照, |
| 131 | + 用于定位供应商参数校验失败的具体祸根参数。 |
| 132 | + """ |
| 133 | + parts: list[str] = [] |
| 134 | + # 顶层不兼容参数 |
| 135 | + for key in ("thinking", "extended_thinking", "reasoning_effort"): |
| 136 | + if key in body: |
| 137 | + val = body[key] |
| 138 | + parts.append(f"{key}={val!r:.80}") |
| 139 | + # 会话历史中的 thinking blocks |
| 140 | + thinking_count = 0 |
| 141 | + for msg in body.get("messages", []): |
| 142 | + content = msg.get("content") |
| 143 | + if not isinstance(content, list): |
| 144 | + continue |
| 145 | + for block in content: |
| 146 | + if isinstance(block, dict) and block.get("type") in ( |
| 147 | + "thinking", |
| 148 | + "redacted_thinking", |
| 149 | + ): |
| 150 | + thinking_count += 1 |
| 151 | + if thinking_count: |
| 152 | + parts.append(f"thinking_blocks_in_history={thinking_count}") |
| 153 | + # cache_control 存在检测 |
| 154 | + has_cc = False |
| 155 | + for section in ( |
| 156 | + body.get("system", []) if isinstance(body.get("system"), list) else [], |
| 157 | + *( |
| 158 | + m.get("content", []) |
| 159 | + for m in body.get("messages", []) |
| 160 | + if isinstance(m.get("content"), list) |
| 161 | + ), |
| 162 | + body.get("tools", []), |
| 163 | + ): |
| 164 | + if isinstance(section, list): |
| 165 | + for item in section: |
| 166 | + if isinstance(item, dict) and "cache_control" in item: |
| 167 | + has_cc = True |
| 168 | + break |
| 169 | + if has_cc: |
| 170 | + break |
| 171 | + if has_cc: |
| 172 | + parts.append("cache_control_fields=present") |
| 173 | + # 模型 + 消息数 |
| 174 | + parts.append(f"model={body.get('model', 'N/A')}") |
| 175 | + parts.append(f"messages={len(body.get('messages', []))}") |
| 176 | + return f" [{', '.join(parts)}]" if parts else "" |
| 177 | + |
51 | 178 |
|
52 | 179 | def _build_semantic_rejection_diagnostic(body: dict[str, Any]) -> str: |
53 | 180 | """构建语义拒绝的请求体诊断上下文. |
@@ -460,10 +587,16 @@ async def execute_stream( |
460 | 587 | failed_tier_name: str | None = None |
461 | 588 | request_caps = build_request_capabilities(body) |
462 | 589 | canonical_request = build_canonical_request(body, headers) |
463 | | - session_record = await self._session_mgr.get_or_create_record( |
| 590 | + session_record, is_new_session = await self._session_mgr.get_or_create_record( |
464 | 591 | canonical_request.session_key, |
465 | 592 | canonical_request.trace_id, |
466 | 593 | ) |
| 594 | + if is_new_session: |
| 595 | + title = _extract_session_title(canonical_request) |
| 596 | + if title: |
| 597 | + await self._recorder.set_session_title( |
| 598 | + canonical_request.session_key, title |
| 599 | + ) |
467 | 600 | incompatible_reasons: list[str] = [] |
468 | 601 | effective_tiers = self._resolve_effective_tiers(canonical_request.session_key) |
469 | 602 | last_idx = len(effective_tiers) - 1 |
@@ -631,10 +764,16 @@ async def execute_message( |
631 | 764 | failed_tier_name: str | None = None |
632 | 765 | request_caps = build_request_capabilities(body) |
633 | 766 | canonical_request = build_canonical_request(body, headers) |
634 | | - session_record = await self._session_mgr.get_or_create_record( |
| 767 | + session_record, is_new_session = await self._session_mgr.get_or_create_record( |
635 | 768 | canonical_request.session_key, |
636 | 769 | canonical_request.trace_id, |
637 | 770 | ) |
| 771 | + if is_new_session: |
| 772 | + title = _extract_session_title(canonical_request) |
| 773 | + if title: |
| 774 | + await self._recorder.set_session_title( |
| 775 | + canonical_request.session_key, title |
| 776 | + ) |
638 | 777 | incompatible_reasons: list[str] = [] |
639 | 778 | effective_tiers = self._resolve_effective_tiers(canonical_request.session_key) |
640 | 779 | last_idx = len(effective_tiers) - 1 |
|
0 commit comments