Files
BrunoGaoSZandClaude Sonnet 4.6 2360e788d3 feat: implement P10 — SOP profiles, integration tests, smoke gate
TASK 1 — Profile Schema (core/profile/schema.py)
已存在:EvidenceThresholds / ModelRouting / WorkflowStep / ApprovalRule /
Permissions / CachePolicy / Profile,全部 frozen=True,含 tool-reference 验证器。

TASK 2 — 三个行业 SOP Profile YAML
- profiles/presales/profile.yaml        企业预售方案生成
- profiles/security_analysis/profile.yaml  安全事件分析(host_isolation 需审批)
- profiles/knowledge_qa/profile.yaml    知识问答(严格模式,阈值全 0.90)

TASK 3 — loader / registry / 测试
- core/profile/loader.py + registry.py(线程安全单例 + 热更新)
- tests/unit/test_profiles.py(19 个用例)
- tests/unit/test_scorer.py(12 个用例,覆盖 EvidenceScorer 全路径)

TASK 4 — 端到端集成测试 + 成本追踪
- tests/integration/test_full_pipeline.py  7 个 mock-based 测试
- tests/integration/test_cost_tracking.py  3 个成本测试
- scripts/smoke_test.sh 更新为与最终门禁一致的 ruff 规则

Bug fixes(使全部测试通过):
- LangGraph: ask_node 显式初始化 error/pending_approval,确保 ainvoke()
  结果 dict 中始终包含这两个字段
- GraphInterrupt 不再被 act_node 的 except Exception 吞掉
- Citation.model_dump(mode="json") 修复 Decimal JSON 序列化问题
- act_node 直接调用 audit_logger.write_tool_call(集成测试 mock executor 时仍可审计)
- 修复 mypy 错误:config.py 重复字段、encryptor __init__ 返回值、
  registry 属性声明、sanitizer.py 类型注解
- 修复 47 处 ruff lint(F401/F841/E501/I001)
- 添加 pytest-timeout>=2.3.0 依赖

最终门禁全部通过:
  ruff E,F,I 0 errors | mypy 0 errors
  143 unit tests, 80.38% coverage | 11 integration tests

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-02-27 09:21:43 +01:00

627 lines
24 KiB
Python

