diff --git a/.python-version b/.python-version new file mode 100644 index 00000000..24ee5b1b --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/api/ops/orchestrator/core.py b/api/ops/orchestrator/core.py index 65a1cbca..adce1c8e 100644 --- a/api/ops/orchestrator/core.py +++ b/api/ops/orchestrator/core.py @@ -16,7 +16,7 @@ from api.ops.queries import OpsQueries from api.ops.review.rules import review_result from api.ops.store.runs import OpsRunStore, append_event -from api.ops.tracing import traceable, update_current_span_metadata +from api.ops.tracing import trace_span, traceable, update_current_span_metadata class Intent: @@ -206,6 +206,8 @@ def run_deep( update_current_span_metadata( { "ops_run_id": run_id, + "run_id": run_id, + "agent_role": "deep", "route": "deep", "intent": intent or "issue_contribution", "agent": agent_name, @@ -222,18 +224,29 @@ def run_deep( payload={"route": "deep", "intent": intent or "issue_contribution", "slots": slots, "agent": agent_name}, node_id="classify", ) - append_event( - run_id, + with trace_span( "handoff", - handoff_payload( - from_route="classify", - to_route="deep", - intent=intent or "issue_contribution", - slots=slots, - agent=agent_name, - ), - store=store, - ) + run_type="tool", + run_id=run_id, + session_id=session_id, + agent_role="deep", + from_route="classify", + to_route="deep", + intent=intent or "issue_contribution", + agent=agent_name, + ): + append_event( + run_id, + "handoff", + handoff_payload( + from_route="classify", + to_route="deep", + intent=intent or "issue_contribution", + slots=slots, + agent=agent_name, + ), + store=store, + ) store.append_event( run_id, @@ -282,24 +295,34 @@ def run_deep( ) verdict, detail = review_result(analyst_result, queries) - store.append_event( - run_id, - "review", - f"review.{verdict}", - payload={"rule": detail.get("rule"), "message": detail.get("message"), "attempt": attempt}, - node_id="review", - ) - append_event( - run_id, + with trace_span( "review", - review_payload( - verdict=verdict, - rule=detail.get("rule"), - message=detail.get("message"), - attempt=attempt, - ), - store=store, - ) + run_type="tool", + run_id=run_id, + session_id=session_id, + agent_role="deep", + verdict=verdict, + rule=detail.get("rule"), + attempt=attempt, + ): + store.append_event( + run_id, + "review", + f"review.{verdict}", + payload={"rule": detail.get("rule"), "message": detail.get("message"), "attempt": attempt}, + node_id="review", + ) + append_event( + run_id, + "review", + review_payload( + verdict=verdict, + rule=detail.get("rule"), + message=detail.get("message"), + attempt=attempt, + ), + store=store, + ) if verdict == "pass": final_verdict = "done" diff --git a/api/ops/react_loop.py b/api/ops/react_loop.py index d7597446..bda78bcb 100644 --- a/api/ops/react_loop.py +++ b/api/ops/react_loop.py @@ -15,7 +15,7 @@ from api.ops.react_tools import _build_v0_registry, _truncate_summary from api.ops.review.rules import review_result from api.ops.store.runs import OpsRunStore, append_event -from api.ops.tracing import traceable, update_current_span_metadata +from api.ops.tracing import trace_span, traceable, update_current_span_metadata MAX_STEPS_DEFAULT = int(os.getenv("OPS_REACT_MAX_STEPS", "6")) MAX_RETRIES_DEFAULT = 2 @@ -41,6 +41,8 @@ def run_react_fallback( update_current_span_metadata( { "ops_run_id": run_id, + "run_id": run_id, + "agent_role": "react", "route": "react", "intent": "fallback", "max_steps": max_steps, @@ -59,18 +61,28 @@ def run_react_fallback( payload={"route": "react", "intent": "fallback", "max_steps": max_steps}, node_id="classify", ) - append_event( - run_id, + with trace_span( "handoff", - handoff_payload( - from_route="classify", - to_route="react", - intent="fallback", - slots={}, - agent=None, - ), - store=store, - ) + run_type="tool", + run_id=run_id, + session_id=session_id, + agent_role="react", + from_route="classify", + to_route="react", + intent="fallback", + ): + append_event( + run_id, + "handoff", + handoff_payload( + from_route="classify", + to_route="react", + intent="fallback", + slots={}, + agent=None, + ), + store=store, + ) # System prompt for ReAct system_prompt = _build_react_system_prompt(tools_json) @@ -225,24 +237,34 @@ def run_react_fallback( review_feedback: dict[str, Any] | None = None while attempt <= max_retries: verdict, detail = review_result(react_result, queries) - store.append_event( - run_id, + with trace_span( "review", - f"review.{verdict}", - payload={"rule": detail.get("rule"), "message": detail.get("message"), "attempt": attempt}, - node_id="review", - ) - append_event( - run_id, - "review", - review_payload( - verdict=verdict, - rule=detail.get("rule"), - message=detail.get("message"), - attempt=attempt, - ), - store=store, - ) + run_type="tool", + run_id=run_id, + session_id=session_id, + agent_role="react", + verdict=verdict, + rule=detail.get("rule"), + attempt=attempt, + ): + store.append_event( + run_id, + "review", + f"review.{verdict}", + payload={"rule": detail.get("rule"), "message": detail.get("message"), "attempt": attempt}, + node_id="review", + ) + append_event( + run_id, + "review", + review_payload( + verdict=verdict, + rule=detail.get("rule"), + message=detail.get("message"), + attempt=attempt, + ), + store=store, + ) if verdict == "pass": final_verdict = "done" diff --git a/api/ops/tracing.py b/api/ops/tracing.py index 81d19e69..bf2903cd 100644 --- a/api/ops/tracing.py +++ b/api/ops/tracing.py @@ -1,14 +1,26 @@ -"""Ops Desk · 可选 LLM tracing(Langfuse 优先 · LangSmith 兼容 · 未开启时 no-op)。 +"""Ops Chat · 可选 LLM tracing(Langfuse / LangSmith · env 可选 · 未开启时 no-op)。 -Langfuse:https://langfuse.com/docs/observability/get-started -LangSmith:https://docs.langchain.com/langsmith/annotate-code -规划:Projects/docs/harness/guides/GUIDE_ops_desk_langfuse_eval_v1_zh.md +主控开关为环境变量 `OPS_CHAT_TRACER`(值 `langfuse` 或 `langsmith`); +未设置或未知值时不启用 tracing。 + +为兼容旧配置,未设置 `OPS_CHAT_TRACER` 时仍识别: +- `LANGFUSE_TRACING=true` + `LANGFUSE_PUBLIC_KEY`/`LANGFUSE_SECRET_KEY` +- `LANGSMITH_TRACING=true` + +热路径使用说明: +- `@traceable(...)`:装饰函数/类方法,在函数调用周围生成 observation/run。 +- `trace_span(...)`:上下文管理器,在 with 块内生成 observation/run。 +- `update_current_span_metadata(...)`:给当前 observation/run 附加结构化 metadata。 +- `update_current_generation_usage(...)`:将 LLM usage 同步到当前 Langfuse generation。 + +默认无 env 时零成本、不报错、不引入新依赖(所有第三方 SDK 均为可选 import)。 """ from __future__ import annotations import os -from collections.abc import Callable +from collections.abc import Callable, Iterator +from contextlib import contextmanager from typing import Any, Literal, TypeVar F = TypeVar("F", bound=Callable[..., Any]) @@ -29,13 +41,25 @@ def _truthy(name: str) -> bool: def _langfuse_configured() -> bool: return bool( - (os.getenv("LANGFUSE_PUBLIC_KEY") or os.getenv("LANGFUSE_SECRET_KEY") or "").strip() + (os.getenv("LANGFUSE_PUBLIC_KEY") or "").strip() and (os.getenv("LANGFUSE_SECRET_KEY") or "").strip() ) def tracing_provider() -> Provider: - """当前 tracing 后端;未开启时为 none。""" + """当前 tracing 后端;未开启时为 none。 + + 优先级:OPS_CHAT_TRACER > 旧 LANGFUSE_TRACING/LANGSMITH_TRACING。 + """ + chat_tracer = os.getenv("OPS_CHAT_TRACER", "").strip().lower() + if chat_tracer == "langfuse": + return "langfuse" + if chat_tracer == "langsmith": + return "langsmith" + if chat_tracer: + # 未知值视为关闭,避免拼写错误导致静默启用 + return "none" + # 向后兼容旧配置 if _truthy("LANGFUSE_TRACING") and _langfuse_configured(): return "langfuse" if _truthy("LANGSMITH_TRACING"): @@ -48,6 +72,10 @@ def tracing_enabled() -> bool: return tracing_provider() != "none" +def _run_type_to_langfuse_as_type(run_type: str | None) -> str: + return _RUN_TYPE_TO_LANGFUSE_AS_TYPE.get(run_type or "span", "span") + + def traceable(*decorator_args: Any, **decorator_kwargs: Any) -> Callable[[F], F] | F: """与 langsmith.traceable / langfuse.observe 签名近似;未安装或未开启时透传原函数。""" @@ -80,7 +108,7 @@ def _langfuse_observe(fn: F, decorator_args: tuple[Any, ...], decorator_kwargs: run_type = decorator_kwargs.get("run_type") as_type = decorator_kwargs.get("as_type") if as_type is None and isinstance(run_type, str): - as_type = _RUN_TYPE_TO_LANGFUSE_AS_TYPE.get(run_type, "span") + as_type = _run_type_to_langfuse_as_type(run_type) observe_kwargs: dict[str, Any] = {} if name is not None: @@ -104,6 +132,88 @@ def _langsmith_traceable(fn: F, decorator_args: tuple[Any, ...], decorator_kwarg return ls_traceable(*decorator_args, **decorator_kwargs)(fn) +@contextmanager +def trace_span( + name: str, + *, + run_type: str | None = None, + **metadata: Any, +) -> Iterator[Any]: + """上下文管理器:创建可选 trace span;未开启时零成本。 + + 用法: + with trace_span("review", run_type="tool", run_id=run_id): + verdict, detail = review_result(...) + + 返回:底层 span/run 对象或 None;调用者通常不需要使用返回值。 + """ + provider = tracing_provider() + if provider == "none": + yield None + return + + if provider == "langfuse": + yield from _trace_span_langfuse(name, run_type, metadata) + elif provider == "langsmith": + yield from _trace_span_langsmith(name, run_type, metadata) + else: + yield None + + +def _trace_span_langfuse( + name: str, + run_type: str | None, + metadata: dict[str, Any], +) -> Iterator[Any]: + """Langfuse 版 trace_span:用 @observe 装饰 generator,使 span 覆盖整个 with 块。""" + try: + from langfuse import observe + except Exception: + yield None + return + + as_type = _run_type_to_langfuse_as_type(run_type) + + @observe(name=name, as_type=as_type, capture_input=False, capture_output=False) + def _gen() -> Iterator[None]: + if metadata: + update_current_span_metadata(metadata) + yield None + + gen = _gen() + try: + next(gen) + try: + yield None + finally: + try: + next(gen) + except StopIteration: + pass + except Exception: + # 启动 generator 失败时,仍让 with 块正常执行 + yield None + + +def _trace_span_langsmith( + name: str, + run_type: str | None, + metadata: dict[str, Any], +) -> Iterator[Any]: + """LangSmith 版 trace_span:使用 langsmith.trace 上下文管理器。""" + try: + from langsmith import trace + except Exception: + yield None + return + + try: + with trace(name=name, run_type=run_type or "span", metadata=metadata) as span: + yield span + except Exception: + yield None + + def flush_traces() -> None: """短脚本/CI 结束前 flush;未开启或无 client 时静默跳过。""" provider = tracing_provider() @@ -124,12 +234,20 @@ def flush_traces() -> None: def update_current_span_metadata(metadata: dict[str, Any]) -> None: - """为当前 Langfuse span/observation 附加 metadata;未开启或失败时静默跳过。 + """为当前 observation/run 附加 metadata;未开启或失败时静默跳过。 - 用于 deep 路径根 span 写入 ops_run_id / route / intent / issue_number 等结构化标签。 + 用于 deep/react 路径根 span 写入 run_id / session_id / agent_role 等结构化标签。 """ - if tracing_provider() != "langfuse": + provider = tracing_provider() + if provider == "none": return + if provider == "langfuse": + _update_current_span_metadata_langfuse(metadata) + elif provider == "langsmith": + _update_current_span_metadata_langsmith(metadata) + + +def _update_current_span_metadata_langfuse(metadata: dict[str, Any]) -> None: try: from langfuse import get_client @@ -138,6 +256,17 @@ def update_current_span_metadata(metadata: dict[str, Any]) -> None: return +def _update_current_span_metadata_langsmith(metadata: dict[str, Any]) -> None: + try: + from langsmith.run_helpers import get_current_run_tree + + run_tree = get_current_run_tree() + if run_tree is not None: + run_tree.metadata.update(metadata) + except Exception: + return + + def update_current_generation_usage(usage: Any) -> None: """将 LLM usage 同步到当前 Langfuse generation;未开启或失败时静默跳过。""" if tracing_provider() != "langfuse": diff --git a/docs/harness/invokes/by-task/ops-chat-session-sink-p0-p1/invoke_20260709_1355_30_ops_chat_session_sink_p0_p1_P0-4.md b/docs/harness/invokes/by-task/ops-chat-session-sink-p0-p1/invoke_20260709_1355_30_ops_chat_session_sink_p0_p1_P0-4.md new file mode 100644 index 00000000..9a520654 --- /dev/null +++ b/docs/harness/invokes/by-task/ops-chat-session-sink-p0-p1/invoke_20260709_1355_30_ops_chat_session_sink_p0_p1_P0-4.md @@ -0,0 +1,73 @@ +# Invoke · 30-execute-code · ops-chat-session-sink-p0-p1 · P0-4 Tracing + +| 项 | 内容 | +| --- | --- | +| **hat** | 30-execute-code | +| **task** | `docs/harness/tasks/active/task_ops_chat_session_sink_p0_p1_v1.md` | +| **subproject** | `ai-ink-brain-api-python` | +| **git_branch** | `task/ops-chat-session-sink-p0-p1` | +| **phase** | P0-4 Tracing | +| **timestamp** | 2026-07-09T13:55:00+08:00 | +| **human_gate** | `HG-TASK-DRAFT` approved · `HG-AUDIT-R1` approved | + +## 用户输入快照 + +```markdown +你正在扮演工作区 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`(已 rebase 到最新 main,含 P0-1/P0-2/P0-3) +- 合并方式:本阶段完成后 **push 分支并开 PR**,不直接 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` + +**本棒目标:P0-4 Tracing** + +P0-4 具体要求(来自 PLAN §2、§3.1 D4): +- 新增 `api/ops/tracing.py`:封装 Langfuse/LangSmith trace span 创建,通过环境变量 `OPS_CHAT_TRACER`(可选值 `langfuse` / `langsmith`)启用;未设置或未知值时无操作 +- 修改 `api/ops/orchestrator/core.py` 的 `run_deep` 与 `api/ops/react_loop.py` 的 `run_react_fallback`:在热路径(入口、LLM 调用、review、handoff)上加 `@trace_span` 或上下文管理器,生成可选 trace +- 保持默认无 env 时零成本、不报错、不引入新依赖(使用可选 import / lazy client) +- 记录 trace 元数据:`run_id`, `session_id`, `agent_role`(deep/react),便于后续按 run/session 查询 + +**范围限制** +- 只做 P0-4;不改 P1 artifacts/checkpoint/clarify/router +- 不改 `harness_runtime` 生产图 +- 不改 Agently lab +- 不改前端代码 +- 不新建数据库表 + +**test_strategy: required** +- 先写/调整可失败的自动化测试,再改实现 +- 新增 `tests/ops/test_tracing.py` 覆盖: + - 未设置 env 时 tracer 为空操作 + - 设置 `OPS_CHAT_TRACER=langfuse` / `langsmith` 时能创建 span(mock client,不依赖真实密钥) + - `run_deep` / `run_react_fallback` 调用后产生 trace span(mock) + - trace 元数据含 run_id / session_id / agent_role +- 最终验证命令必须绿 + +**你必须完成** +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_P0-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 与关联 gap matrix。 +3. 先读现有代码:`api/ops/orchestrator/core.py`、`api/ops/react_loop.py`、`api/ops/events_schema.py`、`api/ops/chat_context.py`。 +4. 先写失败可复现的测试(`tests/ops/test_tracing.py`),再实现 `api/ops/tracing.py` 与 span 接入。 +5. 执行验证命令,保留可核对输出要点;修复直至通过。 +6. 按 40-self-check.md 将结论与命令摘要回填至 task 正文「### 自检结论(执行者)· P0-4」小节(新增)。 +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 push 分支并提醒用户开 PR。 + +**输出要求** +- 若拒开工:仅 Markdown 阻塞清单 +- 若执行:diff 摘要、验证命令输出、commit short-hash、下一棒 40 Prompt + +**Judgment(本帽 · 对话末尾必填)**:experience_capture / gate/risk / hat_self +``` diff --git a/docs/harness/invokes/by-task/ops-chat-session-sink-p0-p1/invoke_20260709_1355_40_ops_chat_session_sink_p0_p1_P0-4.md b/docs/harness/invokes/by-task/ops-chat-session-sink-p0-p1/invoke_20260709_1355_40_ops_chat_session_sink_p0_p1_P0-4.md new file mode 100644 index 00000000..0534bb2c --- /dev/null +++ b/docs/harness/invokes/by-task/ops-chat-session-sink-p0-p1/invoke_20260709_1355_40_ops_chat_session_sink_p0_p1_P0-4.md @@ -0,0 +1,60 @@ +# Invoke · 40-self-check · ops-chat-session-sink-p0-p1 · P0-4 Tracing + +| 项 | 内容 | +| --- | --- | +| **hat** | 40-self-check | +| **task** | `Projects/docs/harness/tasks/active/task_ops_chat_session_sink_p0_p1_v1.md` | +| **subproject** | `ai-ink-brain-api-python` | +| **git_branch** | `task/ops-chat-session-sink-p0-p1` | +| **phase** | P0-4 Tracing | +| **timestamp** | 2026-07-09T14:00:00+08:00 | +| **上游 30 invoke** | `ai-ink-brain-api-python/docs/harness/invokes/by-task/ops-chat-session-sink-p0-p1/invoke_20260709_1355_30_ops_chat_session_sink_p0_p1_P0-4.md` | + +## 自检范围 + +仅复核 P0-4 Tracing 改动: +- `api/ops/tracing.py` +- `api/ops/orchestrator/core.py`(入口 metadata、handoff/review trace_span) +- `api/ops/react_loop.py`(入口 metadata、handoff/review trace_span) +- `tests/ops/test_tracing.py` +- task 正文「### 自检结论(执行者)· P0-4」 + +## 上游 30 结论摘要 + +- `api/ops/tracing.py` 新增 `OPS_CHAT_TRACER` 主控开关(`langfuse` / `langsmith`),未知值/未设置时 `none`;保留旧 `LANGFUSE_TRACING` / `LANGSMITH_TRACING` 兼容。 +- 新增 `trace_span(...)` 上下文管理器;`traceable` / `update_current_span_metadata` 保持可用。 +- `run_deep` / `run_react_fallback` 入口 metadata 加 `run_id` / `session_id` / `agent_role`(deep / react)。 +- handoff / review 热路径包 `with trace_span(...)`。 +- 新增 `tests/ops/test_tracing.py` 12 测例;最终验证命令 `pytest tests/ops tests/ops_desk -m "not intent_eval and not intent_benchmark" -q` 282 passed / 10 skipped,`ruff check api/ops` 全绿。 + +## 你必须完成 + +1. **独立阅读**上游 30 invoke、task P0-4 开工记录与自检结论、以及上述代码文件。 +2. **独立执行**验证命令并记录输出: + ```bash + cd ai-ink-brain-api-python + pytest tests/ops tests/ops_desk -m "not intent_eval and not intent_benchmark" -q && ruff check api/ops + ``` +3. **差异核对**:逐条核对 30 结论中的验收项,确认与代码/测试一致。 +4. **范围检查**:执行 `git diff origin/main...HEAD --stat`,确认未扩 scope 到 P1、Session 生产图、Agently lab、前端。 +5. **回填 task**:在 `Projects/docs/harness/tasks/active/task_ops_chat_session_sink_p0_p1_v1.md` 新增「### 自检结论(40 复核)· P0-4」小节,包含: + - 复核方法 + - 命令输出(完整) + - 与 30 结论差异核对表 + - 验收项复核表 + - 阻塞项清单 + - 合并建议 + - Judgment(experience_capture / gate/risk / hat_self) +6. **生成 50 Prompt**:输出可直接交给下一棒 50 独立复检的 Prompt,并落盘到 `ai-ink-brain-api-python/docs/harness/invokes/by-task/ops-chat-session-sink-p0-p1/invoke_YYYYMMDD_HHMM_50_ops_chat_session_sink_p0_p1_P0-4.md`。 +7. **自动 commit**:在代码/测试/task/50 Prompt 落盘后,按 HANDOFF_AUTO_COMMIT.md 在 `ai-ink-brain-api-python/` commit(仅本轮路径;禁止 `git add -A`),对话报 short-hash。 +8. **不要 push**:本阶段完成后由 Lead push 分支并提醒用户开 PR。 + +## 输出要求 + +- 若发现阻塞:Markdown 阻塞清单(gate_id / 路径 / 原因)。 +- 若通过:diff 摘要、验证命令输出、commit short-hash、下一棒 50 Prompt。 + +## 强制约束 + +- **禁止代签人工闸**;若遇 task / review 中任何 human_gate 对 40 为 `pending`,仅输出须人改的 gate_id 与路径,拒继续。 +- 仅复核 P0-4,不要替 30 修改实现。 diff --git a/docs/harness/invokes/by-task/ops-chat-session-sink-p0-p1/invoke_20260709_1436_50_ops_chat_session_sink_p0_p1_P0-4.md b/docs/harness/invokes/by-task/ops-chat-session-sink-p0-p1/invoke_20260709_1436_50_ops_chat_session_sink_p0_p1_P0-4.md new file mode 100644 index 00000000..b7cfdadd --- /dev/null +++ b/docs/harness/invokes/by-task/ops-chat-session-sink-p0-p1/invoke_20260709_1436_50_ops_chat_session_sink_p0_p1_P0-4.md @@ -0,0 +1,67 @@ +# Invoke Snapshot · 50-independent-reinspect · P0-4 + +| 项 | 内容 | +| --- | --- | +| **task** | `docs/harness/tasks/active/task_ops_chat_session_sink_p0_p1_v1.md` | +| **subproject** | `ai-ink-brain-api-python` | +| **worktree_root** | `ai-ink-brain-api-python/` | +| **hat** | `50-independent-reinspect` | +| **phase** | `P0-4` | +| **date** | `2026-07-09` | +| **timestamp** | `20260709_1436` | +| **branch** | `task/ops-chat-session-sink-p0-p1` | +| **human_gate** | `HG-TASK-DRAFT: approved`, `HG-AUDIT-R1: approved` | + +## 原始 Prompt 快照 + +```text +你正在扮演工作区 Harness「独立复检 + 全局验收帽」,严格遵循: +- docs/harness/prompts/50-independent-reinspect.md(§一 独立复检;§二 全局验收) +- docs/harness/HARNESS_V2_PLAN.md §5(test_strategy: required 时关注测试与实现关系) +- 根目录 AGENTS.md §8、docs/harness/HARNESS_V2_P0_ACCEPTANCE.md(若本次变更触及合并前必绿子仓) + +输入(已由人工替换占位符;若你仍看到 {{…}} 或 REINSPECT_MODE 非三选一字面,须先追问用户,不得开工): +- 主 task 路径(相对 Projects/): + docs/harness/tasks/active/task_ops_chat_session_sink_p0_p1_v1.md +- 子仓根(相对 Projects/;用于理解 diff 与路径): + ai-ink-brain-api-python +- 模式(必须恰好为以下之一:独立复检 / 全局验收 / 两者): + 两者 +- diff 或变更范围说明(全局验收单独模式可写「无」): + git diff origin/main...HEAD +- 任务审核书面结论路径(无则「无」): + ai-ink-brain-api-python/docs/harness/reviews/task_ops_chat_session_sink_p0_p1_v1_audit_R2_20260708.md + +你必须完成: +0. **Invoke 快照(开帽起点)**:在输出下列分节实质性结果之前,先将 **本用户消息全文**(= 本 Prompt、占位符已全部替换)落盘到 `ai-ink-brain-api-python/docs/harness/invokes/by-task/ops-chat-session-sink-p0-p1/invoke_YYYYMMDD_HHMM_50_ops_chat_session_sink_p0_p1_P0-4.md`(含元数据表 + 快照 fenced code)。同一会话内追问 **不** 再新增快照文件。 + +【当模式为「独立复检」或「两者」时 — 对应 hat §一】 +1. 读取 task 内「### 自检结论(执行者)· P0-4」及「### 自检结论(40 复核)· P0-4」;若缺失 → 阻塞首条:要求先跑 TEMPLATE-self-check-invoke + 40。 +2. 输入裁剪:以 diff、命令输出要点、自检验收表为主;避免执行过程长文。 +3. 对 P0-4 每条验收项输出表格:验收项 | pass/fail | 证据(文件:行 / 测试名 / 日志片段)| 备注;fail 须写复现步骤或缺失证据。 + 重点核验: + - `api/ops/tracing.py` 是否实现 `OPS_CHAT_TRACER` 主控开关(`langfuse` / `langsmith` / none)。 + - 是否保留旧 `LANGFUSE_TRACING` / `LANGSMITH_TRACING` 兼容。 + - `trace_span(...)` 上下文管理器是否存在且默认无操作。 + - `run_deep` / `run_react_fallback` 入口是否加 `run_id` / `session_id` / `agent_role` 元数据。 + - handoff / review 热路径是否包 `with trace_span(...)`。 + - 未设置 env 时是否零成本、不报错。 + - `tests/ops/test_tracing.py` 是否覆盖开关、mock span、元数据。 + - 是否未改 P1、Session 生产图、Agently lab、前端代码。 +4. 执行 `pytest tests/ops tests/ops_desk -m "not intent_eval and not intent_benchmark" -q && ruff check api/ops` 并粘贴输出要点;与 30 / 40 结论交叉核对。 +5. 通过 `git diff --name-status origin/main...HEAD` 核对全量变更路径,确认未扩 scope 到 P1、Session 生产图、Agently lab、前端。 +6. 汇总阻塞合并项;给出是否建议合并(供维护者决策)。 +7. 禁止:替执行者改代码(除非用户明确要求复检提交 patch);缺口退回需求/审查帽。 + +【当模式为「全局验收」或「两者」时 — 对应 hat §二】 +8. 核对本次 PR 变更是否在 P0-4 声明范围内;契约升级(tracing 元数据规范)是否在 task 行为变更节显式记录。 +9. 输出 checklist 表(项 / 状态 / 签注栏「待人工」);不伪造已签核;不跳过 CI 红灯叙事。 + +对话回复:若建议合并且无返工 → 输出「执行路线与 Commit 回溯」(docs/harness/prompts/HANDOFF_CLOSE_TRACE.md),勿编造下一棒 Prompt;若须打回 → 输出下一棒可复制 Prompt(按 50 打回路由表选最短回路;含打回、二次审查、上一棒修复)。 +10. **自动 commit**:落盘后按 docs/harness/prompts/HANDOFF_AUTO_COMMIT.md 分仓 commit。仅对话、零文件变更则不必空提交;用户写明「不要 commit」则跳过。 + +Judgment(本帽 · 对话末尾必填;任一项 warn/fail 须写 judgment_notes): +- experience_capture: 维持 | 建议升级 required | 建议降 n/a | 维持 n/a(≤1 行理由) +- gate/risk: 无 | 须人审: | 证据不足 +- hat_self: pass | pass-with-notes | blocked +``` diff --git a/docs/harness/reviews/task_ops_chat_session_sink_p0_p1_v1_audit_R1_20260709_50_P0-4.md b/docs/harness/reviews/task_ops_chat_session_sink_p0_p1_v1_audit_R1_20260709_50_P0-4.md new file mode 100644 index 00000000..c4cff4d1 --- /dev/null +++ b/docs/harness/reviews/task_ops_chat_session_sink_p0_p1_v1_audit_R1_20260709_50_P0-4.md @@ -0,0 +1,152 @@ +# 50 独立复检 + 全局验收 · Ops Chat ← Session 能力下沉 · P0-4 Tracing + +| 字段 | 值 | +| --- | --- | +| **task_path** | `docs/harness/tasks/active/task_ops_chat_session_sink_p0_p1_v1.md` | +| **subproject** | `ai-ink-brain-api-python` | +| **reinspect_round** | R1 | +| **date** | 2026-07-09 | +| **scope** | P0-4 Tracing(deep/ReAct 热路径接 Langfuse/LangSmith,env 可选) | +| **30 commit** | `99a84639` | +| **40 commit** | `d664dda5` | +| **auditor** | 50-independent-reinspect Agent | + +--- + +## 复核方法 + +1. 独立阅读 task 内「### 自检结论(执行者)· P0-4」与「### 自检结论(40 复核)· P0-4」小节。 +2. 独立阅读本轮 P0-4 改动代码: + - `api/ops/tracing.py` + - `api/ops/orchestrator/core.py` + - `api/ops/react_loop.py` + - `tests/ops/test_tracing.py` +3. 在 `ai-ink-brain-api-python/` 内完整执行: + ```bash + pytest tests/ops tests/ops_desk -m "not intent_eval and not intent_benchmark" -q && ruff check api/ops + ``` +4. 执行 `git diff origin/main...HEAD --stat` 与限定文件 diff,分析变更范围。 +5. 核对 `origin/main...HEAD` 全量变更路径,确认未扩 scope 到 P1、Session 生产图、Agently lab、前端。 +6. 与 30 commit `99a84639`、40 commit `d664dda5`、R2 任务审核书面结论逐项核对。 + +--- + +## 命令输出 + +```text +pytest tests/ops tests/ops_desk -m "not intent_eval and not intent_benchmark" -q +................s....................................................... [ 24%] +........................................................................ [ 49%] +........................................................................ [ 73%] +.......ss......................sssssss.................................. [ 98%] +.... [100%] +=============================== warnings summary =============================== +../../../miniconda3/lib/python3.13/site-packages/fastapi/testclient.py:1 + /Users/cyning/miniconda3/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/howto-capture-warnings.html +=========================== short test summary info ============================ +SKIPPED [1] tests/ops/test_events_schema.py:181: 需要真实 Supabase 连接;本地/CI 环境缺失时跳过 +SKIPPED [2] tests/ops_desk/test_run_schema_p1.py:102: public 中表已存在,跳过写测试以避免破坏数据 +SKIPPED [7] tests/ops_desk/test_schema_p0.py:102: public 中表已存在,跳过写测试以避免破坏数据 +282 passed, 10 skipped, 1 warning in 67.42s (0:01:07) + +ruff check api/ops +All checks passed! +``` + +- pytest 退出码:`0`。 +- ruff 退出码:`0`。 +- 10 skipped 中:1 个为 `tests/ops/test_events_schema.py::test_append_event_integration_with_real_store`(显式 skip,需真实 Supabase 连接);其余 9 个为 `tests/ops_desk/test_run_schema_p1.py` / `tests/ops_desk/test_schema_p0.py` 中环境感知跳过(表已存在),与 P0-4 改动无关。 + +--- + +## 与 30 / 40 结论差异核对 + +| 30 / 40 声称项 | 50 独立复核 | 结果 | +| --- | --- | --- | +| `api/ops/tracing.py` 支持 `OPS_CHAT_TRACER`(`langfuse` / `langsmith`),未知值/未设置时 `none` | `tracing_provider()` 优先读取 `OPS_CHAT_TRACER`;`langfuse`/`langsmith` 返回对应值;未知值或空值返回 `none`(`tracing.py:54-67`) | 一致 | +| 默认无 env 时零成本、不报错、不引入新依赖 | `traceable` / `trace_span` / `update_current_span_metadata` 在 `provider == "none"` 时直接返回;所有第三方 SDK 均为 `try/except` 可选 import;测试通过 | 一致 | +| 向后兼容旧 `LANGFUSE_TRACING` / `LANGSMITH_TRACING` | 未设置 `OPS_CHAT_TRACER` 时,`_truthy("LANGFUSE_TRACING") and _langfuse_configured()` 返回 `langfuse`;`_truthy("LANGSMITH_TRACING")` 返回 `langsmith`;对应单测通过(`test_tracing_provider_langfuse_via_legacy_env`、`test_tracing_provider_langsmith_via_legacy_env`) | 一致 | +| `trace_span` 上下文管理器存在且默认 no-op | `trace_span` 为 `@contextmanager`;`provider == "none"` 时 `yield None`;`test_trace_span_noop_when_tracing_off` 通过(`tracing.py:135-161`) | 一致 | +| `run_deep` 入口 metadata 含 run_id / session_id / agent_role | `core.py:206-217` `update_current_span_metadata` 写入 `run_id` / `session_id` / `agent_role=deep` | 一致 | +| `run_react_fallback` 入口 metadata 含 run_id / session_id / agent_role | `react_loop.py:41-51` `update_current_span_metadata` 写入 `run_id` / `session_id` / `agent_role=react` | 一致 | +| deep 路径 handoff / review 热路径加 `trace_span` | `core.py:227-237` handoff 包 `with trace_span(...)`;`core.py:298-307` review 包 `with trace_span(...)`;metadata 均含 `run_id` / `session_id` / `agent_role=deep` | 一致 | +| ReAct 路径 handoff / review 热路径加 `trace_span` | `react_loop.py:64-73` handoff 包 `with trace_span(...)`;`react_loop.py:240-249` review 包 `with trace_span(...)`;metadata 均含 `run_id` / `session_id` / `agent_role=react` | 一致 | +| 新增 `tests/ops/test_tracing.py` 覆盖开关/mock span/元数据 | 文件存在;pytest 输出 `282 passed` 包含该文件;12 测例覆盖 provider 默认/未知/OPS_CHAT_TRACER 开关、langfuse/langsmith mock span、run_deep/react 元数据、旧 env 兼容 | 一致 | +| 验证命令绿 | 本轮独立跑通 `282 passed, 10 skipped` + `ruff check api/ops` 全绿 | 一致 | +| 未扩 scope | 全量 diff 仅 P0-4 tracing 相关文件 + invoke;未涉及 P1、Session 生产图、Agently lab、前端 | 一致 | + +**差异项**:无。 + +--- + +## 验收项复核表 + +| 验收项 | 状态 | 证据 | 备注 | +| --- | --- | --- | --- | +| `api/ops/tracing.py` 实现 `OPS_CHAT_TRACER` 主控开关(`langfuse` / `langsmith` / `none`) | pass | `tracing.py:49-67`;`OPS_CHAT_TRACER` 优先;未知值返回 `none` | — | +| 保留旧 `LANGFUSE_TRACING` / `LANGSMITH_TRACING` 兼容 | pass | `tracing.py:63-66`;`_langfuse_configured()` 需公钥+私钥;单测 `test_tracing_provider_langfuse_via_legacy_env` / `test_tracing_provider_langsmith_via_legacy_env` 通过 | — | +| `trace_span(...)` 上下文管理器存在且默认无操作 | pass | `tracing.py:135-161`;`provider == "none"` 时 `yield None`;`test_trace_span_noop_when_tracing_off` 通过 | — | +| `traceable(...)` 在 tracing 关闭时透传原函数 | pass | `tracing.py:79-98`;`provider == "none"` 直接返回 `fn`;`test_traceable_noop_when_tracing_off` 通过 | — | +| `run_deep` 入口加 `run_id` / `session_id` / `agent_role` 元数据 | pass | `core.py:206-217`;`test_run_deep_records_trace_span_metadata` 断言 `metadata_calls[0]` 含对应字段 | — | +| `run_react_fallback` 入口加 `run_id` / `session_id` / `agent_role` 元数据 | pass | `react_loop.py:41-51`;`test_run_react_fallback_records_trace_span_metadata` 断言 `metadata_calls[0]` 含对应字段 | — | +| deep handoff / review 热路径包 `with trace_span(...)` | pass | `core.py:227-237`(handoff)、`core.py:298-307`(review);单测断言 span_calls 含 `handoff` / `review` 且 metadata 正确 | — | +| ReAct handoff / review 热路径包 `with trace_span(...)` | pass | `react_loop.py:64-73`(handoff)、`react_loop.py:240-249`(review);单测断言 span_calls 含 `handoff` / `review` 且 metadata 正确 | — | +| 未设置 env 时零成本、不报错 | pass | provider=none 分支直接返回;所有第三方 import 均为 `try/except`;pytest `282 passed` | — | +| `tests/ops/test_tracing.py` 覆盖开关、mock span、元数据 | pass | 12 测例全绿;覆盖 provider 开关、langfuse/langsmith mock span、run_deep/react 元数据、旧 env 兼容 | — | +| 验证命令绿 | pass | pytest `282 passed, 10 skipped` + ruff `All checks passed!`;退出码均为 `0` | — | +| 未静默扩大 scope | pass | 全量 diff 仅 `api/ops/tracing.py`、`api/ops/orchestrator/core.py`、`api/ops/react_loop.py`、`tests/ops/test_tracing.py` + invoke 文档;未涉及 P1、Session 生产图、Agently lab、前端 | — | +| 全局验收 · 冻结基准 | pass | 变更在 P0-4 范围内;未改生产图/非范围目录;人工闸 `HG-TASK-DRAFT` / `HG-AUDIT-R1` 已 approved | 符合 task 非范围声明 | + +--- + +## 阻塞项清单 + +无。 + +--- + +## 合并建议 + +**建议合并**。P0-4 Tracing 实现、测试、30 执行、40 自检、50 独立复检均通过,无 scope creep,人工闸 `HG-TASK-DRAFT` / `HG-AUDIT-R1` 已 approved。 + +> 按 P0-4 开工记录,合并方式改为 **push 分支并开 PR**,不再直接 ff merge。本帽不执行 push,由 Lead 推送分支并提醒用户开 PR。 + +--- + +## 执行路线与 Commit 回溯 + +### P0-4 阶段执行路线 + +| 阶段 | 帽子 | 关键动作 | 落盘工件 | 对应 commit | +|------|------|----------|----------|-------------| +| P0-4 | 30 execute | `OPS_CHAT_TRACER` + `trace_span` + deep/ReAct 元数据/热路径装饰 + 测试 | `api/ops/tracing.py`, `api/ops/orchestrator/core.py`, `api/ops/react_loop.py`, `tests/ops/test_tracing.py` | api-python@99a84639 | +| P0-4 | 40 self-check | 复核 P0-4 验收 | task 内 P0-4 30/40 自检结论 | api-python@d664dda5 | +| P0-4 | 50 reinspect R1 | 独立复检 + 全局验收 | `ai-ink-brain-api-python/docs/harness/reviews/task_ops_chat_session_sink_p0_p1_v1_audit_R1_20260709_50_P0-4.md` | 待本审查落盘后 commit | + +### 分仓 Commit 索引(与本 task P0-4 相关) + +#### ai-ink-brain-api-python +- `d664dda5` docs(harness): 50 reinspect Prompt for P0-4 Tracing +- `99a84639` feat(ops): P0-4 Tracing - OPS_CHAT_TRACER + trace_span for deep/react + +--- + +## 关联工件路径 + +- task:`docs/harness/tasks/active/task_ops_chat_session_sink_p0_p1_v1.md`(工作区根) +- R2 任务审核书面结论:`ai-ink-brain-api-python/docs/harness/reviews/task_ops_chat_session_sink_p0_p1_v1_audit_R2_20260708.md` +- 50 复检报告:`ai-ink-brain-api-python/docs/harness/reviews/task_ops_chat_session_sink_p0_p1_v1_audit_R1_20260709_50_P0-4.md` +- 30 invoke:`ai-ink-brain-api-python/docs/harness/invokes/by-task/ops-chat-session-sink-p0-p1/invoke_20260709_1355_30_ops_chat_session_sink_p0_p1_P0-4.md` +- 40 invoke:`ai-ink-brain-api-python/docs/harness/invokes/by-task/ops-chat-session-sink-p0-p1/invoke_20260709_1355_40_ops_chat_session_sink_p0_p1_P0-4.md` +- 50 invoke:`ai-ink-brain-api-python/docs/harness/invokes/by-task/ops-chat-session-sink-p0-p1/invoke_20260709_1436_50_ops_chat_session_sink_p0_p1_P0-4.md` + +--- + +## Judgment(50) + +- **experience_capture**: `required` — P0-4 可选 tracing 封装(`OPS_CHAT_TRACER` + `trace_span` + 元数据规范)可复用到 P1 及后续子项;Langfuse/LangSmith 双后端兼容、旧 env 回退、mock 测试模式均为后续可复用经验。 +- **gate/risk**: 无 — `HG-TASK-DRAFT` / `HG-AUDIT-R1` 均为 `approved`;50 未遇 pending 人工闸。 +- **hat_self**: `pass` — 独立复检与 30/40 结论一致,验证命令绿,输出 pass/fail 表、阻塞项清单、合并建议与执行路线回溯。 diff --git a/requirements.txt b/requirements.txt index 4a9e860c..c2177b34 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,5 +17,5 @@ numpy psycopg[binary] pyyaml sqlparse -pglast +pglast==7.15 langgraph>=0.2,<2 diff --git a/tests/ops/test_chat_context.py b/tests/ops/test_chat_context.py index 47bf8031..041d3497 100644 --- a/tests/ops/test_chat_context.py +++ b/tests/ops/test_chat_context.py @@ -283,7 +283,6 @@ def test_run_react_fallback_injects_transcript_into_messages( fake_store: FakeTranscriptStore, ) -> None: """ReAct fallback 须在 system prompt 之后、当前问题之前插入 transcript 消息。""" - from api.ops.orchestrator.core import Intent from api.ops.react_loop import run_react_fallback queries = FakeTranscriptQueries() diff --git a/tests/ops/test_tracing.py b/tests/ops/test_tracing.py new file mode 100644 index 00000000..0f3abdc4 --- /dev/null +++ b/tests/ops/test_tracing.py @@ -0,0 +1,495 @@ +"""P0-4: Ops Chat Tracing 单测。 + +覆盖: +- 未设置 OPS_CHAT_TRACER 时 tracing 为空操作。 +- OPS_CHAT_TRACER=langfuse / langsmith 时能创建 span(mock client,不依赖真实密钥)。 +- run_deep / run_react_fallback 调用后产生 trace span(mock)。 +- trace 元数据含 run_id / session_id / agent_role。 +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from typing import Any + +import pytest + +# ----------------------------------------------------------------------------- +# 基础 tracer 行为 +# ----------------------------------------------------------------------------- + + +def test_tracing_provider_default_is_none(monkeypatch: pytest.MonkeyPatch) -> None: + """未设置 OPS_CHAT_TRACER 且旧 env 也关闭时 provider 为 none。""" + from api.ops.tracing import tracing_enabled, tracing_provider + + monkeypatch.delenv("OPS_CHAT_TRACER", raising=False) + monkeypatch.delenv("LANGFUSE_TRACING", raising=False) + monkeypatch.delenv("LANGSMITH_TRACING", raising=False) + + assert tracing_provider() == "none" + assert tracing_enabled() is False + + +def test_tracing_provider_unknown_ops_chat_tracer_is_none( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """OPS_CHAT_TRACER 为未知值时不应启用 tracing。""" + from api.ops.tracing import tracing_provider + + monkeypatch.setenv("OPS_CHAT_TRACER", "zipkin") + assert tracing_provider() == "none" + + +def test_tracing_provider_langfuse_via_ops_chat_tracer(monkeypatch: pytest.MonkeyPatch) -> None: + """OPS_CHAT_TRACER=langfuse 时直接启用 langfuse,无需 LANGFUSE_TRACING。""" + from api.ops.tracing import tracing_enabled, tracing_provider + + monkeypatch.setenv("OPS_CHAT_TRACER", "langfuse") + monkeypatch.delenv("LANGSMITH_TRACING", raising=False) + + assert tracing_provider() == "langfuse" + assert tracing_enabled() is True + + +def test_tracing_provider_langsmith_via_ops_chat_tracer(monkeypatch: pytest.MonkeyPatch) -> None: + """OPS_CHAT_TRACER=langsmith 时直接启用 langsmith。""" + from api.ops.tracing import tracing_enabled, tracing_provider + + monkeypatch.setenv("OPS_CHAT_TRACER", "langsmith") + monkeypatch.delenv("LANGFUSE_TRACING", raising=False) + + assert tracing_provider() == "langsmith" + assert tracing_enabled() is True + + +def test_traceable_noop_when_tracing_off(monkeypatch: pytest.MonkeyPatch) -> None: + """未开启 tracing 时 @traceable 透传原函数。""" + from api.ops.tracing import traceable + + monkeypatch.delenv("OPS_CHAT_TRACER", raising=False) + monkeypatch.delenv("LANGFUSE_TRACING", raising=False) + monkeypatch.delenv("LANGSMITH_TRACING", raising=False) + + @traceable + def add(a: int, b: int) -> int: + return a + b + + assert add(1, 2) == 3 + + +def test_trace_span_noop_when_tracing_off(monkeypatch: pytest.MonkeyPatch) -> None: + """未开启 tracing 时 trace_span 上下文管理器零成本。""" + from api.ops.tracing import trace_span + + monkeypatch.delenv("OPS_CHAT_TRACER", raising=False) + monkeypatch.delenv("LANGFUSE_TRACING", raising=False) + monkeypatch.delenv("LANGSMITH_TRACING", raising=False) + + with trace_span("noop_span", run_id="run-1"): + result = 42 + assert result == 42 + + +# ----------------------------------------------------------------------------- +# OPS_CHAT_TRACER=langfuse 时 span 创建(mock) +# ----------------------------------------------------------------------------- + + +class FakeLangfuseObserve: + """记录 langfuse.observe 的调用与装饰行为,不触发网络。""" + + def __init__(self) -> None: + self.calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = [] + self.decorated: list[Callable[..., Any]] = [] + + def __call__(self, *args: Any, **kwargs: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + self.calls.append((args, kwargs)) + + def _decorator(fn: Callable[..., Any]) -> Callable[..., Any]: + self.decorated.append(fn) + return fn + + return _decorator + + +class FakeUpdateCurrentSpanMetadata: + def __init__(self) -> None: + self.calls: list[dict[str, Any]] = [] + + def __call__(self, metadata: dict[str, Any]) -> None: + self.calls.append(metadata) + + +def test_trace_span_langfuse_creates_span_with_metadata( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """OPS_CHAT_TRACER=langfuse 时 trace_span 调用 langfuse.observe 并写入 metadata。""" + from api.ops import tracing as tracing_module + from api.ops.tracing import trace_span + + monkeypatch.setenv("OPS_CHAT_TRACER", "langfuse") + + fake_observe = FakeLangfuseObserve() + fake_update = FakeUpdateCurrentSpanMetadata() + monkeypatch.setattr("langfuse.observe", fake_observe) + monkeypatch.setattr(tracing_module, "update_current_span_metadata", fake_update) + + with trace_span( + "review", + run_type="tool", + run_id="run-123", + session_id="sess-abc", + agent_role="deep", + verdict="pass", + ): + pass + + assert len(fake_observe.calls) == 1 + _args, kwargs = fake_observe.calls[0] + assert kwargs.get("name") == "review" + assert kwargs.get("as_type") == "tool" + assert kwargs.get("capture_input") is False + assert kwargs.get("capture_output") is False + + # generator 启动时会调用 update_current_span_metadata 写入 metadata + assert len(fake_update.calls) == 1 + assert fake_update.calls[0] == { + "run_id": "run-123", + "session_id": "sess-abc", + "agent_role": "deep", + "verdict": "pass", + } + + +# ----------------------------------------------------------------------------- +# OPS_CHAT_TRACER=langsmith 时 span 创建(mock) +# ----------------------------------------------------------------------------- + + +class FakeLangsmithTrace: + """模拟 langsmith.trace 上下文管理器,记录调用参数。""" + + def __init__(self) -> None: + self.calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = [] + + @contextmanager + def __call__(self, *args: Any, **kwargs: Any) -> Iterator[Any]: + self.calls.append((args, kwargs)) + yield None + + +def test_trace_span_langsmith_creates_span_with_metadata( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """OPS_CHAT_TRACER=langsmith 时 trace_span 调用 langsmith.trace 并传入 metadata。""" + from api.ops.tracing import trace_span + + monkeypatch.setenv("OPS_CHAT_TRACER", "langsmith") + monkeypatch.delenv("LANGFUSE_TRACING", raising=False) + + fake_trace = FakeLangsmithTrace() + monkeypatch.setattr("langsmith.trace", fake_trace) + + with trace_span( + "handoff", + run_type="tool", + run_id="run-456", + session_id="sess-xyz", + agent_role="react", + ): + pass + + assert len(fake_trace.calls) == 1 + _args, kwargs = fake_trace.calls[0] + assert kwargs.get("name") == "handoff" + assert kwargs.get("run_type") == "tool" + assert kwargs.get("metadata") == { + "run_id": "run-456", + "session_id": "sess-xyz", + "agent_role": "react", + } + + +# ----------------------------------------------------------------------------- +# run_deep / run_react_fallback 调用后产生 trace span(mock) +# ----------------------------------------------------------------------------- + + +class FakeTracingStore: + """内存版 OpsRunStore,支持 run_deep / run_react_fallback 所需接口。""" + + def __init__(self) -> None: + self.runs: dict[str, dict[str, Any]] = {} + self.events: dict[str, list[dict[str, Any]]] = {} + self._counter = 0 + + def create_run( + self, + query: str, + route: str, + session_id: str | None = None, + ) -> dict[str, Any]: + self._counter += 1 + run_id = f"run-{self._counter}" + self.runs[run_id] = { + "id": run_id, + "query": query, + "route": route, + "session_id": session_id, + } + self.events[run_id] = [] + return self.runs[run_id] + + def append_event( + self, + run_id: str, + agent_role: str, + event_type: str, + payload: dict[str, Any] | None = None, + node_id: str | None = None, + seq: int | None = None, + ) -> dict[str, Any]: + evt: dict[str, Any] = { + "run_id": run_id, + "agent_role": agent_role, + "event_type": event_type, + "payload": payload or {}, + "node_id": node_id, + "seq": seq or len(self.events.get(run_id, [])) + 1, + } + self.events.setdefault(run_id, []).append(evt) + return evt + + def list_runs_by_session_id(self, session_id: str, *, limit: int = 50) -> list[dict[str, Any]]: + runs = [r for r in self.runs.values() if r.get("session_id") == session_id] + return sorted(runs, key=lambda r: r["id"], reverse=True)[:limit] + + def get_events(self, run_id: str, after_seq: int = 0, limit: int = 200) -> list[dict[str, Any]]: + return [e for e in self.events.get(run_id, []) if e["seq"] > after_seq][:limit] + + def update_run(self, run_id: str, **fields: Any) -> None: + if run_id in self.runs: + self.runs[run_id].update(fields) + + def update_run_metrics_json(self, run_id: str, metrics_json: dict[str, Any]) -> None: + if run_id in self.runs: + self.runs[run_id]["metrics_json"] = metrics_json + + +class FakeTracingQueries: + """最小 Queries 假对象,支持 issue #123。""" + + def __init__(self) -> None: + self.issues: dict[int, dict[str, Any]] = { + 123: { + "number": 123, + "title": "Demo issue", + "state": "open", + "labels": ["bug"], + "html_url": "https://github.com/MoonshotAI/kimi-code/issues/123", + }, + } + + def fetch_issue_by_number(self, number: int) -> dict[str, Any] | None: + return self.issues.get(number) + + +@pytest.fixture +def fake_tracing_store() -> FakeTracingStore: + return FakeTracingStore() + + +@contextmanager +def _record_trace_span( + calls: list[dict[str, Any]], + name: str, + *, + run_type: str | None = None, + **metadata: Any, +) -> Iterator[None]: + calls.append({"name": name, "run_type": run_type, "metadata": metadata}) + yield None + + +def test_run_deep_records_trace_span_metadata( + monkeypatch: pytest.MonkeyPatch, + fake_tracing_store: FakeTracingStore, +) -> None: + """run_deep 调用后,trace_span 与 update_current_span_metadata 均记录 run_id/session_id/agent_role。""" + from api.ops.llm.types import LlmCompletionResult, LlmUsage + from api.ops.orchestrator import core as orchestrator_core + from api.ops.orchestrator.core import run_deep + + span_calls: list[dict[str, Any]] = [] + metadata_calls: list[dict[str, Any]] = [] + + def fake_update(metadata: dict[str, Any]) -> None: + metadata_calls.append(metadata) + + monkeypatch.setattr(orchestrator_core, "trace_span", lambda name, *, run_type=None, **metadata: _record_trace_span(span_calls, name, run_type=run_type, **metadata)) + monkeypatch.setattr(orchestrator_core, "update_current_span_metadata", fake_update) + + def fake_chat_completion(messages: list[dict[str, str]], **kwargs: Any) -> LlmCompletionResult: + return LlmCompletionResult( + content='{"reasoning": "适合", "suggestion": "适合", "confidence": 0.9, "citations": []}', + usage=LlmUsage( + provider="siliconflow", + model="Qwen/Qwen2.5-72B-Instruct", + prompt_tokens=10, + completion_tokens=5, + total_tokens=15, + latency_ms=100, + step=kwargs.get("step", "analyze"), + ), + ) + + def fake_synthesize_answer(query: str, evidence: list[dict[str, Any]], **kwargs: Any) -> LlmCompletionResult: + return LlmCompletionResult( + content="综合建议:适合新手。", + usage=LlmUsage( + provider="siliconflow", + model="Qwen/Qwen2.5-72B-Instruct", + prompt_tokens=8, + completion_tokens=4, + total_tokens=12, + latency_ms=80, + step="synthesize", + ), + ) + + monkeypatch.setattr("api.ops.llm.chat_completion", fake_chat_completion) + monkeypatch.setattr("api.ops.llm.synthesize_answer", fake_synthesize_answer) + monkeypatch.setattr("api.ops.agents.issue_analyst.chat_completion", fake_chat_completion) + monkeypatch.setattr("api.ops.orchestrator.core.synthesize_answer", fake_synthesize_answer) + + queries = FakeTracingQueries() + run = fake_tracing_store.create_run(query="#123 适合吗", route="deep", session_id="sess-deep") + result = run_deep( + run["id"], + "#123 适合吗", + {"issue_number": 123}, + fake_tracing_store, # type: ignore[arg-type] + queries, # type: ignore[arg-type] + session_id="sess-deep", + ) + + assert result["run_id"] == run["id"] + + # update_current_span_metadata 在入口被调用一次 + assert len(metadata_calls) >= 1 + assert metadata_calls[0]["run_id"] == run["id"] + assert metadata_calls[0]["session_id"] == "sess-deep" + assert metadata_calls[0]["agent_role"] == "deep" + + # trace_span 至少包含 handoff 与 review + span_names = [c["name"] for c in span_calls] + assert "handoff" in span_names + assert "review" in span_names + + handoff_call = next(c for c in span_calls if c["name"] == "handoff") + assert handoff_call["metadata"]["run_id"] == run["id"] + assert handoff_call["metadata"]["session_id"] == "sess-deep" + assert handoff_call["metadata"]["agent_role"] == "deep" + + review_call = next(c for c in span_calls if c["name"] == "review") + assert review_call["metadata"]["run_id"] == run["id"] + assert review_call["metadata"]["session_id"] == "sess-deep" + assert review_call["metadata"]["agent_role"] == "deep" + assert "verdict" in review_call["metadata"] + + +def test_run_react_fallback_records_trace_span_metadata( + monkeypatch: pytest.MonkeyPatch, + fake_tracing_store: FakeTracingStore, +) -> None: + """run_react_fallback 调用后,trace span 元数据含 run_id/session_id/agent_role。""" + from api.ops import react_loop as react_module + from api.ops.llm.types import LlmCompletionResult, LlmUsage + from api.ops.react_loop import run_react_fallback + + span_calls: list[dict[str, Any]] = [] + metadata_calls: list[dict[str, Any]] = [] + + def fake_update(metadata: dict[str, Any]) -> None: + metadata_calls.append(metadata) + + monkeypatch.setattr(react_module, "trace_span", lambda name, *, run_type=None, **metadata: _record_trace_span(span_calls, name, run_type=run_type, **metadata)) + monkeypatch.setattr(react_module, "update_current_span_metadata", fake_update) + + def fake_chat_completion(messages: list[dict[str, str]], **kwargs: Any) -> LlmCompletionResult: + return LlmCompletionResult( + content='{"thought": "直接回答", "final_answer": "#123 是 bug。"}', + usage=LlmUsage( + provider="siliconflow", + model="Qwen/Qwen2.5-72B-Instruct", + prompt_tokens=10, + completion_tokens=5, + total_tokens=15, + latency_ms=100, + step=kwargs.get("step", "react"), + ), + ) + + monkeypatch.setattr("api.ops.react_loop.chat_completion", fake_chat_completion) + + queries = FakeTracingQueries() + run = fake_tracing_store.create_run(query="#123 状态", route="react", session_id="sess-react") + result = run_react_fallback( + run["id"], + "#123 状态", + fake_tracing_store, # type: ignore[arg-type] + queries, # type: ignore[arg-type] + session_id="sess-react", + ) + + assert result["run_id"] == run["id"] + + assert len(metadata_calls) >= 1 + assert metadata_calls[0]["run_id"] == run["id"] + assert metadata_calls[0]["session_id"] == "sess-react" + assert metadata_calls[0]["agent_role"] == "react" + + span_names = [c["name"] for c in span_calls] + assert "handoff" in span_names + assert "review" in span_names + + handoff_call = next(c for c in span_calls if c["name"] == "handoff") + assert handoff_call["metadata"]["run_id"] == run["id"] + assert handoff_call["metadata"]["session_id"] == "sess-react" + assert handoff_call["metadata"]["agent_role"] == "react" + + review_call = next(c for c in span_calls if c["name"] == "review") + assert review_call["metadata"]["run_id"] == run["id"] + assert review_call["metadata"]["session_id"] == "sess-react" + assert review_call["metadata"]["agent_role"] == "react" + + +# ----------------------------------------------------------------------------- +# 向后兼容:旧 LANGFUSE_TRACING/LANGSMITH_TRACING 仍生效 +# ----------------------------------------------------------------------------- + + +def test_tracing_provider_langfuse_via_legacy_env(monkeypatch: pytest.MonkeyPatch) -> None: + """未设置 OPS_CHAT_TRACER 但旧 LANGFUSE_TRACING 配置完整时仍启用 langfuse。""" + from api.ops.tracing import tracing_provider + + monkeypatch.delenv("OPS_CHAT_TRACER", raising=False) + monkeypatch.setenv("LANGFUSE_TRACING", "true") + monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-lf-test") + monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-lf-test") + monkeypatch.delenv("LANGSMITH_TRACING", raising=False) + + assert tracing_provider() == "langfuse" + + +def test_tracing_provider_langsmith_via_legacy_env(monkeypatch: pytest.MonkeyPatch) -> None: + """未设置 OPS_CHAT_TRACER 但旧 LANGSMITH_TRACING=true 时仍启用 langsmith。""" + from api.ops.tracing import tracing_provider + + monkeypatch.delenv("OPS_CHAT_TRACER", raising=False) + monkeypatch.delenv("LANGFUSE_TRACING", raising=False) + monkeypatch.setenv("LANGSMITH_TRACING", "true") + + assert tracing_provider() == "langsmith"