"""
LangGraph node functions for agent execution flow.
Each node is decorated with @agent_span for OpenTelemetry tracing.
All nodes follow the error boundary pattern: catch all exceptions,
set state.error, and return state (never let exceptions escape).
"""
import json
import logging
from decimal import Decimal
from typing import Any
from langgraph.errors import GraphInterrupt
from core.engine.state import AgentState
# Try to import OTel decorator, fallback to passthrough if not available
try:
from core.observability.tracer import agent_span
except ImportError:
from collections.abc import Callable
from typing import TypeVar
F = TypeVar('F', bound=Callable[..., Any])
def agent_span(node_name: str) -> Callable[[F], F]:
def decorator(func: F) -> F:
return func
return decorator
logger = logging.getLogger(__name__)
# ═══════════════════════════════════════════════════════════════
# Tool Definitions (inline as per spec)
# ═══════════════════════════════════════════════════════════════
EXTRACT_PARAMS_TOOL = {
"name": "extract_user_params",
"description": (
"从用户自然语言输入提取结构化业务参数。"
"识别所有可提取的字段,标记缺失的必填字段,"
"并为每个提取的值提供置信度评分。"
),
"input_schema": {
"type": "object",
"properties": {
"profile_type": {
"type": "string",
"enum": ["presales", "security_analysis", "knowledge_qa", "custom"],
"description": "识别的业务场景类型",
},
"extracted_fields": {
"type": "array",
"description": "成功提取的字段列表",
"items": {
"type": "object",
"properties": {
"field_name": {"type": "string", "description": "字段名称"},
"value": {"description": "提取的值(任意类型)"},
"confidence": {
"type": "number",
"minimum": 0,
"maximum": 1,
"description": "提取置信度 (0-1)",
},
"source_text": {
"type": "string",
"description": "原文中的相关文本片段",
},
},
"required": ["field_name", "value", "confidence"],
},
},
"missing_required_fields": {
"type": "array",
"items": {"type": "string"},
"description": "缺失的必填字段列表",
},
"task_summary": {
"type": "string",
"description": "用户任务的简短摘要",
},
},
"required": ["profile_type", "extracted_fields", "task_summary"],
},
}
GENERATE_PLAN_TOOL = {
"name": "generate_execution_plan",
"description": (
"基于 Profile SOP 和提取的参数生成逐步执行计划。"
"每个步骤指定工具名称、参数、依赖关系和审批要求。"
),
"input_schema": {
"type": "object",
"properties": {
"plan_id": {"type": "string", "description": "计划唯一标识符"},
"steps": {
"type": "array",
"description": "执行步骤列表",
"items": {
"type": "object",
"properties": {
"step_id": {"type": "string", "description": "步骤唯一标识符"},
"tool_name": {"type": "string", "description": "要调用的工具名称"},
"status": {
"type": "string",
"enum": ["pending", "skipped", "requires_approval"],
"description": "步骤初始状态",
},
"params": {
"type": "object",
"description": "工具参数(支持模板语法)",
},
"output_key": {
"type": "string",
"description": "结果存储键名",
},
"on_failure": {
"type": "string",
"enum": ["abort", "skip"],
"description": "失败策略",
},
"depends_on": {
"type": "array",
"items": {"type": "string"},
"description": "依赖的步骤 ID 列表",
},
"approval_required": {
"type": "boolean",
"description": "是否需要人工审批",
},
"estimated_duration_ms": {
"type": "integer",
"description": "预估执行时长(毫秒)",
},
},
"required": ["step_id", "tool_name", "status", "params", "output_key"],
},
},
"plan_summary": {"type": "string", "description": "计划整体摘要"},
},
"required": ["plan_id", "steps", "plan_summary"],
},
}
# ═══════════════════════════════════════════════════════════════
# Helper Functions
# ═══════════════════════════════════════════════════════════════
def _resolve_params(params: dict[str, Any], state: AgentState) -> dict[str, Any]:
"""
Resolve template syntax in tool parameters.
Supported patterns:
- {{user_input}} → state.user_input
- {{user_input.field}} → state.extracted_params.get("field")
- {{steps.step_id.field}} → state.step_outputs["step_id"].get("field")
- {{profile.field}} → (logged, kept as-is)
Unresolvable keys are kept as-is (no exception raised).
"""
import re
unresolved: list[str] = []
def resolve_value(value: Any) -> Any:
if isinstance(value, str):
pattern = r"\{\{([^}]+)\}\}"
matches = re.findall(pattern, value)
for match in matches:
parts = match.strip().split(".")
resolved = None
if parts[0] == "user_input":
if len(parts) == 1:
resolved = state.user_input
elif len(parts) == 2:
resolved = state.extracted_params.get(parts[1])
elif parts[0] == "steps" and len(parts) >= 3:
step_id = parts[1]
field = parts[2]
step_data = state.step_outputs.get(step_id, {})
resolved = step_data.get(field)
elif parts[0] == "profile":
logger.warning("Profile template not resolved: %s", match)
unresolved.append(match)
continue
if resolved is not None:
value = value.replace(f"{{{{{match}}}}}", str(resolved))
else:
logger.warning("Unresolved template: %s", match)
unresolved.append(match)
return value
elif isinstance(value, dict):
return {k: resolve_value(v) for k, v in value.items()}
elif isinstance(value, list):
return [resolve_value(item) for item in value]
else:
return value
resolved_params = resolve_value(params)
if unresolved and isinstance(resolved_params, dict):
if "meta" not in resolved_params:
resolved_params["meta"] = {}
resolved_params["meta"]["unresolved_templates"] = unresolved
return resolved_params # type: ignore[no-any-return]
# ═══════════════════════════════════════════════════════════════
# Node Functions
# ═══════════════════════════════════════════════════════════════
@agent_span("ask")
async def ask_node(state: AgentState) -> AgentState:
"""
Extract structured parameters from user input.
Calls Claude with EXTRACT_PARAMS_TOOL to identify:
- Extracted fields with confidence scores
- Missing required fields
If required fields are missing, sets output_type to "clarification_needed"
and routes to END. Otherwise, populates state.extracted_params.
"""
try:
from core.llm.claude_client import ClaudeClient
from core.llm.prompt_builder import build_ask_user_message, build_system_messages
from core.profile.registry import ProfileRegistry
# Explicitly initialize optional fields so LangGraph tracks them in all code paths.
state.error = None
state.pending_approval = None
logger.info("[ask_node] trace_id=%s user_input=%.100s", state.trace_id, state.user_input)
profile = ProfileRegistry.instance().get(state.profile_type)
system_msgs = build_system_messages(profile.model_dump())
schema = {"input_fields": profile.input_fields} if profile.input_fields else {}
user_msg = build_ask_user_message(state.user_input, schema)
client = ClaudeClient()
tool_input, cost = await client.call_with_tools(
system_msgs=system_msgs,
user_msg=user_msg,
tools=[EXTRACT_PARAMS_TOOL],
tool_choice={"type": "any"},
trace_id=state.trace_id,
)
extracted_fields = tool_input.get("extracted_fields", [])
missing_fields = tool_input.get("missing_required_fields", [])
if missing_fields:
state.output_type = "clarification_needed"
missing_list = ", ".join(missing_fields)
state.final_output = (
f"需要补充以下信息才能继续:{missing_list}。请提供这些字段的值。"
)
logger.info("[ask_node] Clarification needed: %s", missing_fields)
return state
state.extracted_params = {
field["field_name"]: field["value"] for field in extracted_fields
}
state.total_cost_cny += cost.cost_cny
logger.info("[ask_node] Extracted params: %s", list(state.extracted_params.keys()))
return state
except Exception as e:
logger.exception("[ask_node] Unhandled exception: %s", e)
state.error = {
"code": "ASK_NODE_ERROR",
"message": str(e),
"node": "ask",
"trace_id": state.trace_id,
}
return state
@agent_span("plan")
async def plan_node(state: AgentState) -> AgentState:
"""
Generate execution plan based on Profile SOP.
Calls Claude with GENERATE_PLAN_TOOL to create step-by-step plan.
Tool whitelist enforcement is delegated to ToolExecutor at execution time.
"""
try:
from core.llm.claude_client import ClaudeClient
from core.llm.prompt_builder import build_plan_message, build_system_messages
from core.profile.registry import ProfileRegistry
logger.info("[plan_node] trace_id=%s user_role=%s", state.trace_id, state.user_role)
profile = ProfileRegistry.instance().get(state.profile_type)
# Build SOP text from profile description + workflow steps
sop_text = profile.description + "\n\n工作流步骤:\n" + json.dumps(
[s.model_dump() for s in profile.workflow_steps],
ensure_ascii=False,
indent=2,
)
system_msgs = build_system_messages(profile.model_dump())
user_msg = build_plan_message(
extracted_params=state.extracted_params,
sop=sop_text,
user_permissions={"role": state.user_role, "data_scope": state.data_scope},
)
client = ClaudeClient()
tool_input, cost = await client.call_with_tools(
system_msgs=system_msgs,
user_msg=user_msg,
tools=[GENERATE_PLAN_TOOL],
tool_choice={"type": "any"},
trace_id=state.trace_id,
)
steps = tool_input.get("steps", [])
if not steps:
state.error = {
"code": "PLAN_EMPTY",
"message": "生成的执行计划为空",
"node": "plan",
"trace_id": state.trace_id,
}
return state
state.execution_plan = steps
state.total_cost_cny += cost.cost_cny
logger.info("[plan_node] Generated %d steps", len(steps))
return state
except Exception as e:
logger.exception("[plan_node] Unhandled exception: %s", e)
state.error = {
"code": "PLAN_NODE_ERROR",
"message": str(e),
"node": "plan",
"trace_id": state.trace_id,
}
return state
@agent_span("act")
async def act_node(state: AgentState) -> AgentState:
"""
Execute tools according to the execution plan.
Processes steps sequentially from current_step_index:
1. Resolve parameter templates
2. Check for approval requirements (interrupt() if needed)
3. Execute tool via ToolExecutor
4. Store result summary + citations in step_outputs
5. Handle failures based on on_failure strategy
"""
try:
from langgraph.types import interrupt
from core.audit.logger import AuditLogger
from core.profile.registry import ProfileRegistry
from core.tools.base import ToolNotAllowedError
from core.tools.executor import ToolExecutor
from core.tools.registry import get_registry
logger.info(
"[act_node] trace_id=%s step_index=%d",
state.trace_id,
state.current_step_index,
)
profile = ProfileRegistry.instance().get(state.profile_type)
tool_registry = get_registry()
audit_logger = AuditLogger()
tool_executor = ToolExecutor(registry=tool_registry, audit_logger=audit_logger)
while state.current_step_index < len(state.execution_plan):
step = state.execution_plan[state.current_step_index]
step_id = step["step_id"]
tool_name = step["tool_name"]
logger.info("[act_node] Executing step %s: %s", step_id, tool_name)
resolved_params = _resolve_params(step.get("params", {}), state)
# ── Approval gate ──────────────────────────────────────────────────
if step.get("approval_required", False):
approval_payload = {
"step_id": step_id,
"tool_name": tool_name,
"params": resolved_params,
"reason": f"步骤 {step_id} 需要人工审批",
}
state.pending_approval = approval_payload
logger.info("[act_node] Approval required for step %s, interrupting", step_id)
# Pause until resumed with Command(resume=True/False)
approved = interrupt(approval_payload)
if not approved:
state.error = {
"code": "APPROVAL_REJECTED",
"message": f"步骤 {step_id} 审批被拒绝",
"node": "act",
"trace_id": state.trace_id,
}
return state
state.pending_approval = None
# ── Tool execution ─────────────────────────────────────────────────
try:
result = await tool_executor.execute_tool(
tool_name=tool_name,
raw_params=resolved_params,
allowed_tools=profile.allowed_tools,
user_id=state.user_id,
tenant_id=state.tenant_id,
data_scope=state.data_scope[0] if state.data_scope else "",
trace_id=state.trace_id,
step_id=step_id,
)
except ToolNotAllowedError as e:
state.error = {
"code": "TOOL_NOT_ALLOWED",
"message": str(e),
"tool": tool_name,
"node": "act",
"trace_id": state.trace_id,
}
logger.error("[act_node] Tool not allowed: %s", tool_name)
return state
# ── Audit every tool call (success or failure) ──────────────────────
await audit_logger.write_tool_call(
trace_id=state.trace_id,
step_id=step_id,
tool_name=tool_name,
result=result,
actor=state.user_id,
tenant_id=state.tenant_id,
)
if not result.success:
on_failure = step.get("on_failure", "abort")
if on_failure == "abort":
state.error = {
"code": "TOOL_FAILED",
"message": result.error or "工具执行失败",
"tool": tool_name,
"step_id": step_id,
"node": "act",
"trace_id": state.trace_id,
}
logger.error("[act_node] Tool %s failed, aborting", tool_name)
return state
else:
logger.warning(
"[act_node] Tool %s failed, continuing (on_failure=%s)",
tool_name,
on_failure,
)
# ── Store result ───────────────────────────────────────────────────
output_key = step["output_key"]
summary = (
result.data.get("summary", str(result.data)[:200])
if isinstance(result.data, dict)
else str(result.data)[:200] if result.data
else "执行完成"
)
data_ref = result.data.get("data_ref") if isinstance(result.data, dict) else None
state.add_step_output(output_key, summary, data_ref)
# Store citations for evidence scoring (mode="json" converts Decimal → str)
if result.citations:
state.step_outputs[output_key]["citations"] = [
c.model_dump(mode="json") for c in result.citations
]
state.current_step_index += 1
logger.info("[act_node] All steps completed")
return state
except GraphInterrupt:
# LangGraph interrupt must propagate — do not catch as an error.
raise
except Exception as e:
logger.exception("[act_node] Unhandled exception: %s", e)
state.error = {
"code": "ACT_NODE_ERROR",
"message": str(e),
"node": "act",
"trace_id": state.trace_id,
}
return state
@agent_span("verify")
async def verify_node(state: AgentState) -> AgentState:
"""
Assess evidence quality of execution results.
Collects citations from all step outputs and scores them via EvidenceScorer.
Sets evidence_score, evidence_level, and verified_citations.
"""
try:
from core.profile.registry import ProfileRegistry
from core.verify.scorer import EvidenceScorer
logger.info("[verify_node] trace_id=%s", state.trace_id)
profile = ProfileRegistry.instance().get(state.profile_type)
# Collect all citations from step outputs
all_citations: list[dict[str, Any]] = []
for step_output in state.step_outputs.values():
if isinstance(step_output, dict) and "citations" in step_output:
all_citations.extend(step_output["citations"])
scorer = EvidenceScorer()
scorer_result = scorer.score(
citations=all_citations,
step_outputs=state.step_outputs,
required_fields=profile.required_output_fields,
historical_accuracy=Decimal("0.8"),
)
state.evidence_score = scorer_result.score
state.evidence_level = scorer_result.level
state.citations = [c.model_dump() for c in scorer_result.verified_citations]
logger.info(
"[verify_node] Evidence score=%s level=%s",
state.evidence_score,
state.evidence_level,
)
return state
except Exception as e:
logger.exception("[verify_node] Unhandled exception: %s", e)
state.error = {
"code": "VERIFY_NODE_ERROR",
"message": str(e),
"node": "verify",
"trace_id": state.trace_id,
}
return state
@agent_span("report")
async def report_node(state: AgentState) -> AgentState:
"""
Generate final output based on evidence level.
If evidence is insufficient, sets output_type to "insufficient_data"
and explains what's missing. Otherwise, calls Claude to generate
a formatted report using the output template.
"""
try:
from core.llm.claude_client import ClaudeClient
from core.llm.prompt_builder import build_report_message, build_system_messages
from core.profile.registry import ProfileRegistry
logger.info(
"[report_node] trace_id=%s evidence_level=%s",
state.trace_id,
state.evidence_level,
)
profile = ProfileRegistry.instance().get(state.profile_type)
if state.evidence_level == "insufficient":
state.output_type = "insufficient_data"
state.final_output = (
"证据不足,无法生成可靠的输出。\n"
"缺失的数据:请补充更多相关信息或调整查询条件。"
)
logger.info("[report_node] Insufficient evidence, cannot generate report")
return state
system_msgs = build_system_messages(profile.model_dump())
user_msg = build_report_message(
evidence_level=state.evidence_level or "sufficient",
verified_data=state.step_outputs,
citations=state.citations,
template_hint=profile.output_template,
)
client = ClaudeClient()
text, cost = await client.call_text(
system_msgs=system_msgs,
user_msg=user_msg,
max_tokens=4096,
trace_id=state.trace_id,
)
state.final_output = text
state.output_type = "document" if profile.output_format == "docx" else "text"
state.total_cost_cny += cost.cost_cny
logger.info("[report_node] Report generated, length=%d", len(state.final_output))
return state
except Exception as e:
logger.exception("[report_node] Unhandled exception: %s", e)
state.error = {
"code": "REPORT_NODE_ERROR",
"message": str(e),
"node": "report",
"trace_id": state.trace_id,
}
return state